mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 06:16:07 -10:00
A user reported the Workspaces screen looked empty with "no options," and
that all but one of the workspace screenshots were blank/identical.
Both were real problems:
Docs (docs/guides/projects-workflow/workspaces.md)
- Add a "Before you start" section: workspaces are gated behind the
experimental "Enable Isolated Workspaces" flag, the screen is monitor-only
(no manual create), and it stays empty until an agent runs an isolated
task — with the actual empty-state strings ("No workspace activity yet." /
"No non-default workspace activity yet.").
- Correct the detail screen: it now has Tasks / Services / Configuration /
Runtime logs / Routines tabs (+ plugin tabs), not "three tabs." Rename the
Issues tab to Tasks (its real label) and add a Services tab section. The
former "Workspace commands panel above the tabs" is now the Services tab
plus header quick controls.
- Clarify the company-wide Workspaces screen vs. the per-project Workspaces
tab and the surfacing rules. Fix the misleading "multiple isolated
workspaces" caption.
Screenshots
- Root cause: the seed only created a *project* workspace and pointed the
execution-workspace detail routes at its id, so those pages were captured
mid "Loading workspace…". Execution workspaces have no REST create endpoint
(they're provisioned by a real heartbeat), so seed-execution-workspace.mjs
now inserts a realistic one directly into the instance's embedded Postgres:
an isolated git-worktree workspace with runtime config, a running service,
provision/start/failed-job operation logs, and linked tasks.
- routes.mjs: add the {executionWorkspaceId} token, repoint the detail routes
to it, add a Services tab target, and drop the duplicate
org/execution-workspaces-list target (same URL as workspaces/list).
- Recaptured workspaces/{list,configuration,services,runtime-logs,issues} in
light + dark; all now render real content.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
238 lines
9.2 KiB
JavaScript
238 lines
9.2 KiB
JavaScript
/**
|
|
* seed-execution-workspace.mjs — provisions a realistic *execution workspace*
|
|
* directly in the screenshot instance's embedded Postgres.
|
|
*
|
|
* Why direct DB access instead of REST: execution workspaces are never created
|
|
* through a public API — they're born when an agent's heartbeat picks up a task
|
|
* and the workspace-realization service carves out a git worktree. Reproducing a
|
|
* full isolated agent run in the screenshot harness isn't practical, so for
|
|
* screenshots we insert the rows the UI reads: one execution_workspaces row (with
|
|
* runtime config in metadata.config.workspaceRuntime), a running runtime service,
|
|
* a few workspace_operations log rows, and a couple of linked issues.
|
|
*
|
|
* Without this, the execution-workspace detail screenshots capture an endless
|
|
* "Loading workspace…" state, because the detail page is pointed at a *project*
|
|
* workspace id that the page can't resolve.
|
|
*
|
|
* The connection target is the embedded Postgres the onboard server starts under
|
|
* PAPERCLIP_HOME. The `postgres` client is resolved from the parent repo's
|
|
* node_modules (it isn't a dependency of paperclip-docs).
|
|
*/
|
|
|
|
import { createRequire } from "node:module";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import {
|
|
EMBEDDED_POSTGRES_PORT,
|
|
INSTANCE_ID,
|
|
PARENT_REPO,
|
|
scratchHome,
|
|
} from "./config.mjs";
|
|
|
|
/** Resolve the `postgres` (postgres.js) client from the parent repo. */
|
|
function loadPostgres() {
|
|
// Base the require on a path inside the parent's db package so pnpm's
|
|
// node_modules layout resolves `postgres` correctly.
|
|
const requireFromParent = createRequire(resolve(PARENT_REPO, "packages/db/package.json"));
|
|
return requireFromParent("postgres");
|
|
}
|
|
|
|
/**
|
|
* Work out which port the embedded Postgres is listening on. The onboard server
|
|
* prefers `embeddedPostgresPort` (default 54329) but falls back to the next free
|
|
* port if it's taken, so we read the live value from the cluster's
|
|
* `postmaster.pid` (line 4 is the port), then the instance config.json, then the
|
|
* compiled-in default.
|
|
*/
|
|
function resolvePostgresPort(home) {
|
|
const instanceRoot = resolve(home, "instances", INSTANCE_ID);
|
|
|
|
try {
|
|
const pidFile = resolve(instanceRoot, "db", "postmaster.pid");
|
|
const lines = readFileSync(pidFile, "utf8").split("\n");
|
|
const port = Number.parseInt(lines[3]?.trim() ?? "", 10);
|
|
if (Number.isInteger(port) && port > 0) return port;
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
|
|
try {
|
|
const cfg = JSON.parse(readFileSync(resolve(instanceRoot, "config.json"), "utf8"));
|
|
const port = cfg?.database?.embeddedPostgresPort;
|
|
if (Number.isInteger(port) && port > 0) return port;
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
|
|
return EMBEDDED_POSTGRES_PORT;
|
|
}
|
|
|
|
/**
|
|
* Insert a demo execution workspace and its supporting rows.
|
|
*
|
|
* @param {object} input
|
|
* @param {string} input.companyId
|
|
* @param {string} input.projectId
|
|
* @param {string|null} input.projectWorkspaceId the project's "main" workspace
|
|
* @param {string|null} input.sourceIssueId issue that "provisioned" it
|
|
* @param {string[]} input.linkedIssueIds issues to link (Tasks tab + list)
|
|
* @param {string} input.issuePrefix e.g. "ACM" — used for branch name
|
|
* @returns {Promise<{ executionWorkspaceId: string } | null>}
|
|
*/
|
|
export async function seedExecutionWorkspace(input) {
|
|
const { companyId, projectId } = input;
|
|
if (!companyId || !projectId) {
|
|
console.warn("[seed-ws] missing companyId/projectId; skipping execution workspace seed");
|
|
return null;
|
|
}
|
|
|
|
const home = scratchHome();
|
|
const port = resolvePostgresPort(home);
|
|
const connectionString = `postgres://paperclip:[email protected]:${port}/paperclip`;
|
|
|
|
const postgres = loadPostgres();
|
|
const sql = postgres(connectionString, { max: 1, idle_timeout: 5, onnotice: () => {} });
|
|
|
|
const prefix = input.issuePrefix ?? "ACM";
|
|
const branchName = `${prefix}-204-workspace`;
|
|
const cwd = `/srv/acme/website-worktrees/${prefix}-204`;
|
|
const workspaceRuntime = {
|
|
commands: [
|
|
{
|
|
id: "web",
|
|
name: "web",
|
|
kind: "service",
|
|
command: "pnpm dev",
|
|
cwd: ".",
|
|
port: { type: "auto" },
|
|
},
|
|
{
|
|
id: "db-migrate",
|
|
name: "db:migrate",
|
|
kind: "job",
|
|
command: "pnpm db:migrate",
|
|
cwd: ".",
|
|
},
|
|
],
|
|
};
|
|
const metadata = { config: { workspaceRuntime } };
|
|
|
|
try {
|
|
// Reuse an existing demo workspace if a prior run already created one.
|
|
const existing = await sql`
|
|
select id from execution_workspaces
|
|
where company_id = ${companyId} and branch_name = ${branchName}
|
|
limit 1
|
|
`;
|
|
let executionWorkspaceId = existing[0]?.id ?? null;
|
|
|
|
if (!executionWorkspaceId) {
|
|
const inserted = await sql`
|
|
insert into execution_workspaces
|
|
(company_id, project_id, project_workspace_id, source_issue_id,
|
|
mode, strategy_type, name, status, cwd, repo_url, base_ref, branch_name,
|
|
provider_type, metadata, last_used_at, opened_at)
|
|
values
|
|
(${companyId}, ${projectId}, ${input.projectWorkspaceId ?? null}, ${input.sourceIssueId ?? null},
|
|
'isolated_workspace', 'git_worktree', ${"Hero section worktree"}, 'active',
|
|
${cwd}, ${"https://github.com/acme-robotics/website"}, 'origin/main', ${branchName},
|
|
'git_worktree', ${sql.json(metadata)}, now(), now() - interval '2 hours')
|
|
returning id
|
|
`;
|
|
executionWorkspaceId = inserted[0].id;
|
|
console.log(`[seed-ws] created execution workspace "${branchName}" (${executionWorkspaceId})`);
|
|
} else {
|
|
console.log(`[seed-ws] reusing execution workspace "${branchName}" (${executionWorkspaceId})`);
|
|
await sql`delete from workspace_operations where execution_workspace_id = ${executionWorkspaceId}`;
|
|
await sql`delete from workspace_runtime_services where execution_workspace_id = ${executionWorkspaceId}`;
|
|
}
|
|
|
|
// A running web service so the Services tab + list show live status.
|
|
await sql`
|
|
insert into workspace_runtime_services
|
|
(id, company_id, project_id, execution_workspace_id, scope_type, service_name,
|
|
status, lifecycle, command, cwd, port, url, provider, health_status,
|
|
last_used_at, started_at)
|
|
values
|
|
(gen_random_uuid(), ${companyId}, ${projectId}, ${executionWorkspaceId},
|
|
'execution_workspace', 'web', 'running', 'shared', 'pnpm dev', ${cwd},
|
|
5173, 'http://127.0.0.1:5173', 'local_process', 'healthy',
|
|
now(), now() - interval '90 minutes')
|
|
`;
|
|
|
|
// A handful of operation log rows for the Runtime logs tab — a successful
|
|
// provision and service start, plus one failed job so the red stderr
|
|
// excerpt is visible.
|
|
const operations = [
|
|
{
|
|
phase: "workspace_provision",
|
|
command: `git worktree add ../${prefix}-204 -b ${branchName} origin/main`,
|
|
status: "succeeded",
|
|
exitCode: 0,
|
|
stdout: `Preparing worktree (new branch '${branchName}')\nHEAD is now at 9f2c1ab Merge pull request #482`,
|
|
stderr: null,
|
|
startOffset: "2 hours",
|
|
endOffset: "118 minutes",
|
|
},
|
|
{
|
|
phase: "workspace_provision",
|
|
command: "pnpm install --frozen-lockfile",
|
|
status: "succeeded",
|
|
exitCode: 0,
|
|
stdout: "Lockfile is up to date, resolution step is skipped\nPackages: +812\nDone in 9.4s",
|
|
stderr: null,
|
|
startOffset: "117 minutes",
|
|
endOffset: "115 minutes",
|
|
},
|
|
{
|
|
phase: "service_start",
|
|
command: "pnpm dev",
|
|
status: "running",
|
|
exitCode: null,
|
|
stdout: "VITE v5.4.2 ready in 412 ms\n➜ Local: http://127.0.0.1:5173/",
|
|
stderr: null,
|
|
startOffset: "90 minutes",
|
|
endOffset: null,
|
|
},
|
|
{
|
|
phase: "job_run",
|
|
command: "pnpm db:migrate",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
stdout: null,
|
|
stderr: "error: relation \"hero_variants\" already exists\n at Migrator.run (db/migrate.ts:54)",
|
|
startOffset: "40 minutes",
|
|
endOffset: "39 minutes",
|
|
},
|
|
];
|
|
|
|
for (const op of operations) {
|
|
const finishedAt = op.endOffset ? sql`now() - interval ${sql.unsafe(`'${op.endOffset}'`)}` : null;
|
|
await sql`
|
|
insert into workspace_operations
|
|
(company_id, execution_workspace_id, phase, command, cwd, status, exit_code,
|
|
stdout_excerpt, stderr_excerpt, started_at, finished_at)
|
|
values
|
|
(${companyId}, ${executionWorkspaceId}, ${op.phase}, ${op.command}, ${cwd},
|
|
${op.status}, ${op.exitCode}, ${op.stdout}, ${op.stderr},
|
|
now() - interval ${sql.unsafe(`'${op.startOffset}'`)}, ${finishedAt})
|
|
`;
|
|
}
|
|
console.log(`[seed-ws] inserted ${operations.length} workspace operations + 1 runtime service`);
|
|
|
|
// Link issues so the Tasks tab and the Workspaces list populate.
|
|
const linkIds = (input.linkedIssueIds ?? []).filter(Boolean);
|
|
if (linkIds.length > 0) {
|
|
await sql`
|
|
update issues set execution_workspace_id = ${executionWorkspaceId}
|
|
where company_id = ${companyId} and id in ${sql(linkIds)}
|
|
`;
|
|
console.log(`[seed-ws] linked ${linkIds.length} issue(s) to the execution workspace`);
|
|
}
|
|
|
|
return { executionWorkspaceId };
|
|
} finally {
|
|
await sql.end({ timeout: 5 });
|
|
}
|
|
}
|