Files
paperclip-docs/scripts/screenshots/config.mjs
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

307 lines
12 KiB
JavaScript

/**
* config.mjs — shared constants and environment helpers for the screenshot pipeline.
*
* All other scripts in this directory import from here so values stay in one place.
*/
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { createHmac } from "node:crypto";
import os from "node:os";
import net from "node:net";
// ── Server / viewport ────────────────────────────────────────────────────────
export const PORT = 3197;
export const BASE_URL = `http://127.0.0.1:${PORT}`;
export const VIEWPORT = { width: 1440, height: 900 };
export const DEVICE_SCALE = 2;
// ── Demo company ─────────────────────────────────────────────────────────────
/** issuePrefix used in /:companyPrefix/ route segments */
export const COMPANY_PREFIX = "ACME";
export const COMPANY_NAME = "Acme Robotics";
/**
* PAPERCLIP_INSTANCE_ID for the throw-away screenshot instance. Shared by
* instanceEnv() (passed to the spawned server) and the DB-seeding helper, which
* needs it to locate the embedded-postgres data dir under PAPERCLIP_HOME.
*/
export const INSTANCE_ID = "docs-screenshots";
/**
* Compiled-in default embedded-postgres port (server/src/config.ts →
* embeddedPostgresPort). This is only the *starting point* for the free-port
* scan in `findFreeEmbeddedPostgresPort()` — the screenshot instance never
* assumes 54329 is free, because a developer's real local Paperclip uses it too.
*/
export const EMBEDDED_POSTGRES_PORT = 54329;
// ── Paths ────────────────────────────────────────────────────────────────────
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Absolute path to the paperclip-docs repo root (two levels up from this file). */
export const REPO_ROOT = resolve(__dirname, "../..");
/** Where captured PNGs are written, keyed by theme sub-directory. */
export const SHOTS_DIR = resolve(REPO_ROOT, "docs/user-guides/screenshots");
/** JSON registry that tracks route, depends_on, captured_sha, etc. per screenshot. */
export const REGISTRY_PATH = resolve(SHOTS_DIR, "registry.json");
/**
* Gitignored JSON file written by seed.mjs containing the entity IDs created on
* the demo instance (company, agents, project, …).
*/
export const SEED_IDS_PATH = resolve(__dirname, ".seed-ids.json");
/**
* Absolute path to the parent Paperclip repo.
* Override via PAPERCLIP_REPO env var if your checkout lives elsewhere.
*/
export const PARENT_REPO = resolve(
process.env.PAPERCLIP_REPO ||
resolve(os.homedir(), "Documents/PaperclipAI/paperclip"),
);
// ── Isolation helpers ────────────────────────────────────────────────────────
const PASSTHROUGH_ENV_KEYS = [
"PATH",
"Path",
"SystemRoot",
"COMSPEC",
"WINDIR",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"CI",
"USER",
"LOGNAME",
"SHELL",
"PNPM_HOME",
"COREPACK_HOME",
];
/**
* Returns a scratch home directory path under os.tmpdir().
* Used as PAPERCLIP_HOME so the real ~/.paperclip is never touched.
*/
export function scratchHome() {
return resolve(os.tmpdir(), "paperclip-docs-shots-home");
}
/**
* Returns an env object suitable for spawning the onboard process in full
* isolation (loopback binding, local_trusted mode, no external DB).
*
* @param {string} home - path returned by scratchHome() (or a custom dir)
* @returns {Record<string, string>}
*/
export function instanceEnv(home) {
const env = {};
for (const key of PASSTHROUGH_ENV_KEYS) {
if (process.env[key] !== undefined) env[key] = process.env[key];
}
return {
...env,
HOME: home,
XDG_CONFIG_HOME: resolve(home, ".config"),
XDG_CACHE_HOME: resolve(home, ".cache"),
XDG_DATA_HOME: resolve(home, ".local", "share"),
PORT: String(PORT),
PAPERCLIP_HOME: home,
PAPERCLIP_INSTANCE_ID: INSTANCE_ID,
PAPERCLIP_BIND: "loopback",
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private",
// The server loads a `.env` from its cwd (the parent repo) via dotenv with
// `override: false` — so any key we DON'T set here leaks in from the
// developer's parent-repo `.env` and defeats this instance's isolation.
// Pin the ones that would otherwise break a screenshot run:
// • DATABASE_URL="" forces embedded Postgres (the parent's `.env` points at
// a real external database the throw-away instance must never touch).
// • SERVE_UI="true" guarantees the UI is served even if the parent disabled
// it — the capture step navigates real UI routes.
DATABASE_URL: "",
DATABASE_MIGRATION_URL: "",
SERVE_UI: "true",
// The server resolves its config by walking UP from cwd for a
// `.paperclip/config.json` (see server/src/paths.ts) BEFORE honoring
// PAPERCLIP_HOME. Since onboard runs with cwd = PARENT_REPO, a developer's
// real `.paperclip/config.json` in the parent repo would be picked up,
// binding the screenshot run to the real instance's DB. Pin PAPERCLIP_CONFIG
// to the scratch instance's config path so onboard reads/writes the
// isolated config instead.
PAPERCLIP_CONFIG: instanceConfigPath(home),
};
}
/**
* Absolute path to the throw-away instance's config.json — the file onboard
* writes and the server reads. Mirrors the parent's
* `${PAPERCLIP_HOME}/instances/${instanceId}/config.json` layout. We rewrite
* the embedded-postgres port here so the run lands on a guaranteed-free port.
*/
export function instanceConfigPath(home = scratchHome()) {
return resolve(home, "instances", INSTANCE_ID, "config.json");
}
/** Resolve true if nothing is listening on 127.0.0.1:<port>. */
function isPortFree(port) {
return new Promise((res) => {
const srv = net.createServer();
srv.once("error", () => res(false));
srv.once("listening", () => srv.close(() => res(true)));
srv.listen(port, "127.0.0.1");
});
}
/**
* Find a free TCP port for the throw-away instance's embedded Postgres,
* scanning upward from `start`. Crucially this skips any port a real local
* Paperclip (or anything else) is already using, so a screenshot run started
* while your real instance is up never collides with — or worse, connects into
* and seeds — your real database.
*
* @param {number} start first port to probe (default EMBEDDED_POSTGRES_PORT)
* @param {number} attempts how many consecutive ports to try
* @returns {Promise<number>}
*/
export async function findFreeEmbeddedPostgresPort(
start = EMBEDDED_POSTGRES_PORT,
attempts = 200,
) {
for (let port = start; port < start + attempts; port++) {
if (await isPortFree(port)) return port;
}
throw new Error(
`No free port found in [${start}, ${start + attempts}) for the screenshot instance's embedded Postgres`,
);
}
// ── Direct instance access (DB + agent JWT) ──────────────────────────────────
// Some demo state has no board-facing REST path (execution workspaces) or is
// deliberately agent-only (decisions, secret proposals). These helpers give the
// seed the same two doors a real agent run has, pointed strictly at the
// throw-away instance.
/**
* Work out which port the screenshot instance's embedded Postgres is listening
* on. run.mjs pins it to a free port (written into config.json), and the live
* cluster records the same value in `postmaster.pid` (line 4 is the port). We
* read the running value first, then config.json.
*
* There is deliberately NO compiled-in fallback to 54329: that is the default a
* developer's real local Paperclip uses, so guessing it could connect a direct
* write into the real database. If neither source yields a port we throw — a
* loud failure is far safer than a silent write to the wrong cluster.
*/
export function resolvePostgresPort(home = scratchHome()) {
const instanceRoot = resolve(home, "instances", INSTANCE_ID);
try {
const lines = readFileSync(resolve(instanceRoot, "db", "postmaster.pid"), "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 */
}
throw new Error(
`could not resolve the screenshot instance's Postgres port from ` +
`${resolve(instanceRoot, "db", "postmaster.pid")} or ${resolve(instanceRoot, "config.json")}. ` +
"Refusing to guess (54329 may be your real local instance).",
);
}
/**
* Open a postgres.js connection to the screenshot instance's embedded database.
* The `postgres` client is resolved from the parent repo's node_modules (it is
* not a dependency of paperclip-docs). Caller must `await sql.end()`.
*/
export function openInstanceDb(home = scratchHome()) {
const port = resolvePostgresPort(home);
// 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"));
const postgres = requireFromParent("postgres");
return postgres(`postgres://paperclip:[email protected]:${port}/paperclip`, {
max: 1,
idle_timeout: 5,
onnotice: () => {},
});
}
/**
* Mint a local agent JWT for the screenshot instance — the same token the
* server hands a real adapter run.
*
* A handful of endpoints are gated on `actor.source === "agent_jwt"`
* specifically and reject the simpler agent API key (secret proposals, for
* one: "Secret proposals require a verified run-bound agent token"). Rather
* than insert those rows behind the API's back, the seed signs a genuine token
* and drives the real endpoint.
*
* Mirrors `server/src/agent-auth-jwt.ts` → `createLocalAgentJwt()`: HS256 over
* a per-instance, per-company derived key, so a token minted here is valid only
* against this throw-away instance and only for the company it names. The
* master secret is read from the instance's own generated `.env`.
*
* @returns {string|null} the token, or null when the instance has no secret.
*/
export function mintAgentJwt(
{ agentId, companyId, runId, adapterType = "process", responsibleUserId = "local-board" },
home = scratchHome(),
) {
let secret;
try {
const env = readFileSync(resolve(home, "instances", INSTANCE_ID, ".env"), "utf8");
secret = /^PAPERCLIP_AGENT_JWT_SECRET=(.*)$/m.exec(env)?.[1]?.trim();
} catch {
return null;
}
if (!secret) return null;
const b64 = (value) => Buffer.from(value, "utf8").toString("base64url");
const now = Math.floor(Date.now() / 1000);
const claims = {
sub: agentId,
company_id: companyId,
adapter_type: adapterType,
run_id: runId,
responsible_user_id: responsibleUserId,
iat: now,
exp: now + 3600,
iss: "paperclip",
aud: "paperclip-api",
instance_id: INSTANCE_ID,
};
const signingInput = `${b64(JSON.stringify({ alg: "HS256", typ: "JWT" }))}.${b64(JSON.stringify(claims))}`;
// Domain-separated per-company/per-instance key derivation — see
// deriveCompanySigningKey() in the parent's agent-auth-jwt.ts.
const signingKey = createHmac("sha256", secret)
.update(`jwt:${INSTANCE_ID}:${companyId}`)
.digest("hex");
const signature = createHmac("sha256", signingKey).update(signingInput).digest("base64url");
return `${signingInput}.${signature}`;
}