Files
631a6a0c53 Release docs for paperclip v2026.817.0 (content + screenshot overhaul) (#95)
Ships the v2026.817.0 docs to live: the doc content that was authored
and verified
earlier, plus a full screenshot overhaul.

The content half (`cdf81e2` and the nightly commits behind it) has
already been
reviewed. **The screenshots are the part that needs eyes** — `f24d734`,
342 PNGs.

## Screenshots

Everything was recaptured against the release commit
`213dabab4f8e1f3bb1803a2924c0fea1289fcd4c`
(`upstream/candidate/release-2026.817.0`). The previous shots were 375
parent-commits stale (v2026.720.0), with 228 of 310 entries flagged
pending. All
342 registry entries now carry that SHA.

> ⚠️ Worth knowing: the release commit is **not** an ancestor of the
parent's
> `master`, and local `master` was four days *older* than it. Capturing
against
> `master` would have both missed release UI and leaked 93 post-tag
commits' worth
> of UI into the release docs.

### New coverage (34 entries)

Three guides shipped with **no images at all** despite being 115–136
lines. They
now have them:

| Guide | Shots |
|---|---|
| `guides/day-to-day/decisions.md` | queue, an agent-proposed decision
with its options, triage strip, filter panel, named queue, Decided
curtain, empty state |
| `experimental/status-cards.md` | board, New card dialog, and all four
drawer tabs |
| `experimental/task-chat.md` | thread, composer mode picker, side pane
|
| `reference/api/secrets.md` | the board-side Proposals tab |

Chat-Style Tasks is captured in a **separate pass**:
`enableTaskChatRedesign` is
instance-wide, so leaving it on would have turned every classic
issue/task/work-mode
shot into the redesigned chat.

## Two defects that were already live

Both were shipping in the docs before this PR:

1. **`issues/detail-chat.png` and the `tasks/*` shots were mostly plugin
debug
output.** The Kitchen Sink example plugin mounts a panel into the issue
page
that dumps the raw issue JSON, and the seed installed it. Those guides
were
illustrating a debug panel instead of the task page. The seed no longer
   installs it.
2. **The seeded task thread was a monologue** — four board-user messages
in a row,
because the board actor cannot post agent comments. It is now a real
two-sided
   exchange.

## Targets repointed because the UI moved

| Target | Why |
|---|---|
| `activity/*` | `Activity.tsx` deleted; `/activity` renders
`pages/audit/CompanyActivity.tsx` |
| `experimental/cloud-upstream` | `CloudUpstream.tsx` deleted; the route
now redirects to `/company/export` |
| `settings/*`, `plugins/*`, `adapters/*` | `/instance/settings/*` are
legacy redirects now |
| `dashboard/stale-tasks-panel` | panel removed from `Dashboard.tsx` —
target dropped |

Plus eight targets with **silent selector drift**. A failed interaction
step falls
back to "whatever state was reached", so these had been quietly
producing wrong
images — a plain inbox where the doc promised a mode picker, a whole
dashboard
where it promised one panel. Full table in
`docs/user-guides/screenshots/SCREENSHOTS_PLAN_v2026.817.0.md` §C2.

## Pipeline

Decisions, status-card summaries and secret proposals are all
**agent-authored**,
and the board actor the seed runs as is refused by every one of them
(`"Agent run context required"`, `"Secret proposals require a verified
run-bound
agent token"`). Rather than insert rows behind the API,
`seed-new-surfaces.mjs`
borrows the two doors a real run already has — a board-minted agent API
key, and a
locally signed agent JWT — so every write goes through the production
code path.

Also:
- `capture.mjs` records a real `captured_against` on a detached checkout
instead of
the literal string `"HEAD"` (which is what every pre-2026.817 entry
recorded), and
  gains a `scrollTo` step for content below the fold.
- `run.mjs` gains `--serve` for iterating on the seed, the task-chat
phase, and a
keeper that holds the demo board's issue statuses steady — the demo
`process`
agents finish runs without declaring a disposition, so the stalled-work
sweep
re-blocks issues every few minutes over a capture that runs far longer.

## Reviewing

Light and dark both captured at 1440×900 @2x. The new surfaces are the
ones to
scrutinise; the rest is a like-for-like refresh.

### One leftover

Dropping `dashboard/stale-tasks-panel` leaves
`{light,dark}/dashboard/stale-tasks-panel.png`
tracked but unused — they need deleting by hand.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: DevRel Gemini <[email protected]>
Co-authored-by: Paperclip <[email protected]>
Co-authored-by: Dotta <[email protected]>
Co-authored-by: ClaudeCoder <[email protected]>
2026-08-18 12:43:56 +02:00

1069 lines
48 KiB
JavaScript

/**
* seed.mjs — create a rich, realistic demo dataset on a running Paperclip
* instance so the screenshot pipeline captures a company that looks genuinely
* in-use rather than a blank demo.
*
* Runs against an instance in local_trusted mode where every request is
* auto-authorized as instance admin (no auth headers needed).
*
* Every individual API call is soft-failed (try/catch, logged, non-fatal) so a
* single unsupported endpoint never aborts the whole seed — capture simply
* skips any route whose id ends up null.
*
* ── Endpoint evidence (verified against parent repo) ─────────────────────────
* POST /api/companies companies.ts:291 createCompanySchema (company.ts:16)
* note: issuePrefix is AUTO-DERIVED (first 3 uppercase alpha chars of
* name) and not settable via REST. "Acme Robotics" → "ACM". The real
* prefix is read back and written to .seed-ids.json as companyPrefix.
* PATCH /api/companies/:id companies.ts:329 updateCompanySchema (company.ts:25)
* { description?, budgetMonthlyCents?, brandColor?:#rrggbb, ... }
* POST /api/companies/:id/skills company-skills.ts:162 companySkillCreateSchema (company-skill.ts:154)
* POST /api/companies/:id/agents agents.ts:2123 createAgentSchema (agent.ts:66)
* { name, role?, title?, icon?, reportsTo?, desiredSkills?, adapterType, adapterConfig?,
* instructionsBundle?:{entryFile?,files}, budgetMonthlyCents? }
* POST /api/companies/:id/goals goals.ts:32 createGoalSchema (goal.ts:4) level∈company|team|agent|task
* POST /api/companies/:id/projects projects.ts:118 createProjectSchema (project.ts:113) status∈backlog|planned|in_progress|completed|cancelled
* POST /api/projects/:id/workspaces projects.ts:258 createProjectWorkspaceSchema (project.ts:84) requires cwd or repoUrl
* POST /api/companies/:id/issues issues.ts:3505 createIssueSchema (issue.ts:371) status∈backlog|todo|in_progress|in_review|done|blocked|cancelled, priority∈critical|high|medium|low, parentId for sub-issues
* POST /api/issues/:id/comments issues.ts:5611 addIssueCommentSchema (issue.ts:535) { body, authorType?:user|agent|system }
* POST /api/companies/:id/routines routines.ts:96 createRoutineSchema (routine.ts:52)
* POST /api/routines/:id/run routines.ts:414 runRoutineSchema (routine.ts:147) requires assigneeAgentId; persists a run row before any LLM work
* POST /api/companies/:id/invites access.ts:2946 createCompanyInviteSchema (access.ts:12) { allowedJoinTypes, humanRole? }
* GET /api/plugins/examples plugins.ts:792 -> [{ packageName, localPath, ... }]
* POST /api/plugins/install plugins.ts:1006 { packageName, version?, isLocalPath? }
* POST /api/companies/:id/cost-events costs.ts:73 createCostEventSchema (cost.ts:4) { agentId, provider, model, costCents, occurredAt, ... }
* POST /api/companies/:id/finance-events costs.ts:102 createFinanceEventSchema (finance.ts:4) { eventKind, biller, amountCents, occurredAt, direction? }
* ─────────────────────────────────────────────────────────────────────────────
*/
import { writeFileSync } from "node:fs";
import { BASE_URL, COMPANY_NAME, SEED_IDS_PATH, mintAgentJwt, openInstanceDb } from "./config.mjs";
import { seedExecutionWorkspace } from "./seed-execution-workspace.mjs";
import { seedNewSurfaces } from "./seed-new-surfaces.mjs";
// ── HTTP helpers ────────────────────────────────────────────────────────────
async function api(method, path, body, baseUrl) {
const init = { method, headers: { "Content-Type": "application/json" } };
if (body !== undefined) init.body = JSON.stringify(body);
const res = await fetch(`${baseUrl}${path}`, init);
if (!res.ok) {
const text = await res.text().catch(() => "(unreadable body)");
throw new Error(`${method} ${path} → HTTP ${res.status}: ${text}`);
}
// Some endpoints (202) may return empty bodies.
const text = await res.text();
return text ? JSON.parse(text) : {};
}
const get = (path, base) => api("GET", path, undefined, base);
const post = (path, body, base) => api("POST", path, body, base);
const patch = (path, body, base) => api("PATCH", path, body, base);
/** Normalise list responses that may be an array or `{ items: [...] }`. */
const asList = (v) => (Array.isArray(v) ? v : (v?.items ?? []));
/**
* Benign process-adapter config used for the demo org. The screenshot instance
* runs with NO LLM provider configured, and the scheduler heartbeats every
* enabled agent. A `process` agent created with an empty adapterConfig has no
* command to run, so each heartbeat fails instantly and the agent renders with a
* red "error" status ("Ada failed after 1 second", dashboard "N errors") — which
* makes the captured UI look broken. Giving every demo agent a command that
* always exits 0 turns those heartbeats into clean, completed runs, so agents
* render as healthy idle/working with a tidy transcript instead of errors.
*/
const DEMO_AGENT_CONFIG = {
command: "sh",
args: [
"-c",
"echo 'Reviewing assigned work…'; echo 'Nothing actionable this cycle — standing by.'; echo 'Done.'",
],
timeoutSec: 30,
};
/**
* The demo board's intended issue statuses.
*
* Module-level because two things need them: the initial seed, and
* `reassertIssueStatuses()` below, which run.mjs calls on a timer while
* capture is in flight.
*/
const ISSUE_SPECS = [
{ title: "Design the homepage hero section", status: "in_progress", priority: "high", assignee: "Cleo", project: "Website", goal: true },
{ title: "Set up CI/CD pipeline", status: "done", priority: "medium", assignee: "Bob", project: "Website" },
{ title: "Write onboarding docs", status: "todo", priority: "low", assignee: "Eve", project: "Website" },
{ title: "Fix flaky integration test", status: "in_review", priority: "high", assignee: "Dax", project: "Website" },
{ title: "Add dark mode to the portal", status: "todo", priority: "medium", assignee: "Bob", project: "Website" },
{ title: "Investigate battery telemetry spike", status: "blocked", priority: "critical", assignee: "Eve", project: "Mobile App" },
{ title: "Mobile: implement push notifications", status: "backlog", priority: "medium", assignee: "Bob", project: "Mobile App" },
{ title: "Accessibility audit of checkout flow", status: "todo", priority: "high", assignee: "Cleo", project: "Website" },
];
/**
* Run a labelled step, swallowing and logging errors so the seed continues.
* Returns the callback's result, or `fallback` (default null) on failure.
*/
async function step(label, fn, fallback = null) {
try {
return await fn();
} catch (err) {
console.warn(`[seed] ${label} failed (non-fatal): ${err.message}`);
return fallback;
}
}
// ── Seed ──────────────────────────────────────────────────────────────────────
export default async function seed({ baseUrl = BASE_URL } = {}) {
console.log(`[seed] targeting ${baseUrl}`);
// ── 1. Company (find-or-create) ─────────────────────────────────────────────
let company;
let companyExisted = false;
const existingCompanies = asList(await get("/api/companies", baseUrl));
company = existingCompanies.find((c) => c.name === COMPANY_NAME);
if (company) {
companyExisted = true;
console.log(`[seed] reusing company "${company.name}" (${company.id}), prefix=${company.issuePrefix}`);
} else {
company = await post(
"/api/companies",
{
name: COMPANY_NAME,
description:
"Acme Robotics builds autonomous warehouse robots. This workspace runs the engineering, design, and research org end to end.",
},
baseUrl,
);
console.log(`[seed] created company "${company.name}" (${company.id}), prefix=${company.issuePrefix}`);
}
const companyId = company.id;
const companyPrefix = company.issuePrefix;
const C = (p) => `/api/companies/${companyId}${p}`;
// ── 2. Company branding + budget ────────────────────────────────────────────
await step("company branding/budget", async () => {
await patch(
`/api/companies/${companyId}`,
{
description:
"Acme Robotics builds autonomous warehouse robots. This workspace runs the engineering, design, and research org end to end.",
brandColor: "#6366f1",
budgetMonthlyCents: 750_000, // $7,500 / month
},
baseUrl,
);
console.log("[seed] set company brand color + monthly budget");
});
// ── 3. Company skills ───────────────────────────────────────────────────────
// Created before agents so agents can reference them in desiredSkills.
const SKILLS = [
{
name: "Code Review",
slug: "code-review",
description: "Review pull requests for correctness, style, and security.",
markdown:
"# Code Review\n\nReview diffs for correctness, security, and clarity. Leave actionable comments and approve only when CI is green.",
},
{
name: "Release Notes",
slug: "release-notes",
description: "Draft user-facing release notes from merged changes.",
markdown:
"# Release Notes\n\nSummarise merged changes in friendly, user-facing language grouped by Highlights, Fixes, and Internal.",
},
{
name: "Customer Triage",
slug: "customer-triage",
description: "Triage inbound support reports and route them.",
markdown:
"# Customer Triage\n\nClassify inbound reports by severity, reproduce when possible, and route to the right owner.",
},
{
name: "Incident Response",
slug: "incident-response",
description: "Coordinate response during production incidents.",
markdown:
"# Incident Response\n\nDeclare severity, post a status update every 15 minutes, and write a post-mortem within 48 hours.",
},
];
const existingSkills = asList(await step("list skills", () => get(C("/skills"), baseUrl), []));
for (const s of SKILLS) {
const found = existingSkills.find((x) => x.name === s.name || x.slug === s.slug);
if (found) {
console.log(`[seed] reusing skill "${s.name}"`);
continue;
}
await step(`create skill ${s.name}`, async () => {
await post(C("/skills"), s, baseUrl);
console.log(`[seed] created skill "${s.name}"`);
});
}
// ── 4. Agents (a small but full org) ────────────────────────────────────────
const agentSpecs = [
{
name: "Ada",
role: "ceo",
title: "Chief Executive",
icon: "crown",
manager: true,
budgetMonthlyCents: 300_000,
desiredSkills: ["release-notes"],
instructions:
"# Ada — Chief Executive\n\nYou run Acme Robotics. Set direction, unblock the team, and keep work aligned to company goals. Delegate execution to your reports.",
},
{
name: "Bob",
role: "engineer",
title: "Senior Engineer",
icon: "code",
budgetMonthlyCents: 200_000,
desiredSkills: ["code-review", "incident-response"],
instructions:
"# Bob — Senior Engineer\n\nImplement features and fix bugs on the Website and Mobile App projects. Write tests, open small PRs, and keep the build green.",
},
{
name: "Cleo",
role: "designer",
title: "Product Designer",
icon: "sparkles",
budgetMonthlyCents: 150_000,
desiredSkills: [],
instructions:
"# Cleo — Product Designer\n\nOwn the visual design system and review UI work for polish and accessibility.",
},
{
name: "Dax",
role: "qa",
title: "QA Engineer",
icon: "bug",
budgetMonthlyCents: 120_000,
desiredSkills: ["code-review", "customer-triage"],
instructions:
"# Dax — QA Engineer\n\nVerify changes against acceptance criteria, file clear bug reports, and triage inbound issues.",
},
{
name: "Eve",
role: "researcher",
title: "Research Lead",
icon: "telescope",
budgetMonthlyCents: 180_000,
desiredSkills: [],
instructions:
"# Eve — Research Lead\n\nInvestigate hard technical questions and produce concise, cited findings the team can act on.",
},
];
const existingAgents = asList(await step("list agents", () => get(C("/agents"), baseUrl), []));
const agents = {}; // name -> agent record
let managerId = null;
// Create the manager first so reports can point at it.
for (const spec of agentSpecs) {
const found = existingAgents.find((a) => a.name === spec.name);
if (found) {
agents[spec.name] = found;
if (spec.manager) managerId = found.id;
console.log(`[seed] reusing agent "${spec.name}" (${found.id})`);
continue;
}
const created = await step(`create agent ${spec.name}`, () =>
post(
C("/agents"),
{
name: spec.name,
role: spec.role,
title: spec.title,
icon: spec.icon,
adapterType: "process",
adapterConfig: DEMO_AGENT_CONFIG,
budgetMonthlyCents: spec.budgetMonthlyCents,
...(spec.manager ? {} : managerId ? { reportsTo: managerId } : {}),
...(spec.desiredSkills.length ? { desiredSkills: spec.desiredSkills } : {}),
instructionsBundle: {
entryFile: "AGENT.md",
files: { "AGENT.md": spec.instructions },
},
},
baseUrl,
),
);
if (created?.id) {
agents[spec.name] = created;
if (spec.manager) managerId = created.id;
console.log(`[seed] created agent "${spec.name}" (${created.id}), role=${spec.role}`);
}
}
const managerAgentId = agents.Ada?.id ?? null;
const workerAgentId = agents.Bob?.id ?? null;
const agentList = Object.values(agents);
// ── 5. Goals ────────────────────────────────────────────────────────────────
const goalSpecs = [
{ title: "Launch v1", description: "Ship the first public release of the warehouse robot platform.", status: "active" },
{ title: "Improve reliability", description: "Drive crash-free sessions above 99.5%.", status: "planned" },
];
const existingGoals = asList(await step("list goals", () => get(C("/goals"), baseUrl), []));
const goals = {};
for (const g of goalSpecs) {
const found = existingGoals.find((x) => x.title === g.title);
if (found) {
goals[g.title] = found;
console.log(`[seed] reusing goal "${g.title}"`);
continue;
}
const created = await step(`create goal ${g.title}`, () =>
post(C("/goals"), { ...g, level: "company", ownerAgentId: managerAgentId ?? undefined }, baseUrl),
);
if (created?.id) {
goals[g.title] = created;
console.log(`[seed] created goal "${g.title}" (${created.id})`);
}
}
const goalId = goals["Launch v1"]?.id ?? null;
// ── 6. Projects ─────────────────────────────────────────────────────────────
const projectSpecs = [
{
name: "Website",
description: "Public marketing site and customer portal.",
status: "in_progress",
goalIds: goalId ? [goalId] : [],
},
{
name: "Mobile App",
description: "iOS and Android companion app for fleet operators.",
status: "planned",
goalIds: goalId ? [goalId] : [],
},
];
const existingProjects = asList(await step("list projects", () => get(C("/projects"), baseUrl), []));
const projects = {};
for (const p of projectSpecs) {
const found = existingProjects.find((x) => x.name === p.name);
if (found) {
projects[p.name] = found;
console.log(`[seed] reusing project "${p.name}"`);
continue;
}
const created = await step(`create project ${p.name}`, () =>
post(
C("/projects"),
{
name: p.name,
description: p.description,
status: p.status,
...(p.goalIds.length ? { goalIds: p.goalIds } : {}),
...(managerAgentId ? { leadAgentId: managerAgentId } : {}),
},
baseUrl,
),
);
if (created?.id) {
projects[p.name] = created;
console.log(`[seed] created project "${p.name}" (${created.id})`);
}
}
const projectId = projects.Website?.id ?? null;
// ── 7. Workspace (under the Website project) ────────────────────────────────
let workspaceId = null;
if (projectId) {
const detail = await step("get project detail", () => get(`/api/projects/${projectId}`, baseUrl), {});
const existingWs = Array.isArray(detail.workspaces)
? detail.workspaces.find((w) => w.name === "main")
: null;
if (existingWs) {
workspaceId = existingWs.id;
console.log(`[seed] reusing workspace "main" (${workspaceId})`);
} else {
const created = await step("create workspace", () =>
post(
`/api/projects/${projectId}/workspaces`,
{
name: "main",
sourceType: "git_repo",
cwd: "/srv/acme/website",
repoUrl: "https://github.com/acme-robotics/website",
},
baseUrl,
),
);
if (created?.id) {
workspaceId = created.id;
console.log(`[seed] created workspace "main" (${workspaceId})`);
}
}
}
// ── 8. Issues (mixed statuses, assignees, a parent with sub-issues) ─────────
const issueSpecs = ISSUE_SPECS;
const existingIssues = asList(await step("list issues", () => get(C("/issues"), baseUrl), []));
const issues = {};
for (const spec of issueSpecs) {
const found = existingIssues.find((x) => x.title === spec.title);
if (found) {
issues[spec.title] = found;
continue;
}
const created = await step(`create issue "${spec.title}"`, () =>
post(
C("/issues"),
{
title: spec.title,
description: `${spec.title}. Tracked for the ${spec.project} project.`,
status: spec.status,
priority: spec.priority,
...(agents[spec.assignee]?.id ? { assigneeAgentId: agents[spec.assignee].id } : {}),
...(projects[spec.project]?.id ? { projectId: projects[spec.project].id } : {}),
...(spec.goal && goalId ? { goalId } : {}),
},
baseUrl,
),
);
if (created?.id) {
issues[spec.title] = created;
console.log(`[seed] created issue "${spec.title}" (${created.id})`);
}
}
// The primary issue we point screenshots at (homepage hero — in progress, assigned).
const primaryIssue = issues["Design the homepage hero section"] ?? Object.values(issues)[0] ?? null;
const issueId = primaryIssue?.id ?? null;
// Sub-issues under the primary issue (only seed when fresh to avoid dupes).
if (issueId && !companyExisted) {
const subTitles = ["Hero: choose headline copy", "Hero: build responsive layout"];
for (const title of subTitles) {
await step(`create sub-issue "${title}"`, () =>
post(
C("/issues"),
{
title,
description: `${title} (child of the hero section work).`,
status: "todo",
priority: "medium",
parentId: issueId,
...(workerAgentId ? { assigneeAgentId: workerAgentId } : {}),
...(projectId ? { projectId } : {}),
},
baseUrl,
),
);
}
console.log("[seed] created sub-issues under primary issue");
}
// ── 9. Comments / chat on the primary issue (fresh runs only) ───────────────
// A real two-sided thread. The board actor can only post `authorType: "user"`
// comments — the server rejects "agent" from a human actor — which used to
// make the captured chat a monologue of four board messages, and the
// chat-style task page render every bubble on the same side. The agent's
// replies are therefore posted with a genuine run-bound agent JWT, exactly as
// a real adapter run would, so they carry the agent as author and render as
// agent bubbles with an author header.
if (issueId && !companyExisted) {
const assigneeAgentId = agents.Cleo?.id ?? workerAgentId;
const thread = [
{ as: "user", body: "Kicking this off — let's get three headline directions up for review." },
{
as: "agent",
body:
"Three directions are up in the workspace:\n\n"
+ "1. *Build with Acme Robotics* — plain, safe, reads like everyone else.\n"
+ "2. *Your warehouse, running itself* — leads with autonomy.\n"
+ "3. *Ship faster with autonomous teams* — benefit-first.\n\n"
+ "My pick is the second. It fits on one line at 375px, which the third doesn't.",
},
{ as: "user", body: "Agreed on the second. Keep it under eight words and lead with the autonomy angle." },
{
as: "agent",
body:
"Done — desktop and mobile layouts are pushed to the workspace branch. "
+ "Desktop is ready to review; mobile still has a spacing issue between the headline and the CTA at 375px.",
},
{ as: "user", body: "Looks great. Ship the desktop version and we'll iterate on mobile spacing separately." },
];
// One JWT for the whole thread — minting needs an issue-scoped run for the
// agent, which its issue assignment above has already produced.
let agentAuth = null;
if (assigneeAgentId) {
agentAuth = await step("resolve agent thread identity", async () => {
const sql = openInstanceDb();
try {
// The assignment wakeups from step 8 are dispatched asynchronously, so
// the run this token binds to may not exist yet. Poll briefly rather
// than silently falling back to a one-sided thread.
let runId = null;
for (let attempt = 0; attempt < 20 && !runId; attempt++) {
const rows = await sql`
select id from heartbeat_runs
where agent_id = ${assigneeAgentId} and context_snapshot->>'issueId' is not null
order by created_at desc limit 1`;
runId = rows[0]?.id ?? null;
if (!runId) await new Promise((r) => setTimeout(r, 500));
}
if (!runId) {
console.warn("[seed] no issue-scoped run for the assignee — thread falls back to board-only comments");
return null;
}
const token = mintAgentJwt({ agentId: assigneeAgentId, companyId, runId });
return token ? { token } : null;
} finally {
await sql.end().catch(() => {});
}
});
}
for (const entry of thread) {
if (entry.as === "agent" && agentAuth) {
await step("add agent reply", async () => {
const res = await fetch(`${baseUrl}/api/issues/${issueId}/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${agentAuth.token}`,
},
body: JSON.stringify({ body: entry.body, authorType: "agent" }),
});
if (!res.ok) throw new Error(`agent comment → HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
});
} else {
// Falls back to a board comment when no agent identity is available, so
// the thread still has content rather than gaps.
await step("add issue comment", () =>
post(`/api/issues/${issueId}/comments`, { body: entry.body, authorType: "user" }, baseUrl),
);
}
}
console.log(`[seed] added a ${thread.length}-message thread to the primary issue`);
}
// ── 10. Routine + an execution-history row ──────────────────────────────────
let routineId = null;
const existingRoutines = asList(await step("list routines", () => get(C("/routines"), baseUrl), []));
const foundRoutine = existingRoutines.find((r) => r.title === "Daily standup");
if (foundRoutine) {
routineId = foundRoutine.id;
console.log(`[seed] reusing routine "Daily standup" (${routineId})`);
} else {
const created = await step("create routine", () =>
post(
C("/routines"),
{
title: "Daily standup",
description: "Automated daily status summary across active projects.",
assigneeAgentId: managerAgentId ?? undefined,
priority: "medium",
status: "active",
},
baseUrl,
),
);
if (created?.id) {
routineId = created.id;
console.log(`[seed] created routine "Daily standup" (${routineId})`);
}
}
// Trigger one run so the routine detail page shows an execution row. A run
// record is persisted before any LLM work, so this populates history even
// with no provider configured (the downstream agent work just won't run).
if (routineId && managerAgentId && !companyExisted) {
await step("trigger routine run", () =>
post(`/api/routines/${routineId}/run`, { assigneeAgentId: managerAgentId, source: "manual" }, baseUrl),
);
console.log("[seed] triggered one routine run (history row)");
}
// ── 11. Invites (fresh runs only) ───────────────────────────────────────────
if (!companyExisted) {
await step("create invite (operator)", () =>
post(C("/invites"), { allowedJoinTypes: "human", humanRole: "operator" }, baseUrl),
);
await step("create invite (both)", () =>
post(C("/invites"), { allowedJoinTypes: "both", humanRole: "viewer" }, baseUrl),
);
console.log("[seed] created invites");
}
// ── 12. Plugins (install bundled examples) ──────────────────────────────────
// The "kitchen sink" example is deliberately excluded. It is a developer
// demo that mounts itself into every contextual surface it can find, and on
// the issue detail page it injects a "Task Detail View" panel that dumps the
// raw issue JSON plus Action/Context/Modal buttons. That panel dominated the
// shipped issues/detail-chat.png and tasks/* screenshots, so the guides were
// illustrating plugin debug output instead of the task page. Every other
// example still installs, which is all the plugin-list shots need.
const EXCLUDED_PLUGIN_MATCH = /kitchen-sink/i;
await step("install bundled plugins", async () => {
const examples = asList(await get("/api/plugins/examples", baseUrl));
const installed = asList(await get("/api/plugins", baseUrl));
const installedKeys = new Set(installed.map((p) => p.pluginKey ?? p.packageName));
let count = 0;
for (const ex of examples) {
if (EXCLUDED_PLUGIN_MATCH.test(ex.pluginKey ?? ex.packageName ?? "")) {
console.log(`[seed] skipping plugin ${ex.displayName ?? ex.packageName} (injects debug panels into captured surfaces)`);
continue;
}
if (installedKeys.has(ex.pluginKey) || installedKeys.has(ex.packageName)) continue;
const ok = await step(`install plugin ${ex.displayName ?? ex.packageName}`, async () => {
await post("/api/plugins/install", { packageName: ex.localPath, isLocalPath: true }, baseUrl);
return true;
});
if (ok) count++;
}
console.log(`[seed] installed ${count} bundled plugin(s) (of ${examples.length} available)`);
});
// ── 13. Cost + finance events (fresh runs only — no natural key) ────────────
if (!companyExisted && agentList.length) {
await step("seed cost + finance events", async () => {
const providers = [
{ provider: "anthropic", model: "claude-opus-4-8", biller: "Anthropic" },
{ provider: "anthropic", model: "claude-sonnet-4-6", biller: "Anthropic" },
{ provider: "openai", model: "gpt-5.4", biller: "OpenAI" },
{ provider: "google", model: "gemini-2.5-pro", biller: "Google" },
];
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
let costEvents = 0;
// ~32 cost events spread across the last 26 days, cycling agents/providers.
for (let i = 0; i < 32; i++) {
const agent = agentList[i % agentList.length];
const pr = providers[i % providers.length];
const occurredAt = new Date(now - (i % 26) * day - (i % 7) * 3600_000).toISOString();
const inputTokens = 1200 + (i * 137) % 9000;
const outputTokens = 400 + (i * 89) % 3000;
const costCents = 5 + ((i * 17) % 240); // 5..244 cents
const ok = await step(`cost-event #${i}`, async () => {
await post(
C("/cost-events"),
{
agentId: agent.id,
provider: pr.provider,
model: pr.model,
costCents,
occurredAt,
biller: pr.biller,
billingType: "metered_api",
inputTokens,
outputTokens,
},
baseUrl,
);
return true;
});
if (ok) costEvents++;
}
console.log(`[seed] seeded ${costEvents} cost events`);
// A handful of finance events (charges + a credit purchase).
const finance = [
{ eventKind: "inference_charge", biller: "Anthropic", amountCents: 18_400, direction: "debit" },
{ eventKind: "inference_charge", biller: "OpenAI", amountCents: 9_250, direction: "debit" },
{ eventKind: "platform_fee", biller: "Paperclip", amountCents: 4_900, direction: "debit" },
{ eventKind: "credit_purchase", biller: "Anthropic", amountCents: 50_000, direction: "credit" },
{ eventKind: "log_storage_charge", biller: "Paperclip", amountCents: 1_200, direction: "debit" },
];
let financeEvents = 0;
for (let i = 0; i < finance.length; i++) {
const f = finance[i];
const occurredAt = new Date(now - i * 4 * day).toISOString();
const ok = await step(`finance-event #${i}`, async () => {
await post(
C("/finance-events"),
{ ...f, currency: "USD", occurredAt },
baseUrl,
);
return true;
});
if (ok) financeEvents++;
}
console.log(`[seed] seeded ${financeEvents} finance events`);
});
}
// ── 14. Extended coverage entities ──────────────────────────────────────────
// These back the extra screenshot targets added in routes.mjs. All best-effort.
// 14a. A second, EMPTY company for empty-state shots (no agents/data).
let emptyPrefix = null;
await step("create empty company", async () => {
const all = asList(await get("/api/companies", baseUrl));
let empty = all.find((c) => c.name === "Newco Labs");
if (!empty) {
empty = await post("/api/companies", { name: "Newco Labs", description: "A brand-new, empty workspace." }, baseUrl);
console.log(`[seed] created empty company "Newco Labs" (${empty.id}), prefix=${empty.issuePrefix}`);
} else {
console.log(`[seed] reusing empty company (prefix=${empty.issuePrefix})`);
}
emptyPrefix = empty.issuePrefix;
});
// 14b. Budget-utilisation demo agents — seed cost events to 50% / 80% / 100%.
// Each gets budgetMonthlyCents=100000 ($1,000); spend is summed from cost events.
const budgetAgents = {
budgetHalfAgentId: { name: "Budget Demo (50%)", spend: 50_000, pause: false },
budgetWarnAgentId: { name: "Budget Demo (80%)", spend: 80_000, pause: false },
budgetMaxAgentId: { name: "Budget Demo (100%)", spend: 100_000, pause: true },
};
const budgetIds = { budgetHalfAgentId: null, budgetWarnAgentId: null, budgetMaxAgentId: null };
for (const [token, spec] of Object.entries(budgetAgents)) {
await step(`budget agent ${spec.name}`, async () => {
const existing = asList(await get(C("/agents"), baseUrl)).find((a) => a.name === spec.name);
let agent = existing;
if (!agent) {
agent = await post(
C("/agents"),
{ name: spec.name, role: "general", adapterType: "process", adapterConfig: DEMO_AGENT_CONFIG, budgetMonthlyCents: 100_000, reportsTo: managerAgentId ?? undefined },
baseUrl,
);
}
budgetIds[token] = agent.id;
if (!companyExisted) {
// One cost event sized to the target utilisation (current month).
await post(
C("/cost-events"),
{ agentId: agent.id, provider: "anthropic", model: "claude-opus-4-8", costCents: spec.spend, occurredAt: new Date().toISOString(), biller: "Anthropic", billingType: "metered_api", inputTokens: 5000, outputTokens: 2000 },
baseUrl,
);
if (spec.pause) {
await api("PATCH", `/api/agents/${agent.id}`, { status: "paused" }, baseUrl);
}
}
console.log(`[seed] budget agent "${spec.name}" → ${agent.id} (${spec.spend / 1000} of $1000)`);
});
}
// 14c. An HTTP-adapter agent so the http config form is reachable.
let httpAgentId = null;
await step("create http-adapter agent", async () => {
const existing = asList(await get(C("/agents"), baseUrl)).find((a) => a.name === "Webhook Worker");
let agent = existing;
if (!agent) {
agent = await post(
C("/agents"),
{ name: "Webhook Worker", role: "general", adapterType: "http", adapterConfig: {}, reportsTo: managerAgentId ?? undefined },
baseUrl,
);
}
httpAgentId = agent.id;
console.log(`[seed] http-adapter agent → ${agent.id}`);
});
// 14d. A company skill id (for the skill-detail / assign-to-agent shots).
let skillId = null;
await step("resolve a skill id", async () => {
const list = asList(await get(C("/skills"), baseUrl));
skillId = (list.find((s) => s.slug === "code-review") ?? list[0])?.id ?? null;
});
// 14e. Enable the experimental flags whose surfaces the docs screenshot:
// isolated workspaces (otherwise /workspaces redirects to /issues) plus the
// flags documented in docs/experimental/. Auto-recovery is left off so the
// seeded board doesn't grow recovery tasks mid-capture.
// Endpoint: PATCH /api/instance/settings/experimental.
// `enableTaskChatRedesign` is deliberately NOT set here. It is instance-wide
// and rewrites the ordinary task detail page, so turning it on would make
// every classic issue-detail shot (issues/*, tasks/*, work-modes/*,
// watchdogs/*) capture the redesigned chat instead of the page those guides
// describe. run.mjs flips it on only for the `phase: "task-chat"` targets and
// flips it back afterwards.
await step("enable experimental flags", async () => {
await api("PATCH", "/api/instance/settings/experimental", {
enableIsolatedWorkspaces: true,
enableEnvironments: true,
enableExperimentalFileViewer: true,
enableExternalObjects: true,
enableTaskWatchdogs: true,
enableServerInfoDebugView: true,
enableIssuePlanDecompositions: true,
// v2026.817.0 surfaces. `enableDecisions` and `enableStatusCards` gate the
// /decisions and /status routes (and the whole status-card API); the
// built-in Summarizer that status cards require is enabled alongside.
enableDecisions: true,
enableStatusCards: true,
enableSummaries: true,
enableBuiltInAgents: true,
}, baseUrl);
console.log("[seed] enabled experimental flags");
});
// 14e-2. An issue that references external work objects (GitHub PR/issue
// URLs) for the external-objects screenshot. Created after the flag flips on
// so URL detection runs on create; the refresh call resolves live status when
// the capture host has GitHub egress (non-fatal when offline).
let externalObjectIssueId = null;
await step("create external-object issue", async () => {
const existing = asList(await get(C("/issues"), baseUrl)).find((i) => i.title === "Adopt upstream sandbox proxy fix");
let issue = existing;
if (!issue) {
issue = await post(
C("/issues"),
{
title: "Adopt upstream sandbox proxy fix",
description:
"Upstream shipped the fix in https://github.com/paperclipai/paperclip/pull/8512 — verify it covers our case, tracked upstream in https://github.com/paperclipai/paperclip/issues/4419.",
status: "in_progress",
priority: "medium",
assigneeAgentId: workerAgentId ?? undefined,
projectId: projectId ?? undefined,
},
baseUrl,
);
}
externalObjectIssueId = issue?.id ?? null;
if (externalObjectIssueId) {
try {
await post(`/api/issues/${externalObjectIssueId}/external-objects/refresh`, {}, baseUrl);
} catch (err) {
console.warn("[seed] external-object refresh failed (non-fatal):", err.message);
}
}
console.log(`[seed] external-object issue → ${externalObjectIssueId}`);
});
// 14f. Approval items (fresh runs only — no natural key).
const approvalIds = { hireApprovalId: null, strategyApprovalId: null, boardApprovalId: null, approvedApprovalId: null };
if (!companyExisted) {
await step("seed approvals", async () => {
const hire = await post(C("/approvals"), {
type: "hire_agent",
payload: { name: "Aria Chen", role: "engineer", title: "Senior Engineer", icon: "code", adapterType: "claude_local", adapterConfig: {}, budgetMonthlyCents: 150_000, desiredSkills: [], capabilities: "Backend development, API design", agentId: null },
}, baseUrl);
approvalIds.hireApprovalId = hire?.id ?? null;
const strat = await post(C("/approvals"), {
type: "approve_ceo_strategy",
payload: { title: "Q3 Product Roadmap", plan: "Expand into the enterprise segment by shipping SSO and audit logs in Q3.", description: "Strategic pivot toward mid-market and enterprise accounts." },
}, baseUrl);
approvalIds.strategyApprovalId = strat?.id ?? null;
const board = await post(C("/approvals"), {
type: "request_board_approval",
payload: { title: "Vendor contract: cloud reserved instances", summary: "Commit to a 1-year reservation to cut infra cost ~35%.", recommendedAction: "Approve the $12,000 annual commitment" },
}, baseUrl);
approvalIds.boardApprovalId = board?.id ?? null;
// One more, then approve it, for the "approved" state.
const toApprove = await post(C("/approvals"), {
type: "request_board_approval",
payload: { title: "Increase log retention to 90 days", summary: "Improves debuggability for incident response." },
}, baseUrl);
if (toApprove?.id) {
await post(`/api/approvals/${toApprove.id}/approve`, {}, baseUrl);
approvalIds.approvedApprovalId = toApprove.id;
}
console.log("[seed] seeded approvals:", JSON.stringify(approvalIds));
});
}
// 14g. Process-adapter "runner" agents — execute a real shell command (no LLM)
// so we get genuine run rows, a transcript, a running status, and a passing
// environment test. The process adapter runs `command` + `args` directly.
let runnerAgentId = null;
let runnerRunId = null;
let longRunnerAgentId = null;
await step("create + run a process agent (completed run → transcript)", async () => {
const existing = asList(await get(C("/agents"), baseUrl)).find((a) => a.name === "Task Runner");
let agent = existing;
if (!agent) {
agent = await post(
C("/agents"),
{
name: "Task Runner",
role: "engineer",
title: "Automation",
icon: "terminal",
adapterType: "process",
adapterConfig: {
command: "sh",
args: ["-c", "echo 'Analyzing repository...'; echo 'Applying changes to src/app.ts'; echo 'Running tests — 42 passed'; echo 'Done.'"],
timeoutSec: 30,
},
reportsTo: managerAgentId ?? undefined,
},
baseUrl,
);
}
runnerAgentId = agent.id;
if (!companyExisted) {
const run = await post(`/api/agents/${agent.id}/wakeup`, { source: "on_demand", triggerDetail: "manual" }, baseUrl);
runnerRunId = run?.id ?? run?.heartbeatRun?.id ?? run?.run?.id ?? null;
console.log(`[seed] Task Runner run → ${runnerRunId}`);
} else {
// Reuse the most recent completed run if present.
const runs = asList(await step("list runner runs", () => get(`/api/agents/${agent.id}/runs`, baseUrl), []));
runnerRunId = runs[0]?.id ?? null;
}
});
// Give the short run a moment to execute and complete before we capture it.
if (runnerRunId && !companyExisted) {
await new Promise((r) => setTimeout(r, 4000));
}
await step("create a long-running process agent (running status)", async () => {
const existing = asList(await get(C("/agents"), baseUrl)).find((a) => a.name === "Live Task");
let agent = existing;
if (!agent) {
agent = await post(
C("/agents"),
{
name: "Live Task",
role: "engineer",
title: "Long Job",
icon: "cpu",
adapterType: "process",
adapterConfig: {
// Long enough to outlast the full capture phase so the run is still
// in-flight (status "running", an in-progress run row) when capture
// reaches the run/status targets near the end of the list.
command: "sh",
args: ["-c", "echo 'Starting long task...'; sleep 3600; echo done"],
timeoutSec: 4200,
},
reportsTo: managerAgentId ?? undefined,
},
baseUrl,
);
}
longRunnerAgentId = agent.id;
// Kick it off LAST so it is still running throughout the capture phase.
if (!companyExisted) {
await post(`/api/agents/${agent.id}/wakeup`, { source: "on_demand", triggerDetail: "manual" }, baseUrl);
console.log("[seed] Live Task wakeup triggered (stays running during capture)");
}
});
// ── 14f. Execution workspace (direct DB insert) ─────────────────────────────
// Execution workspaces have no public create endpoint — they're provisioned by
// a real agent heartbeat. For screenshots we insert a realistic one directly so
// the execution-workspace detail tabs render instead of "Loading workspace…".
let executionWorkspaceId = null;
if (projectId && issueId) {
try {
const linkedIssueIds = [
issueId,
issues["Fix flaky integration test"]?.id,
issues["Add dark mode to the portal"]?.id,
].filter(Boolean);
const result = await seedExecutionWorkspace({
companyId,
projectId,
projectWorkspaceId: workspaceId,
sourceIssueId: issueId,
linkedIssueIds,
issuePrefix: companyPrefix,
});
executionWorkspaceId = result?.executionWorkspaceId ?? null;
} catch (err) {
console.warn("[seed] execution workspace seed failed (non-fatal):", err.message);
}
}
// ── 14h. v2026.817.0 surfaces (decisions, status cards, secret proposals) ──
// Runs last: every one of these is agent-authored and needs the issue-scoped
// agent runs that the issue assignments above produce.
let newSurfaceIds = {};
await step("seed v2026.817.0 surfaces", async () => {
newSurfaceIds = await seedNewSurfaces({
companyId,
// Most-preferred origin agent first — the decision's provenance line reads
// "Proposed by <name> while running <ISSUE>", so a worker agent reads best.
agentIds: [workerAgentId, agents.Cleo?.id, agents.Eve?.id, managerAgentId],
baseUrl,
});
});
// ── 14i. Re-assert the intended issue statuses ─────────────────────────────
// See reassertIssueStatuses() for why this is needed. Run once here so the
// board is correct the moment capture starts; run.mjs keeps it correct.
await step("re-assert issue statuses", () => reassertIssueStatuses({ companyId, baseUrl }));
// ── 15. Persist ids ─────────────────────────────────────────────────────────
const ids = {
companyPrefix,
companyId,
managerAgentId,
agentId: workerAgentId, // Bob — used for the agent-detail screenshots
projectId,
goalId,
routineId,
issueId,
workspaceId,
executionWorkspaceId,
emptyPrefix,
httpAgentId,
skillId,
externalObjectIssueId,
runnerAgentId,
runnerRunId,
longRunnerAgentId,
...budgetIds,
...approvalIds,
...newSurfaceIds,
};
writeFileSync(SEED_IDS_PATH, JSON.stringify(ids, null, 2) + "\n", "utf-8");
console.log(`[seed] wrote ids to ${SEED_IDS_PATH}`);
console.log("[seed] done:", JSON.stringify(ids, null, 2));
return ids;
}
/**
* Put the demo board's issue statuses back to the ones ISSUE_SPECS declares.
*
* Why this exists: the demo agents are `process` adapters that echo a line and
* exit. They complete *successfully* without declaring what happened to the
* issue they were woken for, and Paperclip treats a successful run with no
* disposition as work that stalled — it moves the issue to `blocked` and posts
* a "Successful run missing issue disposition" notice into the thread
* (server/src/services/issues.ts, heartbeat.ts). That is right for a real agent
* that went quiet, but on the demo board it means the hero task the guides
* point at renders as Blocked under an error banner.
*
* Disabling the agents' timer heartbeats does not stop it: the sweep runs over
* the *existing* dispositionless runs, so it re-blocks within a few minutes and
* keeps doing so. A full capture takes far longer than that, which is why this
* is a keeper rather than a one-shot — run.mjs calls it on an interval for the
* duration of the capture.
*
* @param {{ companyId: string, baseUrl?: string }} input
* @returns {Promise<number>} how many issues were corrected
*/
export async function reassertIssueStatuses({ companyId, baseUrl = BASE_URL }) {
const wanted = new Map(ISSUE_SPECS.map((spec) => [spec.title, spec.status]));
let restored = 0;
const all = asList(await get(`/api/companies/${companyId}/issues`, baseUrl));
for (const issue of all) {
const target = wanted.get(issue.title);
if (!target || issue.status === target) continue;
try {
await patch(`/api/issues/${issue.id}`, { status: target }, baseUrl);
restored++;
} catch (err) {
console.warn(`[seed] could not restore "${issue.title}" to ${target}: ${err.message}`);
}
}
if (restored) console.log(`[seed] re-asserted ${restored} drifted issue status(es)`);
return restored;
}
// ── CLI entry-point ─────────────────────────────────────────────────────────
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
const urlFlagIdx = process.argv.indexOf("--base-url");
const baseUrl = urlFlagIdx !== -1 ? process.argv[urlFlagIdx + 1] : BASE_URL;
seed({ baseUrl }).catch((err) => {
console.error("[seed] fatal:", err.message);
process.exit(1);
});
}