mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-09 17:41:56 -10:00
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]>
423 lines
18 KiB
JavaScript
423 lines
18 KiB
JavaScript
/**
|
|
* run.mjs — one-command orchestrator for the screenshot pipeline.
|
|
*
|
|
* Steps:
|
|
* 1. Validate PARENT_REPO exists.
|
|
* 1b. Generate the instance config.json and pin its embedded Postgres to a
|
|
* guaranteed-free port (so a screenshot run never collides with — or
|
|
* connects into — a real local Paperclip already on the default 54329).
|
|
* 2. Spawn `pnpm paperclipai onboard --yes --run` inside PARENT_REPO with a
|
|
* fully isolated env (scratchHome as PAPERCLIP_HOME, loopback binding,
|
|
* local_trusted mode, no external DB). onboard preserves the config from 1b.
|
|
* 3. Poll BASE_URL/api/health until 200 (timeout 120 s).
|
|
* 3.5. Capture `phase: "pre-seed"` targets (onboarding wizard) while the
|
|
* instance is still company-less — the only window those states exist.
|
|
* 4. Run seed() to create demo entities and write .seed-ids.json.
|
|
* 5. Run sync-registry to back-fill routes into registry.json.
|
|
* 6. Run capture(), passing through supported CLI flags.
|
|
* 7. On finish or error: kill the server child and (unless --keep) rm -rf the
|
|
* scratch home directory.
|
|
*
|
|
* Supported flags (forwarded to capture):
|
|
* --all Recapture every target.
|
|
* --only Filter by name substring.
|
|
* --theme light | dark | both
|
|
* --stale Comma-separated registry file list.
|
|
* --base-url Override BASE_URL.
|
|
* --keep Do NOT rm -rf the scratch home after capture.
|
|
* --serve Boot + seed, then STAY UP instead of capturing. For iterating on
|
|
* seed.mjs and eyeballing screens before committing to a full run.
|
|
*
|
|
* Usage:
|
|
* node scripts/screenshots/run.mjs [--all] [--only <substr>] [--theme light|dark|both]
|
|
* node scripts/screenshots/run.mjs --serve # live instance, Ctrl-C to tear down
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { rm, access, readFile, writeFile } from "node:fs/promises";
|
|
import { constants as fsConstants, existsSync } from "node:fs";
|
|
|
|
import {
|
|
BASE_URL,
|
|
PARENT_REPO,
|
|
REPO_ROOT,
|
|
SEED_IDS_PATH,
|
|
scratchHome,
|
|
instanceEnv,
|
|
instanceConfigPath,
|
|
findFreeEmbeddedPostgresPort,
|
|
} from "./config.mjs";
|
|
|
|
// Lazy imports — loaded after the server is ready so import errors surface clearly.
|
|
async function importSeed() {
|
|
return (await import("./seed.mjs")).default;
|
|
}
|
|
async function importCapture() {
|
|
return (await import("./capture.mjs")).default;
|
|
}
|
|
async function importSyncRegistry() {
|
|
// sync-registry is a script (no default export) — we execute it as a side-effect
|
|
// by dynamically importing it. To avoid running it at import time before the
|
|
// server is ready, we exec it as a child process instead.
|
|
return null;
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Poll GET <url> until status 200 or timeout. Returns true on success. */
|
|
async function pollHealth(url, timeoutMs = 120_000) {
|
|
const interval = 2_000;
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(3_000) });
|
|
if (res.ok) return true;
|
|
} catch {
|
|
// Connection refused or timeout — server not ready yet.
|
|
}
|
|
await new Promise((r) => setTimeout(r, interval));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Parse a CLI flag value: --flag value */
|
|
function getFlag(args, flag) {
|
|
const idx = args.indexOf(flag);
|
|
return idx !== -1 ? args[idx + 1] : undefined;
|
|
}
|
|
/** Check whether a boolean CLI flag is present. */
|
|
function hasFlag(args, flag) {
|
|
return args.includes(flag);
|
|
}
|
|
|
|
/**
|
|
* Phase 1b — generate the instance config.json and pin its embedded Postgres to
|
|
* a free port.
|
|
*
|
|
* Why this is needed: the onboard wizard hard-codes embedded Postgres to 54329
|
|
* and offers no env/flag override — the server reads the port only from
|
|
* config.json. A developer's real local Paperclip uses the same default, so an
|
|
* un-pinned screenshot run would either fail to boot or, worse, connect into the
|
|
* real database on 54329 and seed demo rows there.
|
|
*
|
|
* onboard writes config.json *before* it boots the server and preserves an
|
|
* existing config on the next run. So we let it write the config, stop it,
|
|
* rewrite the port to a free one, and let the real run (step 2) reuse it.
|
|
*
|
|
* Crucially, this pass runs with `DATABASE_URL` pointed at an unreachable
|
|
* external Postgres. That puts onboard in external-database mode, so it writes a
|
|
* config (which still contains the embedded-Postgres fields) but never
|
|
* initializes the embedded cluster in the scratch data dir. If it *did* touch
|
|
* the data dir, a half-finished `initdb` would leave a `PG_VERSION` file behind;
|
|
* step 2 would then treat the cluster as "already initialized", skip `initdb`,
|
|
* and fail with `role "paperclip" does not exist`. Keeping the data dir pristine
|
|
* lets step 2 initialize a fresh cluster on the pinned port.
|
|
*
|
|
* @returns {Promise<number>} the free port written into config.json
|
|
*/
|
|
async function generateIsolatedConfig({ home, env }) {
|
|
const port = await findFreeEmbeddedPostgresPort();
|
|
const cfgPath = instanceConfigPath(home);
|
|
console.log(`run: generating config; pinning embedded Postgres to free port ${port}…`);
|
|
|
|
// Unreachable external DB (port 1 → instant ECONNREFUSED, never a real
|
|
// Postgres) so onboard generates config without initializing the embedded
|
|
// cluster. The dummy URL is used only for this generation pass.
|
|
const genEnv = { ...env, DATABASE_URL: "postgres://paperclip:[email protected]:1/none" };
|
|
const gen = spawn("pnpm", ["paperclipai", "onboard", "--yes"], {
|
|
cwd: PARENT_REPO,
|
|
env: genEnv,
|
|
stdio: "pipe",
|
|
detached: true,
|
|
});
|
|
gen.stderr.on("data", (d) => process.stderr.write(`[config] ${d}`));
|
|
|
|
// Wait for config.json to appear, then stop onboard before it boots Postgres.
|
|
const deadline = Date.now() + 60_000;
|
|
let wrote = false;
|
|
while (Date.now() < deadline) {
|
|
if (existsSync(cfgPath)) { wrote = true; break; }
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
try { process.kill(-gen.pid, "SIGKILL"); } catch { try { gen.kill("SIGKILL"); } catch {} }
|
|
if (!wrote) {
|
|
throw new Error(`config not written within 60 s at ${cfgPath}`);
|
|
}
|
|
|
|
// Rewrite the port. Parse with a short retry in case we caught the file mid-flush.
|
|
let cfg;
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
try {
|
|
cfg = JSON.parse(await readFile(cfgPath, "utf8"));
|
|
break;
|
|
} catch (err) {
|
|
if (attempt === 4) throw new Error(`could not parse generated config at ${cfgPath}: ${err.message}`);
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
}
|
|
cfg.database = { ...cfg.database, mode: "embedded-postgres", embeddedPostgresPort: port };
|
|
delete cfg.database.connectionString;
|
|
await writeFile(cfgPath, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
console.log(`run: pinned embedded Postgres to port ${port}.`);
|
|
return port;
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
// ── 1. Validate PARENT_REPO ──────────────────────────────────────────────
|
|
try {
|
|
await access(PARENT_REPO, fsConstants.F_OK);
|
|
} catch {
|
|
console.error(
|
|
`run: PARENT_REPO does not exist: ${PARENT_REPO}\n` +
|
|
" Set the PAPERCLIP_REPO env var to the correct path.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const home = scratchHome();
|
|
const env = instanceEnv(home);
|
|
const healthUrl = `${getFlag(args, "--base-url") ?? BASE_URL}/api/health`;
|
|
const keepScratch = hasFlag(args, "--keep");
|
|
|
|
let server = null;
|
|
|
|
async function cleanup() {
|
|
if (server) {
|
|
// `pnpm paperclipai onboard --run` spawns child processes (the actual
|
|
// server, embedded-postgres). Killing only the pnpm parent leaves those
|
|
// orphaned and holding the port. We spawn the server `detached` so it
|
|
// gets its own process group, then signal the whole group via -pid.
|
|
const pid = server.pid;
|
|
try { process.kill(-pid, "SIGTERM"); } catch { try { server.kill("SIGTERM"); } catch {} }
|
|
// Give it a moment then force-kill the group.
|
|
await new Promise((r) => setTimeout(r, 2_000));
|
|
try { process.kill(-pid, "SIGKILL"); } catch { try { server.kill("SIGKILL"); } catch {} }
|
|
server = null;
|
|
}
|
|
if (!keepScratch) {
|
|
try {
|
|
await rm(home, { recursive: true, force: true });
|
|
console.log("run: removed scratch home", home);
|
|
} catch (err) {
|
|
console.warn("run: could not remove scratch home:", err.message);
|
|
}
|
|
} else {
|
|
console.log("run: keeping scratch home (--keep):", home);
|
|
}
|
|
}
|
|
|
|
// Register cleanup on unexpected exit.
|
|
process.on("SIGINT", async () => { await cleanup(); process.exit(130); });
|
|
process.on("SIGTERM", async () => { await cleanup(); process.exit(143); });
|
|
|
|
// ── 1b. Start from a clean instance ──────────────────────────────────────
|
|
// Wipe any leftover scratch home (e.g. from a prior `--keep` run) so onboard
|
|
// builds a fresh database and the seed runs from scratch — otherwise a reused
|
|
// company makes the seed skip its one-shot steps (comments, invites, events).
|
|
try {
|
|
await rm(home, { recursive: true, force: true });
|
|
} catch (err) {
|
|
console.warn("run: could not pre-clean scratch home:", err.message);
|
|
}
|
|
|
|
// ── 1b. Generate config + pin embedded Postgres to a free port ───────────
|
|
try {
|
|
await generateIsolatedConfig({ home, env });
|
|
} catch (err) {
|
|
console.error("run: failed to generate isolated config:", err);
|
|
await cleanup();
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── 2. Spawn the onboard server ──────────────────────────────────────────
|
|
console.log("run: starting Paperclip onboard server…");
|
|
console.log("run: cwd =", PARENT_REPO);
|
|
console.log("run: home =", home);
|
|
|
|
server = spawn("pnpm", ["paperclipai", "onboard", "--yes", "--run"], {
|
|
cwd: PARENT_REPO,
|
|
env,
|
|
stdio: "pipe",
|
|
// Own process group so cleanup() can signal the whole tree (see below).
|
|
detached: true,
|
|
});
|
|
|
|
server.stdout.on("data", (d) => process.stdout.write(`[server] ${d}`));
|
|
server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
|
|
server.on("exit", (code, sig) => {
|
|
if (code !== 0 && code !== null) {
|
|
console.warn(`run: server exited with code ${code} signal ${sig}`);
|
|
}
|
|
});
|
|
|
|
// ── 3. Poll health ───────────────────────────────────────────────────────
|
|
console.log("run: waiting for server health…");
|
|
const ready = await pollHealth(healthUrl);
|
|
if (!ready) {
|
|
console.error("run: server did not become healthy within 120 s.");
|
|
await cleanup();
|
|
process.exit(1);
|
|
}
|
|
console.log("run: server is healthy.");
|
|
|
|
// ── 3.5. Pre-seed capture (onboarding wizard) ────────────────────────────
|
|
// The instance has no company yet, so /onboarding is reachable — this is the
|
|
// only window where the create-a-company wizard states can be captured.
|
|
// Targets marked `phase: "pre-seed"` in routes.mjs are shot here; once
|
|
// seed.mjs creates the demo company, these surfaces become unreachable.
|
|
console.log("run: capturing pre-seed (onboarding) screenshots…");
|
|
try {
|
|
const capture = await importCapture();
|
|
const staleRaw = getFlag(args, "--stale");
|
|
await capture({
|
|
all: hasFlag(args, "--all"),
|
|
only: getFlag(args, "--only"),
|
|
theme: getFlag(args, "--theme") ?? "both",
|
|
staleFiles: staleRaw ? staleRaw.split(",") : undefined,
|
|
baseUrl: getFlag(args, "--base-url") ?? BASE_URL,
|
|
keep: keepScratch,
|
|
phase: "pre-seed",
|
|
});
|
|
} catch (err) {
|
|
// Pre-seed captures are a bonus pass — a failure here should not abort the
|
|
// main run, but it must be loud in the summary.
|
|
console.error("run: pre-seed capture failed (continuing to seed):", err);
|
|
}
|
|
|
|
// ── 4. Seed demo data ────────────────────────────────────────────────────
|
|
console.log("run: seeding demo data…");
|
|
try {
|
|
const seed = await importSeed();
|
|
const ids = await seed({ baseUrl: getFlag(args, "--base-url") ?? BASE_URL });
|
|
console.log("run: seed complete:", ids);
|
|
} catch (err) {
|
|
console.error("run: seed failed:", err);
|
|
await cleanup();
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── 5. Sync registry ─────────────────────────────────────────────────────
|
|
console.log("run: syncing registry…");
|
|
try {
|
|
// Import and immediately re-execute sync-registry as a module. Because
|
|
// sync-registry's main() runs at the top level (no exported function), we
|
|
// spawn it as a child process.
|
|
await new Promise((resolve, reject) => {
|
|
const syncProc = spawn(
|
|
process.execPath,
|
|
["scripts/screenshots/sync-registry.mjs"],
|
|
{ cwd: REPO_ROOT, stdio: "inherit" },
|
|
);
|
|
syncProc.on("close", (code) => {
|
|
code === 0 ? resolve() : reject(new Error(`sync-registry exited ${code}`));
|
|
});
|
|
});
|
|
} catch (err) {
|
|
console.warn("run: sync-registry warning:", err.message, "(continuing)");
|
|
}
|
|
|
|
// ── 5.5. Serve mode — hand the live instance over and stop here ──────────
|
|
// Seeding a screen correctly is iterative: extend seed.mjs, look at the page,
|
|
// adjust. Booting the whole instance for each pass costs minutes, so --serve
|
|
// keeps it up. `npm run screenshots:seed` re-runs the seed against it in
|
|
// place, and `npm run screenshots:capture -- --only <name>` shoots one target.
|
|
if (hasFlag(args, "--serve")) {
|
|
const base = getFlag(args, "--base-url") ?? BASE_URL;
|
|
console.log("");
|
|
console.log(`run: serving at ${base} — instance is live and seeded.`);
|
|
console.log("run: re-seed: npm run screenshots:seed");
|
|
console.log(`run: capture: npm run screenshots:capture -- --only <name>`);
|
|
console.log("run: stop: Ctrl-C (tears down the server and scratch home)");
|
|
console.log("");
|
|
// Park forever; the SIGINT/SIGTERM handlers registered above run cleanup().
|
|
await new Promise(() => {});
|
|
return;
|
|
}
|
|
|
|
// ── 6. Capture screenshots ───────────────────────────────────────────────
|
|
// Keep the demo board's issue statuses correct for the whole capture. The
|
|
// demo `process` agents finish their runs without declaring a disposition, so
|
|
// Paperclip's stalled-work sweep keeps moving those issues to `blocked` with
|
|
// an error notice — every few minutes, over a capture that runs much longer
|
|
// than that. See reassertIssueStatuses() in seed.mjs.
|
|
const seedIds = JSON.parse(await readFile(SEED_IDS_PATH, "utf8").catch(() => "{}"));
|
|
let statusKeeper = null;
|
|
if (seedIds.companyId) {
|
|
const { reassertIssueStatuses } = await import("./seed.mjs");
|
|
statusKeeper = setInterval(() => {
|
|
reassertIssueStatuses({
|
|
companyId: seedIds.companyId,
|
|
baseUrl: getFlag(args, "--base-url") ?? BASE_URL,
|
|
}).catch((err) => console.warn("run: status keeper:", err.message));
|
|
}, 45_000);
|
|
// Never hold the process open on this timer alone.
|
|
statusKeeper.unref?.();
|
|
}
|
|
|
|
console.log("run: capturing screenshots…");
|
|
try {
|
|
const capture = await importCapture();
|
|
|
|
const staleRaw = getFlag(args, "--stale");
|
|
const captureOpts = {
|
|
all: hasFlag(args, "--all"),
|
|
only: getFlag(args, "--only"),
|
|
theme: getFlag(args, "--theme") ?? "both",
|
|
staleFiles: staleRaw ? staleRaw.split(",") : undefined,
|
|
baseUrl: getFlag(args, "--base-url") ?? BASE_URL,
|
|
keep: keepScratch,
|
|
};
|
|
|
|
await capture(captureOpts);
|
|
console.log("run: capture complete.");
|
|
|
|
// ── 6b. Task-chat phase ────────────────────────────────────────────────
|
|
// `enableTaskChatRedesign` replaces the ordinary task detail page for the
|
|
// whole instance, so it cannot be on during the main pass — every classic
|
|
// issue/task/work-mode shot would come back as the redesigned chat. Flip it
|
|
// on, shoot only the `phase: "task-chat"` targets, flip it back.
|
|
const base = getFlag(args, "--base-url") ?? BASE_URL;
|
|
const setTaskChat = (enabled) =>
|
|
fetch(`${base}/api/instance/settings/experimental`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enableTaskChatRedesign: enabled }),
|
|
});
|
|
|
|
try {
|
|
const res = await setTaskChat(true);
|
|
if (!res.ok) throw new Error(`PATCH experimental → HTTP ${res.status}`);
|
|
console.log("run: capturing task-chat screenshots (enableTaskChatRedesign on)…");
|
|
await capture({ ...captureOpts, phase: "task-chat" });
|
|
} catch (err) {
|
|
console.error("run: task-chat capture failed (continuing):", err.message);
|
|
} finally {
|
|
// Always restore, even if the phase threw — leaving the flag on would
|
|
// silently poison any later capture against this instance.
|
|
await setTaskChat(false).catch((err) =>
|
|
console.warn("run: could not restore enableTaskChatRedesign:", err.message),
|
|
);
|
|
}
|
|
|
|
if (statusKeeper) clearInterval(statusKeeper);
|
|
} catch (err) {
|
|
if (statusKeeper) clearInterval(statusKeeper);
|
|
console.error("run: capture failed:", err);
|
|
await cleanup();
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── 7. Teardown ──────────────────────────────────────────────────────────
|
|
await cleanup();
|
|
console.log("run: done.");
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
console.error("run: fatal error:", err);
|
|
process.exit(1);
|
|
});
|