diff --git a/tools/docs-media/capture.mjs b/tools/docs-media/capture.mjs index a0866b69..7102378b 100644 --- a/tools/docs-media/capture.mjs +++ b/tools/docs-media/capture.mjs @@ -12,7 +12,7 @@ * node capture.mjs --only adopt-modal */ import { chromium } from "playwright"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; const arg = (n, d) => { @@ -23,6 +23,18 @@ const BASE = arg("--base", process.env.PADDOCK_RIG_BASE || "http://127.0.0.1:400 const OUT = arg("--out", process.env.PADDOCK_SHOTS_OUT || "./shots"); const ONLY = arg("--only", null); +/** + * Appearance is per-BROWSER, not per-instance: three localStorage keys read by + * an inline pre-paint script in index.html. There is no server-side theme, so a + * capture has to pin them itself. + * + * Default is the out-of-the-box appearance — Foundation, dark, the theme's own + * accent, no tint. That is what a reader sees on first boot, which is the whole + * job of a documentation screenshot. Override for the theme quartet only. + */ +const SHOT_THEME = process.env.PADDOCK_SHOT_THEME || "foundation"; +const SHOT_DARK = (process.env.PADDOCK_SHOT_MODE || "dark") === "dark"; + mkdirSync(OUT, { recursive: true }); /** @@ -98,7 +110,48 @@ async function assertClean(page, label) { * an element shot of a 4-row chat list is ~40% empty black — which reads as a * sloppy screenshot rather than as a short list. */ -async function shoot(page, name, { selector = null, fitToLast = null, pad = 8 } = {}) { +/** + * Record WHAT WAS ON SCREEN beside the shot, as `.png.json`. + * + * With four runtime themes and a free accent picker, "which theme is this?" is + * no longer answerable from the PNG — and that question is most of what made + * deciding this re-shoot expensive. A sidecar turns it into a file read. + * + * Everything here is OBSERVED from the live page, not restated from the config + * that was requested: the point is evidence that the intended appearance + * actually applied, so a shot taken with a silently-failed theme is detectable + * afterwards rather than only at capture time. + */ +async function provenance(page, name, viewport) { + return page.evaluate( + ([shotName, vp]) => { + const root = document.documentElement; + const cs = getComputedStyle(root); + let stored = {}; + try { + stored = JSON.parse(localStorage.getItem("paddock:appearance") || "{}"); + } catch {} + // The instance stamps its own version into the sidebar; that is the + // build that is literally in the frame. + const v = (document.body.innerText || "").match(/\bv(\d+\.\d+\.\d+)\b/); + return { + shot: shotName, + route: location.pathname, + viewport: vp, + theme: stored.theme ?? null, + mode: root.classList.contains("dark") ? "dark" : "light", + hue: stored.hue ?? null, + tint: stored.tint ?? 0, + // Bare space-separated sRGB channels — the branding seam's format. + accent: cs.getPropertyValue("--accent").trim() || null, + appVersion: v ? v[1] : null, + }; + }, + [name, viewport], + ); +} + +async function shoot(page, name, { selector = null, fitToLast = null, pad = 8 } = {}, viewport) { await mask(page); await assertClean(page, name); const file = path.join(OUT, `docs-${name}.png`); @@ -120,7 +173,9 @@ async function shoot(page, name, { selector = null, fitToLast = null, pad = 8 } } else { await page.screenshot({ path: file, scale: "css" }); } - console.log(` ✓ ${file}`); + const meta = await provenance(page, name, viewport); + writeFileSync(`${file}.json`, JSON.stringify(meta, null, 2) + "\n"); + console.log(` ✓ ${file} [${meta.theme}/${meta.mode} accent=${meta.accent} v${meta.appVersion}]`); return file; } @@ -215,7 +270,7 @@ shot("root-home", { width: 1280, height: 800 }, async (page) => { // 3 · using/creating-and-organizing-projects.md:405 — Promote to project. // NB the opener is an unlabelled hover-only "+" on a root chat row, NOT a // button reading "Promote to project" as the prose claims. -shot("promote-dialog", { width: 1180, height: 700 }, async (page) => { +shot("promote-to-project", { width: 1180, height: 700 }, async (page) => { await page.goto(`${BASE}/chat`); // The opener is opacity-0 until the chat row is hovered // (SessionSidebar.tsx:411), so hover the row before clicking. @@ -232,13 +287,66 @@ shot("promote-dialog", { width: 1180, height: 700 }, async (page) => { // 6 · guides/agent-capabilities.md:159-178 — tool picker, Bash ticked, amber // warning visible. The warning renders ONLY while Bash is ticked // (TriggersPane.tsx:808), so ticking is the shot. -shot("trigger-bash-warning", { width: 900, height: 820 }, async (page) => { +shot("trigger-tool-picker-bash", { width: 900, height: 820 }, async (page) => { await page.goto(`${BASE}/projects/tidepool/triggers`); await page.getByTestId("add-trigger").click(); await page.getByRole("checkbox", { name: /^Bash/ }).check(); await page.getByText(/lets this trigger run arbitrary shell commands/).waitFor(); }); +// 7 · configuration/config-file.md:712 — the project Settings tab. #768 rebuilt +// this screen STRUCTURALLY, not just repainted it, so the old frame is +// wrong about layout and not merely about colour. Framed on the form's +// scroll container rather than the window: the subject is the settings +// measure, and the sidebar beside it adds nothing at docs-column width. +shot( + "project-settings", + { width: 1180, height: 900 }, + async (page) => { + await page.goto(`${BASE}/projects/tidepool/settings`); + await page.getByText(/Summary|Domain|Model/).first().waitFor(); + await page.waitForLoadState("networkidle"); + }, + { selector: "main" }, +); + +// 8 · The Appearance section of /config (#780). NOTHING on the site illustrates +// the four themes or the accent picker — the feature is un-illustrated +// anywhere, which is why this is net-new rather than a re-shoot. +shot( + "appearance-panel", + { width: 1100, height: 760 }, + async (page) => { + await page.goto(`${BASE}/config`); + await page.getByRole("heading", { name: "Appearance" }).waitFor(); + await page.getByText("The neutral base. Warm ground, terracotta accent.").waitFor(); + }, + { selector: "section:has(h3:text-is('Appearance'))" }, +); + +// 9 · The theme quartet for the 0.67 entry. The SAME route in all four themes, +// driven by $PADDOCK_SHOT_THEME — four separate runs, four files. This is +// the ONE shot that must not be Foundation-only, because the subject is the +// choice itself. +// +// Four screenshots of one URL at one viewport is precisely the +// configuration that has produced byte-identical files before, so md5sum +// the four before believing you have four. +shot(`theme-${SHOT_THEME}`, { width: 1280, height: 800 }, async (page) => { + await page.goto(`${BASE}/projects/tidepool/settings`); + await page.waitForLoadState("networkidle"); + await page.getByText("Tidepool").first().waitFor(); +}); + +// 10 · /discover (#745/#802). 0.68 is the newest What's New entry and carries +// NO image at all. Discovery is also what an empty instance renders as its +// Home, so this doubles as the first-run screen. +shot("discover", { width: 1280, height: 800 }, async (page) => { + await page.goto(`${BASE}/discover`); + await page.waitForLoadState("networkidle"); + await page.getByText(/Discover|scan|candidate/i).first().waitFor(); +}); + // --------------------------------------------------------------------------- async function main() { @@ -249,11 +357,43 @@ async function main() { const s = SHOTS[name]; if (!s) throw new Error(`no such shot: ${name}`); const ctx = await browser.newContext({ viewport: s.viewport, deviceScaleFactor: 2 }); + // addInitScript, NOT page.evaluate after goto: the keys are read by a + // pre-paint inline script, so writing them after navigation gives you a + // flash of the wrong theme and, worse, a shot taken mid-swap. This runs + // before any page script, on every navigation. + await ctx.addInitScript( + ([theme, dark]) => { + try { + localStorage.setItem("paddock:theme", dark ? "dark" : "light"); + localStorage.setItem( + "paddock:appearance", + JSON.stringify({ theme, hue: null, tint: 0 }), + ); + // Keyed :. A stale entry paints the PREVIOUS + // theme's solved accent before React boots, and a fast shot catches + // exactly that frame. Removing it is not optional. + localStorage.removeItem("paddock:appearance-cache"); + } catch {} + }, + [SHOT_THEME, SHOT_DARK], + ); const page = await ctx.newPage(); try { console.log(`→ ${name}`); await s.fn(page); - await shoot(page, name, s.opts); + // Assert the theme actually took rather than trusting it. Do not try to + // verify by grepping CSS: OKLCH serialises as `oklch(...)` and --accent + // is a bare RGB triple, so regex readers score a themed build zero. + const applied = await page.evaluate(() => ({ + dark: document.documentElement.classList.contains("dark"), + accent: getComputedStyle(document.documentElement).getPropertyValue("--accent").trim(), + })); + if (applied.dark !== SHOT_DARK || !applied.accent) { + throw new Error( + `theme did not apply (dark=${applied.dark} want ${SHOT_DARK}, accent="${applied.accent}")`, + ); + } + await shoot(page, name, s.opts, s.viewport); } catch (e) { failed++; console.error(` ✗ ${name}: ${String(e).split("\n")[0]}`); diff --git a/tools/docs-media/drive.mjs b/tools/docs-media/drive.mjs new file mode 100644 index 00000000..a6070ea7 --- /dev/null +++ b/tools/docs-media/drive.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * Drive real turns into the rig so the chat list has texture. + * + * seed.mjs creates projects and adoptable transcripts; it cannot create CHATS, + * because a chat is the product of a turn. This runs those turns through the + * fake `claude` on the rig's PATH, so it costs nothing and is deterministic: + * replies come from $PADDOCK_FAKE_SCRIPT (a prompt -> reply JSON map), which is + * how the on-camera text is authored rather than improvised. + * + * Env: + * PADDOCK_RIG_HOME required — same var as serve.sh (identity guard) + * PADDOCK_RIG_BASE instance URL (default http://127.0.0.1:4000) + * + * Usage: node drive.mjs [--base http://127.0.0.1:PORT] + */ +import WebSocket from "ws"; + +const RIG = process.env.PADDOCK_RIG_HOME; +if (!RIG) { + console.error("set PADDOCK_RIG_HOME (the rig scratch root — the same value serve.sh uses)"); + process.exit(1); +} +const argBase = process.argv.indexOf("--base"); +const BASE = + argBase > -1 ? process.argv[argBase + 1] : process.env.PADDOCK_RIG_BASE || "http://127.0.0.1:4000"; +const API = `${BASE}/api`; +const WS = BASE.replace(/^http/, "ws") + "/ws"; + +// Same identity guard as seed.mjs. Driving turns into a stale squatter would +// write real chats into somebody else's instance — and on a `session` instance +// it would bill real money. +async function assertIsRig() { + const r = await fetch(`${API}/instance-config`); + if (!r.ok) throw new Error(`no instance at ${BASE} (${r.status})`); + const cfg = await r.json(); + const field = (k) => cfg.groups?.flatMap((g) => g.fields ?? []).find((f) => f.key === k)?.value; + if (field("dataDir") !== `${RIG}/data`) { + throw new Error(`REFUSING TO DRIVE: ${BASE} reports dataDir=${field("dataDir")}, not ${RIG}/data`); + } + if (field("driveMode") !== "batch") { + throw new Error(`REFUSING TO DRIVE: driveMode=${field("driveMode")} (real credit risk)`); + } + console.log(`✓ verified ${BASE} is the rig`); +} + +/** + * Send one message and resolve when the turn completes. + * + * `slug` is the WORKSPACE KEY, and the root workspace's key is the EMPTY + * STRING — so this takes it verbatim and never tests it for truthiness. A + * `if (!slug)` here would silently redirect every root chat to a project. + */ +function send(slug, message, sessionId = null) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(WS); + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`timeout waiting for chat:complete (${String(message).slice(0, 40)}…)`)); + }, 120000); + ws.on("open", () => + ws.send(JSON.stringify({ type: "chat:send", payload: { projectSlug: slug, sessionId, message } })), + ); + ws.on("message", (buf) => { + let f; + try { + f = JSON.parse(buf.toString()); + } catch { + return; + } + if (f.type === "chat:complete") { + clearTimeout(timer); + ws.close(); + resolve(f.payload); + } + if (f.type === "chat:error") { + clearTimeout(timer); + ws.close(); + reject(new Error(JSON.stringify(f.payload))); + } + }); + ws.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); +} + +const patch = (slug, sessionId, body) => + fetch(`${API}/${slug === "" ? "root" : `projects/${slug}`}/chats/${sessionId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +// --------------------------------------------------------------------------- +// The script. Each entry is one chat: a workspace, the prompt to send, and the +// display name to give the resulting chat. The prompts are KEYS into +// fixtures.json, so the reply on camera is authored, not improvised. +// --------------------------------------------------------------------------- +const CHATS = [ + ["tidepool", "Why do cold starts take 40s on the ingest worker?", "Why cold starts take 40s on the ingest worker"], + ["tidepool", "Add a staleness alert for silent gauges.", "Add a staleness alert for silent gauges"], + ["tidepool", "Normalise the Dover sensor's timestamp drift.", "Normalise the Dover sensor's timestamp drift"], + ["tidepool", "Backfill 2024 readings from the archive bucket.", "Backfill 2024 readings from the archive bucket"], + ["lanternfish", "The overnight run missed its window again.", "Overnight run missed its window again"], + ["lanternfish", "Split the batch queue by priority.", "Split the batch queue by priority"], + ["harbour-notes", "Compare the two survey methods.", "Compare the two survey methods"], + ["", "Compare the two survey methods.", "Compare the two survey methods"], + ["", "Draft the release note for the tide model review.", "Notes from the tide model review"], +]; + +async function main() { + await assertIsRig(); + const made = []; + for (const [slug, prompt, name] of CHATS) { + const where = slug === "" ? "(root)" : slug; + try { + const res = await send(slug, prompt); + const id = res?.sessionId; + if (id) { + made.push({ slug, id, name }); + console.log(`✓ ${where}: turn complete`); + } else { + console.log(`! ${where}: completed without a sessionId`); + } + } catch (e) { + console.log(`✗ ${where}: ${String(e).split("\n")[0]}`); + } + } + + // Rename in a SECOND PASS, after every turn has finished. + // + // Renaming immediately after `chat:complete` loses the race: the transcript's + // own title resolution (ai-title, else the first user message) lands after the + // turn completes and overwrites the custom name. The symptom is subtle — the + // chat is named the prompt you sent rather than the name you set, which looks + // like a name you chose badly rather than a write that was clobbered. + for (const m of made) { + const r = await patch(m.slug, m.id, { name: m.name }); + console.log(`${r.ok ? "✓" : "✗"} named: ${m.name}`); + } + + // Texture: one starred, one left unread. Both are shots in their own right + // (starred-chats, mark-unread) and both make the sidebar look like an + // instance somebody actually uses rather than a fresh seed. + const tide = made.filter((m) => m.slug === "tidepool"); + if (tide[0]) { + // Starring is its OWN route (POST …/star), not a field on the rename PATCH + // — whose body schema accepts `name` only, so a `starred` key there is + // accepted and silently dropped. + await fetch(`${API}/projects/tidepool/chats/${tide[0].id}/star`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ starred: true }), + }); + console.log(`✓ starred: ${tide[0].name}`); + } + if (tide[1]) { + await fetch(`${API}/projects/tidepool/chats/${tide[1].id}/unread`, { method: "POST" }).catch( + () => {}, + ); + console.log(`✓ marked unread: ${tide[1].name}`); + } + + console.log(`\ndrove ${made.length} chats`); +} + +main().catch((e) => { + console.error(String(e)); + process.exit(1); +}); diff --git a/tools/docs-media/fixtures.json b/tools/docs-media/fixtures.json new file mode 100644 index 00000000..8f79422b --- /dev/null +++ b/tools/docs-media/fixtures.json @@ -0,0 +1,10 @@ +{ + "Why do cold starts take 40s on the ingest worker?": "The 40s is almost all module resolution, not work. The worker imports the whole `readings` package at top level to get one date helper, and that package pulls in the parquet reader and its native bindings.\n\nTwo things would fix it:\n\n1. Import the helper directly (`readings/time`) rather than the package root.\n2. Move the parquet reader behind a lazy import so it only loads on a backfill.\n\nCold start drops to about 4s in a local run. Nothing else on the path is slow.", + "Add a staleness alert for silent gauges.": "Added. The poller now records `lastSeenAt` per gauge, and a sweep every five minutes flags anything past its expected interval times three.\n\nThe threshold is per-gauge rather than global on purpose: the harbour gauges report every 60s, but the offshore buoys are on a 15-minute cadence, so one global timeout would either spam for the buoys or never fire for the harbour.", + "Normalise the Dover sensor's timestamp drift.": "The Dover unit stamps readings in local time with no offset, so everything between late March and late October lands an hour early once it is stored as UTC.\n\nI have put the correction in the ingest normaliser rather than in a migration, because the raw archive should stay exactly as the sensor sent it. The normaliser now reads the site's timezone from the gauge registry and converts on the way in.", + "Backfill 2024 readings from the archive bucket.": "Backfill is running. 2024 is 8.4M readings across 312 objects, and the writer is committing in batches of 5,000.\n\nOne thing worth knowing before it finishes: the archive has three objects for 2024-11-03 where the same hour appears twice. That is the DST fold, not duplication — both hours are real. The normaliser keeps them because they carry different UTC offsets.", + "The overnight run missed its window again.": "It did not miss the window — it started 40 minutes late because the queue was still draining the previous day's retries.\n\nThe scheduler's window check runs at enqueue time, not at start time, so a job that waits in the queue is still counted as on time. That is why the dashboard shows green for a run that finished two hours after its deadline.", + "Split the batch queue by priority.": "Split into three queues — interactive, nightly and backfill — with the workers weighted 4:2:1.\n\nThe weighting matters more than the split: with an even share, a large backfill still starves the nightly jobs, because backfill work items are individually much longer. The 4:2:1 keeps the nightly queue moving even while a backfill is saturating its own workers.", + "Compare the two survey methods.": "The short version is that they answer different questions.\n\nThe transect method gives you density along a fixed line, repeatable year on year, and is cheap to run — but it systematically under-counts anything that avoids the line. The quadrat method samples area rather than distance, so it catches the patchy distributions the transects miss, at roughly three times the field time.\n\nFor the tide-pool work specifically, the patchiness is the whole signal, so quadrats are worth the extra cost.", + "Draft the release note for the tide model review.": "Here is a draft:\n\n> **Tide model review — outcome.** The revised model reduces mean absolute error at the harbour gauges from 11cm to 4cm, with the largest gains on spring tides. The offshore buoys are unchanged, which is expected: the revision targets the shallow-water terms.\n>\n> The review recommended one follow-up — re-fitting the Dover coefficients once the timestamp correction has been backfilled, since the current fit was trained on the drifted series." +} diff --git a/tools/docs-media/seed.mjs b/tools/docs-media/seed.mjs new file mode 100644 index 00000000..2dd24c5d --- /dev/null +++ b/tools/docs-media/seed.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +/** + * Re-runnable seed for the docs media rig. + * + * The point of this file is that re-shooting after a design overhaul is a + * RE-RUN, not a rebuild. Nothing here is hand-clicked. + * + * Everything is synthetic. No production data is copied, so there is nothing to + * scrub and no title to fictionalise after the fact — the names below are the + * only names the rig has ever seen. + * + * Env: + * PADDOCK_RIG_HOME required — the rig scratch root (same var as serve.sh) + * PADDOCK_RIG_BASE instance URL (default http://127.0.0.1:4000) + * + * Usage: node seed.mjs [--base http://127.0.0.1:PORT] + */ +import { mkdirSync, writeFileSync, utimesSync as fsUtimes } from "node:fs"; +import path from "node:path"; + +const RIG = process.env.PADDOCK_RIG_HOME; +if (!RIG) { + console.error("set PADDOCK_RIG_HOME (the rig scratch root — the same value serve.sh uses)"); + process.exit(1); +} + +const argBase = process.argv.indexOf("--base"); +// Default matches capture.mjs. The two files disagreeing on a default port is +// itself a bug, so they are kept identical here deliberately. +const BASE = + argBase > -1 ? process.argv[argBase + 1] : process.env.PADDOCK_RIG_BASE || "http://127.0.0.1:4000"; +const API = `${BASE}/api`; + +// --- guard: refuse to seed anything that is not the rig --------------------- +// `pm status: online` and `/api/health: 200` are both satisfied by a stale +// squatter on the same port; a seeding run has already written into the wrong +// instance that way. Check identity, not liveness. +// +// Identity is the DATA DIR, not the projects dir: the projects dir is +// deliberately relocated to a presentable synthetic path that another instance +// could plausibly share, whereas the data dir is unique to this rig. Also +// assert driveMode=batch — a `session` instance here would mean the fake +// `claude` is being ignored and real credit is being spent. +let PROJECTS_DIR = null; +async function assertIsRig() { + const r = await fetch(`${API}/instance-config`); + if (!r.ok) throw new Error(`no instance at ${BASE} (${r.status})`); + const cfg = await r.json(); + const field = (k) => cfg.groups?.flatMap((g) => g.fields ?? []).find((f) => f.key === k)?.value; + const dataDir = field("dataDir"); + const driveMode = field("driveMode"); + if (dataDir !== `${RIG}/data`) { + throw new Error( + `REFUSING TO SEED: ${BASE} reports dataDir=${dataDir}, not ${RIG}/data. ` + + `That is somebody else's instance — a stale squatter satisfies both ` + + `\`pm status: online\` and \`/api/health: 200\`.`, + ); + } + if (driveMode !== "batch") { + throw new Error(`REFUSING TO SEED: driveMode=${driveMode}, expected batch (real credit risk)`); + } + // Read the projects root back off the running server rather than hard-coding + // it a second time — the previous version of this file duplicated the path + // and the two could silently disagree. + PROJECTS_DIR = field("projectsDir") || `${RIG}/projects`; + console.log(`✓ verified ${BASE} is the rig (dataDir=${dataDir}, driveMode=${driveMode})`); + console.log(` projects root: ${PROJECTS_DIR}`); +} + +const j = async (method, url, body) => { + const r = await fetch(url, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await r.text(); + if (!r.ok) throw new Error(`${method} ${url} -> ${r.status} ${text.slice(0, 300)}`); + return text ? JSON.parse(text) : null; +}; + +// --- the cast --------------------------------------------------------------- +// Fictional throughout: a small consultancy's internal tooling. Plausible +// enough to read as real work, invented enough that no title is a signal. +// +// `area` gives the projects grid something to group by — two areas, so the +// grouped layout actually has more than one heading. +const PROJECTS = [ + { + name: "Tidepool", + slug: "tidepool", + summary: "Ingest and normalise tide-gauge readings from the coastal sensor network.", + domain: ["data", "ingest"], + group: "Platform", + }, + { + name: "Lanternfish", + slug: "lanternfish", + summary: "Scheduling service for the overnight batch fleet.", + domain: ["infra"], + group: "Platform", + }, + { + name: "Harbour Notes", + slug: "harbour-notes", + summary: "Research notebook — survey methods, references, meeting notes.", + domain: ["research"], + group: "Research", + }, +]; + +// A disabled curate-overview trigger, set BEFORE seeding. The rig runs a real +// keeper, so a completed turn enqueues a sweep and the sweeper replaces +// OVERVIEW.md/CHANGELOG.md wholesale — which is how a previous pass lost its +// demo OVERVIEW between seeding and shooting. +// NB: `run` is REQUIRED — a two-key definition (trigger + enabled) is rejected +// with a bare {"error":"Invalid trigger definition"} that does not say which +// key is missing. +const DISABLED_CURATION = { + trigger: { type: "event", on: "afterTurn" }, + run: { prompt: "disabled placeholder — this trigger exists only to be off" }, + enabled: false, +}; + +async function seedProjects() { + for (const p of PROJECTS) { + // No `repo:` and no `path:` — nothing in this rig may reach a real + // repository or a directory outside its own scratch tree. + await j("POST", `${API}/projects`, { + name: p.name, + slug: p.slug, + summary: p.summary, + domain: p.domain, + group: p.group, + }).catch((e) => { + if (!String(e).includes("exists")) throw e; + console.log(` (${p.slug} already exists)`); + }); + console.log(`✓ project ${p.slug}`); + + await j("PUT", `${API}/projects/${p.slug}/triggers/curate-overview`, DISABLED_CURATION).catch( + (e) => console.log(` ! trigger on ${p.slug}: ${String(e).slice(0, 120)}`), + ); + } + + // The root workspace curates too — the sweeper skips only when THIS + // workspace's trigger is disabled, so root needs its own. + await j("PUT", `${API}/root/triggers/curate-overview`, DISABLED_CURATION); + console.log("✓ curation disabled on all workspaces incl. root"); +} + +// --------------------------------------------------------------------------- +// Adoptable native sessions (the adopt-row / adopt-modal / adopted-badge shots). +// +// Recipe verified from source, not guessed: +// - file lives in /.chats/.jsonl (the project's OWN +// source, appended unconditionally at adoptable.ts:569 — no `cwd` field and +// no git setup needed, unlike the scanned-folder route) +// - sessionId must match /^[A-Za-z0-9-]+$/ (cli-session-path.js:177) +// - file must be >= 256 bytes (adoptable.ts:166,242 `too-small`) +// - first non-blank line must not be isSidechain:true (jsonl-parser.js:515) +// - must have NO job-*.yaml and no adopted-sessions record, or the engine +// classifies it `attributed-to-run` and it never reaches paddock +// +// The on-camera label is resolved custom-title > ai-title > summary > first +// user message (truncated at 100 chars) > raw UUID. We set an explicit +// custom-title so the modal shows a sentence we chose, not a UUID. +// --------------------------------------------------------------------------- +const NATIVE = { + tidepool: [ + { id: "7c1f9a34-2b60-4e18-9d51-a0c3e7b41d92", title: "Trace the duplicate gauge readings", days: 6 }, + { id: "b48e05c7-9d13-4a72-8f60-1e5cb9270a3f", title: "Rename the ingest CLI flags", days: 3 }, + { id: "e2a76d10-4c85-4b39-a7e2-63f0d81b5c47", title: "Draft the sensor onboarding checklist", days: 1 }, + // The 4th exists so that ADOPTING one (to stage the adopted badge) still + // leaves THREE adoptable — the count the modal shot wants. Adopt is one-way + // within a run, so the two shots would otherwise compete for the same + // fixtures. + { id: "5d93c8b2-6a41-4e07-b1f8-2c7a0e6d4931", title: "Chase down the missing archive manifest", days: 9 }, + ], +}; + +/** A transcript that is plausible, comfortably over 256 B, and says nothing. */ +function transcript(cwd, title, userText) { + const lines = [ + { type: "custom-title", customTitle: title }, + { type: "user", cwd, sessionId: "seed", message: { role: "user", content: userText } }, + { + type: "assistant", + cwd, + message: { + role: "assistant", + content: [ + { + type: "text", + text: + "Looked through the poller and the archive writer. The duplicate rows come " + + "from the retry path re-submitting a batch that had already been committed, " + + "so the fix belongs at the commit boundary rather than in the dedupe pass.", + }, + ], + }, + }, + ]; + return lines.map((l) => JSON.stringify(l)).join("\n") + "\n"; +} + +function seedNativeSessions() { + for (const [slug, sessions] of Object.entries(NATIVE)) { + const projectDir = path.join(PROJECTS_DIR, slug); + const chats = path.join(projectDir, ".chats"); + mkdirSync(chats, { recursive: true }); + for (const s of sessions) { + const file = path.join(chats, `${s.id}.jsonl`); + const body = transcript( + projectDir, + s.title, + "The nightly load is writing some readings twice. Can you work out where the " + + "duplication is introduced before I touch the dedupe pass?", + ); + writeFileSync(file, body); + if (Buffer.byteLength(body) < 256) throw new Error(`${file} under the 256 B floor`); + // Spread the mtimes so the modal's date column has texture rather than + // three identical dates. `mtime` is what the modal renders. + const when = new Date(Date.now() - s.days * 864e5); + fsUtimes(file, when, when); + } + console.log(`✓ ${sessions.length} adoptable native sessions for ${slug}`); + } +} + +async function main() { + await assertIsRig(); + await seedProjects(); + seedNativeSessions(); + + // Prove the state actually rendered rather than assuming the recipe worked. + const res = await j("GET", `${API}/projects/tidepool/adoptable-chats`); + console.log(`\nadoptable count = ${res.count} (sources: ${res.sources?.length ?? 0})`); + if (res.filtered?.length) console.log(`filtered: ${JSON.stringify(res.filtered)}`); + if (res.count === 0) { + console.log("!! count is 0 — the adopt row will NOT render. Check the exclusion list."); + } + console.log("\nseed complete"); +} + +main().catch((e) => { + console.error(String(e)); + process.exit(1); +}); diff --git a/tools/docs-media/serve.sh b/tools/docs-media/serve.sh new file mode 100755 index 00000000..a1b82365 --- /dev/null +++ b/tools/docs-media/serve.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# docs-media rig launcher. +# +# Stands up a Paddock instance that is safe to photograph: synthetic projects, +# a fake `claude`, an isolated Claude home, no credentials, no branding. +# +# Required env: +# PADDOCK_RIG_HOME scratch root — holds home/, data/, projects/ +# PADDOCK_RIG_CLONE a built checkout (packages/{web,server}/dist) +# Optional: +# PADDOCK_RIG_PROJECTS projects root (default "$PADDOCK_RIG_HOME/projects") +# PORT injected by the process manager; required +set -euo pipefail + +RIG="${PADDOCK_RIG_HOME:?set PADDOCK_RIG_HOME}" +CLONE="${PADDOCK_RIG_CLONE:?set PADDOCK_RIG_CLONE}" + +# --------------------------------------------------------------------------- +# Re-exec under a SCRUBBED environment. This is the load-bearing safety +# mechanism of the whole file, not a tidiness measure. +# +# A process manager copies the operator's whole environment. If an inherited +# CLAUDE_CODE_OAUTH_TOKEN meets a drive mode that ignores the fake `claude`, +# the rig quietly bills real money while LOOKING like it worked: turns complete +# fast, with plausible replies. `env -i` removes the ingredient rather than +# relying on remembering to unset it. +# --------------------------------------------------------------------------- +if [ -z "${DOCS_MEDIA_CLEANENV:-}" ]; then + exec /usr/bin/env -i \ + DOCS_MEDIA_CLEANENV=1 \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + PORT="${PORT:-}" \ + TERM=xterm \ + PADDOCK_RIG_HOME="$RIG" \ + PADDOCK_RIG_CLONE="$CLONE" \ + PADDOCK_RIG_PROJECTS="${PADDOCK_RIG_PROJECTS:-}" \ + "$0" "$@" +fi + +# --- isolation -------------------------------------------------------------- +# HOME and CLAUDE_CONFIG_DIR must BOTH be isolated. PADDOCK_DATA_DIR isolates +# the data dir only; anything resolving the Claude home via os.homedir() lands +# on the operator's real ~/.claude — real transcripts and a real login. +export HOME="$RIG/home" + +# Precedence is CLAUDE_CONFIG_DIR > `claudeHome:` > /claude-home. +# CLAUDE_HOME was removed (#691) and is IGNORED rather than an error, so a +# launcher still exporting it silently falls back to the default while you +# believe you isolated. Paddock also refuses to start if the home resolves to a +# user's own ~/.claude — a guard, not a substitute for setting this correctly. +export CLAUDE_CONFIG_DIR="$RIG/data/claude-home" +export PADDOCK_DATA_DIR="$RIG/data" + +# --- the projects root ------------------------------------------------------ +# MUST be on persistent storage. The previous rig pointed this at /home/demo on +# a box where only /data was a volume; a container restart destroyed every +# project.yaml and every .chats/*.jsonl while the data dir survived, leaving +# orphaned job records that reported chats whose transcripts were gone. +export PADDOCK_PROJECTS_DIR="${PADDOCK_RIG_PROJECTS:-$RIG/projects}" +export PADDOCK_WEB_DIST="$CLONE/packages/web/dist" + +# --- exposure --------------------------------------------------------------- +# Auth is OFF, so bind LOOPBACK ONLY. Capture runs on the same host, so this is +# sufficient — and it means the rig is never reachable from the network. Do not +# reach for PADDOCK_DANGEROUSLY_ALLOW_OPEN to bind 0.0.0.0 instead: with auth +# off that publishes an unauthenticated instance. +export PADDOCK_AUTH_MODE=none +export HOST=127.0.0.1 +export PADDOCK_OPENAPI_ENABLED=1 +export LOG_LEVEL=info + +# --- $0 turns --------------------------------------------------------------- +# The fake `claude` is a CLI stub, so turns MUST run on the batch runtime. The +# DEFAULT drive mode is `session`, which uses the SDK runtime, ignores PATH +# entirely, and would call the real API. This line is what stops real billing. +export PADDOCK_DRIVE_MODE=batch +export PATH="$CLONE/test/bin:$PATH" + +# Belt and braces after `env -i`: derive the unset list from the environment +# rather than hand-writing it, so a newly-added credential var is covered. +for v in $(env | cut -d= -f1 | grep -E 'TOKEN|API_KEY|SECRET|PASSWORD|_KEY$' || true); do + unset "$v" || true +done +unset PADDOCK_BRAND_NAME PADDOCK_BRAND_LOGO PADDOCK_BRAND_ACCENT || true + +echo "docs-media rig: HOME=$HOME DATA=$PADDOCK_DATA_DIR PROJECTS=$PADDOCK_PROJECTS_DIR PORT=${PORT:-unset}" +exec node "$CLONE/packages/server/dist/cli/paddock.js" --port "${PORT:?PORT not injected}" diff --git a/tools/docs-media/shots.manifest.json b/tools/docs-media/shots.manifest.json new file mode 100644 index 00000000..b1912ece --- /dev/null +++ b/tools/docs-media/shots.manifest.json @@ -0,0 +1,184 @@ +{ + "docs-adopt-modal.png": { + "shot": "adopt-modal", + "route": "/projects/tidepool/chat", + "viewport": { + "width": 1180, + "height": 780 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-adopt-row.png": { + "shot": "adopt-row", + "route": "/projects/tidepool/chat", + "viewport": { + "width": 1180, + "height": 620 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-adopted-badge.png": { + "shot": "adopted-badge", + "route": "/projects/tidepool/chat", + "viewport": { + "width": 1180, + "height": 620 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-appearance-panel.png": { + "shot": "appearance-panel", + "route": "/config", + "viewport": { + "width": 1100, + "height": 760 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-discover.png": { + "shot": "discover", + "route": "/discover", + "viewport": { + "width": 1280, + "height": 800 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-project-settings.png": { + "shot": "project-settings", + "route": "/projects/tidepool/settings", + "viewport": { + "width": 1180, + "height": 900 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-promote-to-project.png": { + "shot": "promote-to-project", + "route": "/chat", + "viewport": { + "width": 1180, + "height": 700 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-root-home.png": { + "shot": "root-home", + "route": "/", + "viewport": { + "width": 1280, + "height": 800 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-theme-foundation.png": { + "shot": "theme-foundation", + "route": "/projects/tidepool/settings", + "viewport": { + "width": 1280, + "height": 800 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + }, + "docs-theme-parchment.png": { + "shot": "theme-parchment", + "route": "/projects/tidepool/settings", + "viewport": { + "width": 1280, + "height": 800 + }, + "theme": "parchment", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "229 172 76", + "appVersion": "0.69.0" + }, + "docs-theme-scifi.png": { + "shot": "theme-scifi", + "route": "/projects/tidepool/settings", + "viewport": { + "width": 1280, + "height": 800 + }, + "theme": "scifi", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "0 213 242", + "appVersion": "0.69.0" + }, + "docs-theme-terminal.png": { + "shot": "theme-terminal", + "route": "/projects/tidepool/settings", + "viewport": { + "width": 1280, + "height": 800 + }, + "theme": "terminal", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "48 247 109", + "appVersion": "0.69.0" + }, + "docs-trigger-tool-picker-bash.png": { + "shot": "trigger-tool-picker-bash", + "route": "/projects/tidepool/triggers", + "viewport": { + "width": 900, + "height": 820 + }, + "theme": "foundation", + "mode": "dark", + "hue": null, + "tint": 0, + "accent": "194 96 60", + "appVersion": "0.69.0" + } +} diff --git a/website/src/assets/config/project-settings.png b/website/src/assets/config/project-settings.png index c47cf162..f42585b4 100644 Binary files a/website/src/assets/config/project-settings.png and b/website/src/assets/config/project-settings.png differ diff --git a/website/src/assets/getting-started/root-home.png b/website/src/assets/getting-started/root-home.png index 180fb769..30a79ed0 100644 Binary files a/website/src/assets/getting-started/root-home.png and b/website/src/assets/getting-started/root-home.png differ diff --git a/website/src/assets/guides/trigger-tool-picker-bash.png b/website/src/assets/guides/trigger-tool-picker-bash.png index 7846cc24..f87759c0 100644 Binary files a/website/src/assets/guides/trigger-tool-picker-bash.png and b/website/src/assets/guides/trigger-tool-picker-bash.png differ diff --git a/website/src/assets/using/adopt-modal.png b/website/src/assets/using/adopt-modal.png index 100120ee..727e4cf6 100644 Binary files a/website/src/assets/using/adopt-modal.png and b/website/src/assets/using/adopt-modal.png differ diff --git a/website/src/assets/using/adopt-row.png b/website/src/assets/using/adopt-row.png index 44acacd2..7bfc03eb 100644 Binary files a/website/src/assets/using/adopt-row.png and b/website/src/assets/using/adopt-row.png differ diff --git a/website/src/assets/using/adopted-badge.png b/website/src/assets/using/adopted-badge.png index 4048a93e..0e61a5d2 100644 Binary files a/website/src/assets/using/adopted-badge.png and b/website/src/assets/using/adopted-badge.png differ diff --git a/website/src/assets/using/promote-to-project.png b/website/src/assets/using/promote-to-project.png index f36f9de8..63551a27 100644 Binary files a/website/src/assets/using/promote-to-project.png and b/website/src/assets/using/promote-to-project.png differ diff --git a/website/src/assets/whats-new/appearance-panel.png b/website/src/assets/whats-new/appearance-panel.png new file mode 100644 index 00000000..a98c2d05 Binary files /dev/null and b/website/src/assets/whats-new/appearance-panel.png differ diff --git a/website/src/assets/whats-new/theme-foundation.png b/website/src/assets/whats-new/theme-foundation.png new file mode 100644 index 00000000..c1a598ba Binary files /dev/null and b/website/src/assets/whats-new/theme-foundation.png differ diff --git a/website/src/assets/whats-new/theme-parchment.png b/website/src/assets/whats-new/theme-parchment.png new file mode 100644 index 00000000..11d767ac Binary files /dev/null and b/website/src/assets/whats-new/theme-parchment.png differ diff --git a/website/src/assets/whats-new/theme-scifi.png b/website/src/assets/whats-new/theme-scifi.png new file mode 100644 index 00000000..9a3a3b69 Binary files /dev/null and b/website/src/assets/whats-new/theme-scifi.png differ diff --git a/website/src/assets/whats-new/theme-terminal.png b/website/src/assets/whats-new/theme-terminal.png new file mode 100644 index 00000000..bb20643b Binary files /dev/null and b/website/src/assets/whats-new/theme-terminal.png differ diff --git a/website/src/content/docs/using/working-in-chats.md b/website/src/content/docs/using/working-in-chats.md index 034bfdad..4976e4c5 100644 --- a/website/src/content/docs/using/working-in-chats.md +++ b/website/src/content/docs/using/working-in-chats.md @@ -77,7 +77,7 @@ terminal icon) so you can tell them from chats started here — see [Provenance](/concepts/provenance/). They are *not* counted as unattended runs: you had those conversations, just somewhere else. -![A chat list in which one row, "Trace the duplicate gaug…", carries a small emerald terminal icon at its right edge marking it as adopted; the three chats above it have no badge](../../../assets/using/adopted-badge.png) +![A chat list in which one row, "Chase down the missing archi…", carries a small emerald terminal icon at its right edge marking it as adopted; the four chats above it have no badge](../../../assets/using/adopted-badge.png) :::note[When Paddock can't see your `~/.claude`] A containerised instance only sees what is mounted. Mount the history at the