mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 14:21:18 -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]>
307 lines
12 KiB
JavaScript
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}`;
|
|
}
|