diff --git a/package.json b/package.json index 534192a..2a4516a 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "batches": "tsx src/cli.ts batches", "run": "tsx src/cli.ts run", "serve": "tsx src/cli.ts serve", - "demo": "tsx src/cli.ts demo --root fixtures/vault --source filesystem:fixture" + "demo": "tsx src/cli.ts demo --root fixtures/vault --source filesystem:fixture", + "demo:workbench": "tsx scripts/workbench-demo.ts" }, "dependencies": { "@atproto/jwk-jose": "0.2.4", diff --git a/scripts/workbench-demo.ts b/scripts/workbench-demo.ts new file mode 100644 index 0000000..4790f2c --- /dev/null +++ b/scripts/workbench-demo.ts @@ -0,0 +1,241 @@ +/** + * Synthetic workbench demo. NOT a deployment surface. + * + * Seeds a temporary Jazz store with harmless fixture events, starts the + * loopback inspector with a generated Review capability, and starts a second + * loopback "demo front" that plays the role of the authenticated proxy: it + * answers `/inspector/api/session` with write access, signs workbench and + * decision POSTs with the capability under a CSRF check, and proxies every + * other `/inspector/*` read to the inspector. There is no authentication at + * all, so it binds 127.0.0.1 only and destroys its temporary store on exit. + */ +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomBytes, timingSafeEqual, createHash } from "node:crypto"; +import { JazzThoughtStore } from "../src/jazz/store.js"; +import { + REVIEW_CSRF_HEADER, + REVIEW_NONCE_HEADER, + REVIEW_SIGNATURE_HEADER, + REVIEW_TIMESTAMP_HEADER, + signReviewRequest, +} from "../src/review/web-capability.js"; +import { startInspectorServer } from "../src/web/inspector.js"; + +process.env.THOUGHTSTREAM_JAZZ_AUTO ??= "1"; + +const HOST = "127.0.0.1"; +const inspectorPort = Number(process.env.WORKBENCH_DEMO_INSPECTOR_PORT ?? "4317"); +const frontPort = Number(process.env.WORKBENCH_DEMO_PORT ?? "4327"); +const MAX_WRITE_BODY = 98_304; + +const root = await fs.mkdtemp(path.join(os.tmpdir(), "thoughtstream-workbench-demo-")); +const store = await JazzThoughtStore.open({ projectRoot: root, appId: `thoughtstream-workbench-demo-${randomBytes(4).toString("hex")}`, runtimeRevision: "workbench-demo" }); + +await seed(store); + +const reviewCapability = randomBytes(32); +const csrfToken = randomBytes(24).toString("base64url"); +const inspector = await startInspectorServer(store, { + host: HOST, + port: inspectorPort, + reviewCapability, + proposalActor: "operator:demo", +}); + +const front = http.createServer((request, response) => { + void handle(request, response).catch(() => { + if (!response.headersSent) { + response.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); + } + response.end("demo front error\n"); + }); +}); +await new Promise((resolve, reject) => { + front.once("error", reject); + front.listen(frontPort, HOST, () => { + front.off("error", reject); + resolve(); + }); +}); + +process.stdout.write([ + "", + "thought stream workbench demo — SYNTHETIC, NO AUTHENTICATION, LOOPBACK ONLY", + ` open: http://${HOST}:${frontPort}/inspector/`, + ` inspector: http://${HOST}:${inspectorPort} (loopback, signed writes only)`, + ` store: ${root} (temporary; removed on exit)`, + " data: a few synthetic fixture observations, no real sources", + " Ctrl+C to stop.", + "", +].join("\n")); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + front.closeAllConnections(); + inspector.closeAllConnections(); + front.close(() => { + inspector.close(() => { + void store.close() + .then(() => fs.rm(root, { recursive: true, force: true })) + .then(() => process.exit(0), () => process.exit(1)); + }); + }); + setTimeout(() => process.exit(1), 3_000).unref(); + }); +} + +async function handle(request: IncomingMessage, response: ServerResponse): Promise { + const url = new URL(request.url ?? "/", `http://${HOST}`); + if (url.pathname === "/" || url.pathname === "/inspector") { + request.resume(); + response.writeHead(302, { location: "/inspector/" }); + response.end(); + return; + } + if (!url.pathname.startsWith("/inspector/")) { + request.resume(); + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("not found\n"); + return; + } + if (url.pathname === "/inspector/api/session") { + request.resume(); + response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end(JSON.stringify({ reviewWriteEnabled: true, courseChatEnabled: false, csrfToken })); + return; + } + const writeRoute = url.search === "" && request.method === "POST" && ( + /^\/inspector\/api\/(?:reviews|proposals)\/[^/]+\/decisions$/.test(url.pathname) + || url.pathname === "/inspector/api/workbench/documents" + || /^\/inspector\/api\/workbench\/documents\/[^/]+\/(?:versions|selections|proposals)$/.test(url.pathname) + || /^\/inspector\/api\/workbench\/proposals\/[^/]+\/decisions$/.test(url.pathname) + ); + const upstreamPath = `${url.pathname.slice("/inspector".length)}${url.search}`; + if (writeRoute) { + const presented = request.headers[REVIEW_CSRF_HEADER]; + if (typeof presented !== "string" || !safeEqual(presented, csrfToken)) { + request.resume(); + response.writeHead(403, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: "Request could not be verified (CSRF)" })); + return; + } + let body: Buffer; + try { + body = await readJsonBody(request); + JSON.parse(body.toString("utf8")); + } catch { + request.resume(); + response.writeHead(400, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: "Request is invalid" })); + return; + } + const signature = signReviewRequest(reviewCapability, { method: "POST", path: upstreamPath, body }); + forward(request, response, upstreamPath, body, { + "content-type": "application/json", + "content-length": String(body.length), + [REVIEW_TIMESTAMP_HEADER]: signature.timestamp, + [REVIEW_NONCE_HEADER]: signature.nonce, + [REVIEW_SIGNATURE_HEADER]: signature.signature, + }); + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { + request.resume(); + response.writeHead(405, { "content-type": "application/json; charset=utf-8", allow: "GET, HEAD" }); + response.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + forward(request, response, upstreamPath); +} + +function forward(request: IncomingMessage, response: ServerResponse, upstreamPath: string, body?: Buffer, extra: Record = {}): void { + const headers: Record = { host: `${HOST}:${inspectorPort}` }; + for (const name of ["accept", "accept-language", "user-agent"]) { + const value = request.headers[name]; + if (typeof value === "string") headers[name] = value; + } + Object.assign(headers, extra); + const upstream = http.request({ hostname: HOST, port: inspectorPort, path: upstreamPath, method: request.method, headers }, (upstreamResponse) => { + const responseHeaders: Record = {}; + for (const [name, value] of Object.entries(upstreamResponse.headers)) { + if (value === undefined || name === "connection" || name === "keep-alive" || name === "transfer-encoding" || name === "set-cookie") continue; + responseHeaders[name] = value; + } + response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders); + upstreamResponse.pipe(response); + }); + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + response.end("inspector unavailable\n"); + }); + if (body) upstream.end(body); + else if (request.method === "GET" || request.method === "HEAD") upstream.end(); + else request.pipe(upstream); +} + +async function readJsonBody(request: IncomingMessage): Promise { + const contentType = (request.headers["content-type"] ?? "").toLowerCase().split(";", 1)[0]?.trim(); + if (contentType !== "application/json") throw new Error("Expected JSON body"); + const parts: Buffer[] = []; + let bytes = 0; + for await (const part of request) { + const buffer = Buffer.isBuffer(part) ? part : Buffer.from(part); + bytes += buffer.length; + if (bytes > MAX_WRITE_BODY) throw new Error("JSON body too large"); + parts.push(buffer); + } + if (bytes === 0) throw new Error("JSON body is empty"); + return Buffer.concat(parts); +} + +function safeEqual(left: string, right: string): boolean { + const digest = (value: string) => createHash("sha256").update(value, "utf8").digest(); + return timingSafeEqual(digest(left), digest(right)); +} + +async function seed(target: JazzThoughtStore): Promise { + const base = Date.parse("2026-09-18T09:00:00.000Z"); + const at = (minutes: number) => new Date(base + minutes * 60_000).toISOString(); + const rssItems = [ + { id: "fixture-1", title: "Fixture: notes on deterministic runners", summary: "A synthetic article about keeping proposal runners deterministic so tests stay reproducible." }, + { id: "fixture-2", title: "Fixture: bounded context snapshots", summary: "A synthetic article on recording exact event ids and payload hashes instead of mutable titles." }, + { id: "fixture-3", title: "Fixture: append-only decisions", summary: "A synthetic article arguing that human decisions should be appended, never edited in place." }, + ]; + for (const [index, item] of rssItems.entries()) { + await target.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:fixture-demo", + sourceKind: "rss", + externalId: item.id, + idempotencyKey: `rss:${item.id}`, + occurredAt: at(index * 7), + actor: "rss:fixture-demo", + correlationId: "demo-seed", + privacy: "public-source", + payload: { title: item.title, summary: item.summary, url: `https://example.invalid/${item.id}` }, + }); + } + const telegramMessages = [ + "Synthetic note to self: try turning the runner article into a working document.", + "Synthetic reminder: the demo store is temporary and contains no real data.", + ]; + for (const [index, text] of telegramMessages.entries()) { + await target.appendEvent({ + type: "stream.thought.source.telegram.message", + schemaVersion: 1, + source: "telegram:fixture-demo", + sourceKind: "telegram", + externalId: `message-${index + 1}`, + idempotencyKey: `telegram:demo:${index + 1}`, + occurredAt: at(30 + index * 5), + actor: "telegram:fixture-demo", + correlationId: "demo-seed", + privacy: "sensitive", + payload: { chatId: "demo-chat", messageId: String(index + 1), senderName: "Fixture operator", text }, + }); + } +} diff --git a/spec/README.md b/spec/README.md index 03ad174..146d3e0 100644 --- a/spec/README.md +++ b/spec/README.md @@ -24,6 +24,7 @@ The core local milestone is implemented and exercised in `test/agent-runtime.tes - [`review.md`](review.md): complete review prompts, blinded candidate pairs, judgeability, append-only human decisions, OAuth-only browser writes, and training-data custody. - [`incidents.md`](incidents.md): content-dark operational incident projection, private ledger, and independent Telegram alert policy. - [`artifacts.md`](artifacts.md): immutable self-rooted content-addressed thought artifacts, artifact-catalog projection, and private inspector rendering. +- [`documents.md`](documents.md): operator-owned working documents: identity, source links, version identity, head projection, proposal state with stale-base failure, access, judgment lineage, and the deterministic runner seam. - [`courses.md`](courses.md): private interactive lessons and the OAuth-only, receipt-backed course-question path. - [`tinker.md`](tinker.md): Tinker model and adapter boundary. - [`security.md`](security.md): privacy, credentials, authority, and prompt-injection boundaries. diff --git a/spec/documents.md b/spec/documents.md new file mode 100644 index 0000000..19beefb --- /dev/null +++ b/spec/documents.md @@ -0,0 +1,112 @@ +# Working documents + +## Purpose + +A working document is an operator-owned, editable Markdown document created from one Stream observation. It proves one bounded workflow: + +`open an event → create a working document linked to it → select exact supporting context → request a proposal from a runner → inspect the diff → accept or reject → retain the version and the judgment` + +It is not a general editor, a task system, a collaboration surface, or a new agent runtime. Those remain out of scope and are listed under [Not implemented](#not-implemented). + +## Kind + +Observed filesystem versions (`filesystem-charter.md`) record what a connector saw on disk. A working document is a separate kind: it is written only through the trusted workbench path and never touches a file on disk. Editing a working document created from a file observation does not modify the observed file. + +Working documents reuse the existing `documentVersions` (immutable content) and `documents` (mutable current head) tables rather than adding tables: + +| Field | Value | +| --- | --- | +| `source` | `workbench:documents` | +| `contentType` | `text/markdown` | +| `path` | `documents/.md` (a label, not a filesystem path) | +| version `content` | `---\ntitle: \n---\n` | + +The title travels in frontmatter so every version is self-describing without the head projection. Body is at most 64,000 characters; title at most 200. + +## Identity + +- `documentId = stableKey("working-document", originEventId, createRequestId)`. The origin event is the observation the operator started from. The create request id is a client-generated idempotency key, so a retried create converges on the same document. +- `versionId = stableKey("version", "workbench:documents", documentId, sha256(content))`, exactly as filesystem versions derive theirs, so `appendDocumentVersion` idempotency holds and identical content is one version. +- The `documents` row id is `stableKey("document", "workbench:documents", documentId)`. Its `versionId` is the current head. The row is a projection; the version events are the historical authority. +- Context snapshots are immutable JSON document versions under `source: "context:workbench"` with `snapshotVersionId = stableKey("workbench-context-snapshot", documentId, sha256(canonical { baseVersionId, ids: sorted selected ids }))`. +- Runner runs are ordinary `AgentRun` rows with `id = stableKey("workbench-run", documentId, requestId)`. + +## Events + +All workbench events are append-only under `source: "workbench:documents"`, `sourceKind: "system"`, `privacy: "sensitive"`, `schemaVersion: 1`, actor = the configured proposal actor (`operator:local` by default), with zod-validated payloads in `src/workbench/contracts.ts` and registration in the default event registry. + +| Type | Lineage | Payload | +| --- | --- | --- | +| `stream.thought.workbench.document.created` | root = origin root, parent = origin event | `documentId`, `title`, `originEventId`, `versionId`, `sha256`, `requestId` | +| `stream.thought.workbench.document.version` | parent = created event (operator edit) or proposal event (accepted proposal) | `documentId`, `versionId`, `baseVersionId \| null`, `sha256`, `sizeBytes`, `reason: created \| operator-edit \| proposal-accepted`, `proposalEventId?`, `decisionEventId?`, `requestId` | +| `stream.thought.workbench.context.selected` | parent = created event | `documentId`, `selectionId`, `snapshotVersionId`, `selectedEventIds`, `selectedVersionIds`, `requestId` | +| `stream.thought.workbench.proposal.requested` | parent = selected event | `documentId`, `baseVersionId`, `baseSha256`, `selectionId`, `snapshotVersionId`, `runnerId`, `requestId` | +| `stream.thought.workbench.proposal.proposed` | parent = requested event | `proposalState: "runner-proposed"`, `proposer { runnerId, runnerRevision, requestId, runId?, contextSnapshotId }`, `target { documentId, baseVersionId, baseSha256 }`, `operation: "replace-document"`, `proposedText`, `proposedTextChars`, `proposedTextSha256`, `reason`, `evidenceEventIds` (at most 16, all admitted by the selection), `publicationEligible: false` | +| `stream.thought.workbench.proposal.decision` | parent = proposal event | `proposalEventId`, `disposition: accept \| reject`, `submissionId`, `authority: "human"`, `resultVersionId?` (accept only) | + +Every event is findable from the origin event's lineage because all of them carry the origin's `rootEventId`. + +Idempotency keys are deterministic per request: created `(documentId)`, version `(documentId, reason, requestId)`, selected `(selectionId)`, requested and proposed `(documentId, requestId)`, decision `(proposalEventId)`. One decision identity per proposal is the concurrency boundary, exactly like agent-proposal decisions: the same submission id with identical content replays, a different submission on a decided proposal fails with "already has a human decision", and a reused submission id with different content fails with "submission id conflicts". + +## Source links and context snapshots + +Selecting context records exact evidence, never mutable titles. The snapshot content is canonical JSON: + +```json +{ + "documentId": "...", + "baseVersionId": "...", + "selectedEvents": [{ "eventId": "...", "type": "...", "source": "...", "occurredAt": "...", "payloadHash": "...", "excerpt": "..." }], + "selectedVersions": [{ "documentId": "...", "versionId": "...", "sha256": "...", "path": "..." }] +} +``` + +`excerpt` is a bounded plain-text rendering of the payload (at most 2,000 characters), never HTML. Every selected id must exist at selection time. The UI labels this as the evidence the operator selected and states that a runner receives this snapshot plus the current document head and nothing else in this slice; a future model-backed runner that supplies broader context must record that separately rather than widening the selection silently. + +## Trusted mutation path + +`src/workbench/workflow.ts` is the only writer. It runs behind the inspector's Review capability and serializes mutations per document in process. + +- `createWorkingDocument`: origin event must exist; idempotent on request id; a request id reused with different content is a conflict. +- `saveOperatorEdit`: fails closed with `StaleBaseError` (HTTP 409 with `headVersionId`) when the submitted `baseVersionId` is not the head. Identical content to the head is a no-op. A replayed request id settles the head if the earlier attempt appended the version but not the projection, then returns the current head. +- `selectContext`: validates every id, writes the snapshot version, then the selected event. +- `requestProposal`: resolves the runner from the registry, appends the requested event, records a `running` run row, runs the runner, appends the proposed event, then completes the run with `outputEventIds = [proposalEventId]`. Runner failure records a `failed` run with a bounded classified `errorText` and appends no proposal; the error is returned to the action that requested it. A replayed request id returns the existing proposal or the existing failure. +- `decideProposal`: write order is version row, version event, decision event, head upsert, judgment. Accept requires `head.versionId === target.baseVersionId`; otherwise `StaleBaseError` and nothing is appended. Reject appends the decision and judgment and leaves the document unchanged; rejecting a stale proposal is allowed. Replaying the same submission settles any unfinished head upsert or judgment and applies nothing twice. + +## Proposal state + +A proposal is `pending` until decided. The projection computes `stale = head.versionId !== target.baseVersionId` and exposes `headVersionId`; a stale undecided proposal is shown as `stale` with Accept disabled and a hint to request a new proposal. Decided proposals are `accepted` or `rejected`. There is no merge, rebase, or overwrite path: a stale proposal either gets rejected or is superseded by a new request. + +The diff is computed server side with the `diff` package between the exact base version body and `proposedText` (the identical frontmatter is excluded so the diff shows only body changes) and is rendered in the UI as escaped text lines, never as HTML. + +## Judgment lineage + +Each decision records one judgment through the existing `recordJudgment` with criterion `workbench-document-proposal@1`, kind `accept` or `reject`, `qualityEligible: false`, `externalExportEligible: false`, `feedbackSourceEventId` = the decision event, `source: judgment:workbench-documents`. The judged run is the runner run whose trigger is the requested event and whose only output is the proposal event, with `contextManifest.contextSnapshot` naming the selection snapshot. The judgment therefore links proposal, base version, result version (through the decision payload), context snapshot, and run. A completed run is not training consent: no workbench judgment is quality- or export-eligible, and nothing here exports documents or judgments. + +## Runner seam + +`src/workbench/runners.ts` defines `WorkbenchProposalRunner { id, revision, label, inference, propose(request) }` with `request = { documentId, baseVersionId, baseSha256, title, baseText, snapshot, maxProposedChars }` and `result = { proposedText, reason, evidenceEventIds }`. The trusted workflow validates the result (length bound, hash, evidence ids admitted by the selection) before it becomes an event. + +The registered runner is `fixture-deterministic@1`: it appends or replaces a `## Sources` section listing each selected event and version and a one-line `Summary:` count, reports `reason: "fixture: append sources section"`, cites the selected event ids, and performs no inference. Identical input yields identical output. + +Coordination with the separate fx consumer work: an fx-backed runner implements the same interface, maps the harness result frame's `finalText` to `proposedText` and its run id to `proposer.runId`, and reports its own reason. This repository does not implement that adapter and `src/workbench/` imports nothing from `src/agents/harness/`. + +## Access + +Reads: `GET /api/workbench/documents`, `GET /api/workbench/documents/:id`, `GET /api/workbench/candidates?documentId=`. Candidates are bounded: the recent root-observation window plus the document's own versions. + +Writes: `POST /api/workbench/documents`, `POST /api/workbench/documents/:id/versions`, `POST /api/workbench/documents/:id/selections`, `POST /api/workbench/documents/:id/proposals`, `POST /api/workbench/proposals/:id/decisions`. Each requires the same body-bound Review capability signature as the agent-proposal decision route and returns 405 when no verifier is configured. Bodies are strict zod schemas of at most 98 KB with a client-generated `requestId`/`submissionId`. The authenticated proxy signs these routes only for an allowlisted OAuth session with the session CSRF header; Basic remains read-only. See `web-auth.md`. + +Jazz credentials stay server side. Browser clients never write Jazz directly. + +## UI + +The `Documents` destination lists working documents and opens a detail view with the editor, sources, proposals, and versions. From an observation, `Create working document` appears only when the session has write access. Drafts persist in browser local storage per document until saved. Every write carries a client-generated id held in page state; after a network failure the UI reports the outcome as unknown, reloads the document before any retry, and reuses the same id rather than resending under a new one. See `ui.md`. + +## Not implemented + +- Real-time or multi-user collaboration, comments, or a permission contract beyond the single owner session. The append-only version chain and per-document serialization leave room for a future collaboration contract without claiming one. +- Tasks, assignments, or status fields. +- Model-backed runners. Only the deterministic fixture is registered; the fx adapter is coordinated, not implemented. +- Document deletion, rename of `documentId`, or import of an observed file version as the initial body beyond the operator typing it. +- Training export of any workbench judgment or document. diff --git a/spec/events.md b/spec/events.md index 0ad8ec0..c998993 100644 --- a/spec/events.md +++ b/spec/events.md @@ -134,6 +134,17 @@ A repair request is deterministic append-only evidence over one eligible origina Proposal events are sensitive, append-only agent requests bound by the trusted parent to one retry-stable context snapshot. They grant no canonicalization, judgment, file, publication, channel, or export authority. A human decision may accept, edit, or reject memory/correction proposals. Public Knowledge diff proposals currently have no decision or materialization path; any future path must consume a separate human decision and may never write or publish directly from the agent proposal. See `proposals.md` and `public-knowledge.md`. +### Working documents + +- `stream.thought.workbench.document.created@1` +- `stream.thought.workbench.document.version@1` +- `stream.thought.workbench.context.selected@1` +- `stream.thought.workbench.proposal.requested@1` +- `stream.thought.workbench.proposal.proposed@1` +- `stream.thought.workbench.proposal.decision@1` + +Workbench events are sensitive, append-only system events under `source: workbench:documents`, rooted at the origin observation's root so a working document is findable from that event's lineage. A version event references one immutable `documentVersions` row; the `documents` row is only the current-head projection. A proposal is inert runner output bound to one exact base version and one exact context snapshot; it grants no publication, file, or training authority. One deterministic decision identity per proposal makes same-submission retries converge and different submissions conflict. Accept appends a new version only when the base is still the head; a stale base fails closed. Decisions project through `recordJudgment` with quality and export eligibility fixed false. See `documents.md`. + ### Focus proposals - `stream.thought.focus.declaration.proposed` diff --git a/spec/testing.md b/spec/testing.md index 4b80cc1..15bf190 100644 --- a/spec/testing.md +++ b/spec/testing.md @@ -82,6 +82,9 @@ These are capability gates, not aspirational checks. An API named `transaction`, - Review tests freeze exact same-trigger candidate runs; `underdetermined`, `tie`, `malformed`, and `skip` decisions never become preference examples; correction requires a contract-valid replacement; supersession leaves one active export label; and public prompt authority gates v4 input text. Private browser decisions cannot declassify data. - Review web tests prove Basic remains read-only; OAuth POST requires CSRF and a fresh one-time proxy signature; replay, stale signatures, unknown routes, oversized bodies, malformed decisions, and direct loopback POSTs fail without appending events. - Course web tests prove the repository course has eight ordered lessons and one typed workshop each; question ids are idempotent, stale revisions and divergent reuse fail, Basic remains read-only, OAuth forwarding requires CSRF and a separate fresh body-bound capability, signature replay fails, and the status API reveals an answer only after exact completed tutor lineage. +- Workbench workflow tests (`test/workbench-workflow.test.ts`) run against temporary Jazz stores and prove: a working document created from an event keeps its origin reference across store reopen; operator edits require the exact head, are idempotent per request id, and fail closed with a stale-base error; context selection records exact event ids, payload hashes, version ids, and hashes in one immutable bounded plain-text snapshot; the fixture runner is deterministic and its diff is computed against the exact base; accepting once yields exactly one new version, one decision, and one non-exportable judgment while a duplicate submission creates nothing extra and a conflicting submission fails; reject leaves the document unchanged; an operator edit followed by accepting the older proposal fails with the stale error and the edit survives; a runner failure records a failed run and no proposal; and a second store client over the same project root sees the accepted head. +- Workbench inspector tests (`test/workbench-inspector.test.ts`) prove every workbench write returns 405 without a Review verifier, unsigned and wrong-path signatures fail with 403 and no store effect, malformed bodies fail with 400, signed writes apply through the trusted workflow, the GET projections expose head, origin, exact selections, proposals with diff and stale flags, and versions, stale edits and stale accepts return 409 with the current head, decisions replay by submission id, and a consumed signature nonce cannot be replayed. +- Proxy tests (`test/authenticated-proxy.test.ts`) prove every workbench write route rejects unauthenticated, Basic-only, missing-CSRF, wrong-CSRF, query-bearing, oversized, and non-JSON requests before any upstream contact, forwards one CSRF-checked OAuth write per route with a fresh body-bound Review signature and no browser credentials, and still serves workbench reads to Basic. - Inspector HTML tests compile the rendered inline JavaScript, contain the Learn destination and floating composer, and preserve fragment routes for exact lesson ids. Browser acceptance exercises every workshop, narrow and wide layouts, keyboard submission, disabled composer state, and one natural OAuth question through event, tutor run, output, and status readback. - Replay uses the exact public run binding but recompiles the private checkpoint through the trusted startup loader; it does not float to another release or trust serialized checkpoint authority. - Live Tinker sampling is an opt-in credentialed test and never runs in ordinary CI. diff --git a/spec/ui.md b/spec/ui.md index ea36ced..89ed0dd 100644 --- a/spec/ui.md +++ b/spec/ui.md @@ -68,6 +68,10 @@ The inspector prewarms and retains both bounded Feed projections in process. An The bounded Feed projection scans one deterministic window of at most 400 recent source records to select up to 100 root observations, so ordinary child records such as reactions and corrections do not displace unrelated roots. If that hard scan cap cannot recover a complete root window, the response and UI say so instead of falling back to a global scan. It then hydrates at most 500 events in those root lineages, 250 recent runs, and 250 runs directly triggered by the hydrated events. Same-timestamp query limits use the record key as a deterministic second ordering term. When either the recent-run or root-lineage hydration cap is reached, each observation marks processing history incomplete; the UI may show found processors but cannot classify an empty bounded result as “not processed.” The Feed path never enumerates complete run history; complete run evidence remains a System concern. +## Documents + +The **Documents** destination lists operator-owned working documents and opens one in the same column: a title field, a body editor, a **Sources** section showing the exact selected evidence with event ids, payload hashes, version ids, and content hashes, a **Proposals** section, and a **Versions** list. From an observation detail, a compact **Create working document** control appears only when the session has write access; otherwise a one-line note says why it is absent. **Save** submits the head version the editor loaded and, on a stale-base conflict, keeps the draft in the browser, explains that the document changed, and offers a reload. **Select context** opens a bounded checkbox list of recent observations plus the document's own versions and records only the checked items. **Request proposal** names the runner and whether it performs inference. Each proposal shows its status, reason, base version, and a unified diff rendered as escaped text lines with `+`/`-` markers, never as HTML; a proposal whose base is no longer the head shows a stale notice with Accept disabled. Errors render at the action that failed. Drafts persist in browser local storage until saved. Every write carries a client-generated request or submission id held in page state; after a network failure the outcome is reported as unknown, the document is reloaded before any retry, and the same id is reused rather than resending under a new one. See [`documents.md`](documents.md). + ## Detail view - Consumer lifecycle and derived-output details lead with a plain-language **what processed this** block: the declaration display name, actual trigger source, whether it was a rule or model, whether LLM inference occurred, the exact produced summary, any recommendation or proposal that actually exists, derived-record count, and whether external actions were enabled. Technical agent ids and context strategies belong in execution details. Runtime metadata must not be appended to model-authored prose. A reader must not have to traverse raw lifecycle payloads to discover either the semantic result or its provenance. @@ -88,7 +92,7 @@ The operator CLI may resolve one exact run's durable `contextSnapshot` identity - Localhost by default. - No client-side secrets. -- No send, publish, arbitrary edit, adapter activation, or generic Jazz controls. The separately specified Review decision form is the only data mutation. +- No send, publish, arbitrary edit, adapter activation, or generic Jazz controls. The Review decision form, agent-proposal decisions, and the bounded working-document writes in `documents.md` are the only data mutations, and all of them are append-only through the trusted server path. - Streaming UI may use server-sent events; persistence never depends on the browser being open. ## Authenticated external access diff --git a/spec/web-auth.md b/spec/web-auth.md index d5865e0..044ba10 100644 --- a/spec/web-auth.md +++ b/spec/web-auth.md @@ -46,6 +46,12 @@ Public assets are the four reviewed Markdown pages, one exact validated Around W | `/inspector/`, `/inspector/*` except the decision route | GET, HEAD | allowlisted OAuth DID or enabled Basic fallback | loopback inspector | | `/inspector/api/reviews/:item/decisions` | POST | allowlisted OAuth browser session, session CSRF, and configured proxy-to-inspector Review capability | one fixed append-only Review decision | | `/inspector/api/courses/post-training/questions` | POST | allowlisted OAuth browser session, session CSRF, and configured proxy-to-inspector course-chat capability | one fixed private course question | +| `/inspector/api/workbench/documents` | GET, HEAD, POST | reads: allowlisted OAuth DID or enabled Basic fallback; POST: allowlisted OAuth browser session, session CSRF, and the Review capability | list working documents; create one working document from one origin event | +| `/inspector/api/workbench/documents/:id`, `/inspector/api/workbench/candidates` | GET, HEAD | allowlisted OAuth DID or enabled Basic fallback | one working-document projection; bounded selectable context | +| `/inspector/api/workbench/documents/:id/versions` | POST | allowlisted OAuth browser session, session CSRF, and the Review capability | one operator edit against one exact base version (409 when stale) | +| `/inspector/api/workbench/documents/:id/selections` | POST | allowlisted OAuth browser session, session CSRF, and the Review capability | one exact context selection and snapshot | +| `/inspector/api/workbench/documents/:id/proposals` | POST | allowlisted OAuth browser session, session CSRF, and the Review capability | one runner proposal request over the current head | +| `/inspector/api/workbench/proposals/:id/decisions` | POST | allowlisted OAuth browser session, session CSRF, and the Review capability | one append-only accept/reject decision with judgment (accept fails closed on a stale base) | | every other route | none | none | none | ## Threats and controls @@ -84,6 +90,8 @@ Logout is a POST with a random token stored only in the server-side browser sess The same session token protects the exact Review-decision JSON route. The token is returned only from an OAuth-authenticated private session endpoint and is sent in a dedicated request header. Basic authorization never receives it and remains read-only. A successful CSRF check does not reach Jazz directly: the proxy signs the exact method, normalized route, body digest, timestamp, and one-time nonce under a separately injected capability. The inspector verifies that envelope before accepting the fixed Review decision schema. Browser authentication, CSRF, loopback capability, and event validation are distinct gates. +The workbench document routes reuse the Review capability rather than adding a key: they are the same class of bounded, operator-authored append-only writes, and the inspector's workbench handler independently validates every body against strict schemas and the trusted workflow's stale-base and decision-identity checks. Query strings, non-JSON bodies, bodies over 98 KB, Basic credentials, and missing or mismatched CSRF tokens are rejected in the proxy before any upstream contact. Reads of the workbench projections remain ordinary authenticated inspector reads. Edge activation is a separate deployment step: the checked nginx template forwards POST only for the exact Review-decision and course-question locations, so the workbench POST locations must be added there (with the same rate limit and no body/query logging) before browser writes work through the public origin. The loopback proxy and inspector already enforce the full gate without that change. + The course-question route uses the same browser-session CSRF token and a separate course-chat loopback capability. The separate key prevents Review authority from silently expanding into model-triggering authority. The proxy signs the exact bounded question request, and the inspector derives lesson context from repository source before event insertion. Basic remains read-only on both routes. ### SSRF and hostile OAuth metadata diff --git a/src/events/registry.ts b/src/events/registry.ts index 7c171af..1b512e5 100644 --- a/src/events/registry.ts +++ b/src/events/registry.ts @@ -69,6 +69,21 @@ import { COURSE_QUESTION_SCHEMA_VERSION, courseQuestionPayloadSchema, } from "../courses/questions.js"; +import { + CONTEXT_SELECTED_EVENT_TYPE as WORKBENCH_CONTEXT_SELECTED_EVENT_TYPE, + DOCUMENT_CREATED_EVENT_TYPE as WORKBENCH_DOCUMENT_CREATED_EVENT_TYPE, + DOCUMENT_VERSION_EVENT_TYPE as WORKBENCH_DOCUMENT_VERSION_EVENT_TYPE, + PROPOSAL_DECISION_EVENT_TYPE as WORKBENCH_PROPOSAL_DECISION_EVENT_TYPE, + PROPOSAL_PROPOSED_EVENT_TYPE as WORKBENCH_PROPOSAL_PROPOSED_EVENT_TYPE, + PROPOSAL_REQUESTED_EVENT_TYPE as WORKBENCH_PROPOSAL_REQUESTED_EVENT_TYPE, + WORKBENCH_SCHEMA_VERSION, + contextSelectedPayloadSchema as workbenchContextSelectedPayloadSchema, + documentCreatedPayloadSchema as workbenchDocumentCreatedPayloadSchema, + documentVersionPayloadSchema as workbenchDocumentVersionPayloadSchema, + proposalDecisionPayloadSchema as workbenchProposalDecisionPayloadSchema, + proposalProposedPayloadSchema as workbenchProposalProposedPayloadSchema, + proposalRequestedPayloadSchema as workbenchProposalRequestedPayloadSchema, +} from "../workbench/contracts.js"; import type { ThoughtEvent } from "./types.js"; import { X_ACTIVITY_SOURCE_EVENT_TYPE, @@ -697,6 +712,22 @@ export function createDefaultRegistry(): EventRegistry { description: "Signature-verified allowlisted X Activity webhook observation", payload: xActivityPayloadSchema, }); + for (const [type, payload, description] of [ + [WORKBENCH_DOCUMENT_CREATED_EVENT_TYPE, workbenchDocumentCreatedPayloadSchema, "Operator-owned working document created from one origin event"], + [WORKBENCH_DOCUMENT_VERSION_EVENT_TYPE, workbenchDocumentVersionPayloadSchema, "Immutable working-document version appended through the trusted workbench path"], + [WORKBENCH_CONTEXT_SELECTED_EVENT_TYPE, workbenchContextSelectedPayloadSchema, "Exact operator-selected evidence snapshot for one working document"], + [WORKBENCH_PROPOSAL_REQUESTED_EVENT_TYPE, workbenchProposalRequestedPayloadSchema, "Operator request for one runner proposal over one exact base version"], + [WORKBENCH_PROPOSAL_PROPOSED_EVENT_TYPE, workbenchProposalProposedPayloadSchema, "Inert runner-proposed replacement for one exact working-document base version"], + [WORKBENCH_PROPOSAL_DECISION_EVENT_TYPE, workbenchProposalDecisionPayloadSchema, "Append-only human accept/reject decision over one workbench proposal"], + ] as const) { + registry.register({ + type, + schemaVersion: WORKBENCH_SCHEMA_VERSION, + description, + payload: payload as unknown as z.ZodType, + minimumPrivacy: "sensitive", + }); + } registry.register({ type: ARTIFACT_REQUEST_EVENT_TYPE, schemaVersion: ARTIFACT_REQUEST_SCHEMA_VERSION, diff --git a/src/web/authenticated-proxy.ts b/src/web/authenticated-proxy.ts index 4b10260..d0580ae 100644 --- a/src/web/authenticated-proxy.ts +++ b/src/web/authenticated-proxy.ts @@ -317,7 +317,16 @@ async function handleRequest(options: { return notFound(options.response); } - const reviewWriteRoute = url.search === "" && /^\/inspector\/api\/(?:reviews|proposals)\/[^/]+\/decisions$/.test(url.pathname); + // Review-capability write routes: Review decisions, agent-proposal decisions, + // and the bounded workbench document routes. Each is one exact POST path with + // no query; the same OAuth session, CSRF header, and body-bound signature apply. + const workbenchWriteRoute = url.search === "" && options.request.method === "POST" && ( + url.pathname === "/inspector/api/workbench/documents" + || /^\/inspector\/api\/workbench\/documents\/[^/]+\/(?:versions|selections|proposals)$/.test(url.pathname) + || /^\/inspector\/api\/workbench\/proposals\/[^/]+\/decisions$/.test(url.pathname) + ); + const reviewWriteRoute = (url.search === "" && /^\/inspector\/api\/(?:reviews|proposals)\/[^/]+\/decisions$/.test(url.pathname)) + || workbenchWriteRoute; const courseChatWriteRoute = url.search === "" && url.pathname === "/inspector/api/courses/post-training/questions"; const basicAuthorized = options.expectedAuthorizationDigest !== undefined && isAuthorized(options.request.headers.authorization, options.expectedAuthorizationDigest); diff --git a/src/web/inspector.ts b/src/web/inspector.ts index 0258d6f..25d6d74 100644 --- a/src/web/inspector.ts +++ b/src/web/inspector.ts @@ -49,6 +49,21 @@ import { materializeMemoryDecision } from "../agent-proposals/memory-materialize import { recordProposalDecision } from "../agent-proposals/review.js"; import { z } from "zod"; import { FONT_DEBUG_ASSET_PATH, FONT_DEBUG_SCRIPT } from "./font-debug.js"; +import { + createDocumentRequestSchema, + decideProposalRequestSchema, + requestProposalRequestSchema, + saveEditRequestSchema, + selectContextRequestSchema, +} from "../workbench/contracts.js"; +import type { WorkbenchRunnerRegistry } from "../workbench/runners.js"; +import { + StaleBaseError, + WorkbenchConflictError, + WorkbenchNotFoundError, + WorkbenchRunnerError, + WorkbenchWorkflow, +} from "../workbench/workflow.js"; const RUN_TERMINAL_EVENT_TYPES = [ "stream.thought.agent.run.completed", @@ -132,6 +147,7 @@ export interface InspectorServerOptions { courseChatVerifier?: CourseChatCapabilityVerifier | undefined; agentContextRoot?: string | undefined; proposalActor?: string | undefined; + workbenchRunners?: WorkbenchRunnerRegistry | undefined; } interface InspectorActivityCache { @@ -188,8 +204,12 @@ export async function startInspectorServer( ?? (options.courseChatCapability ? new CourseChatCapabilityVerifier(options.courseChatCapability) : undefined); const proposalWritesEnabled = Boolean(reviewVerifier && options.agentContextRoot); const activityCache = createInspectorActivityCache(store); + const workbench = new WorkbenchWorkflow(store, { + actor: options.proposalActor ?? "operator:cameron", + ...(options.workbenchRunners ? { runners: options.workbenchRunners } : {}), + }); const server = http.createServer((request, response) => { - void handleRequest(store, activityCache, request, response, reviewVerifier, courseChatVerifier, proposalWritesEnabled ? options.agentContextRoot : undefined, options.proposalActor ?? "operator:cameron").catch((error) => { + void handleRequest(store, activityCache, request, response, reviewVerifier, courseChatVerifier, proposalWritesEnabled ? options.agentContextRoot : undefined, options.proposalActor ?? "operator:cameron", workbench).catch((error) => { sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }); }); }); @@ -212,8 +232,13 @@ async function handleRequest( courseChatVerifier?: CourseChatCapabilityVerifier, agentContextRoot?: string, proposalActor = "operator:cameron", + workbench: WorkbenchWorkflow = new WorkbenchWorkflow(store, { actor: proposalActor }), ): Promise { const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "POST" && url.pathname.startsWith("/api/workbench/")) { + await handleWorkbenchWrite(workbench, request, response, url, reviewVerifier); + return; + } if (request.method === "POST" && url.pathname === "/api/courses/post-training/questions" && url.search === "") { if (!courseChatVerifier) { request.resume(); @@ -440,6 +465,28 @@ async function handleRequest( sendJson(response, 200, await getArtifactCatalog(store)); return; } + if (url.pathname === "/api/workbench/documents") { + sendJson(response, 200, { items: await workbench.listDocuments(), runners: workbench.listRunners() }); + return; + } + if (url.pathname === "/api/workbench/candidates") { + const documentId = url.searchParams.get("documentId"); + if (!documentId || [...url.searchParams.keys()].some((key) => key !== "documentId")) { + return sendJson(response, 400, { error: "Candidate lookup requires exactly one documentId" }); + } + const candidates = await workbench.listCandidates(documentId); + if (!candidates) return sendJson(response, 404, { error: "Working document not found" }); + sendJson(response, 200, candidates); + return; + } + const workbenchDocumentMatch = url.pathname.match(/^\/api\/workbench\/documents\/([^/]+)$/); + if (workbenchDocumentMatch) { + const documentId = decodeURIComponent(workbenchDocumentMatch[1]!); + const detail = await workbench.getDocument(documentId); + if (!detail) return sendJson(response, 404, { error: "Working document not found" }); + sendJson(response, 200, detail); + return; + } const sourceMatch = url.pathname.match(/^\/api\/sources\/([^/]+)$/); if (sourceMatch) { const sourceId = decodeURIComponent(sourceMatch[1]!); @@ -518,6 +565,95 @@ async function handleRequest( sendJson(response, 404, { error: "Not found" }); } +const WORKBENCH_WRITE_BODY_LIMIT = 98_304; + +/** + * Trusted workbench mutations. Every route requires the same body-bound Review + * capability as the proposal decision route; without a verifier the inspector + * stays read-only (405). Request bodies are strict zod schemas and every + * mutation carries a client-generated request/submission id so retries after + * an uncertain outcome converge instead of duplicating effects. + */ +async function handleWorkbenchWrite( + workbench: WorkbenchWorkflow, + request: IncomingMessage, + response: ServerResponse, + url: URL, + reviewVerifier?: ReviewCapabilityVerifier, +): Promise { + const createMatch = url.pathname === "/api/workbench/documents"; + const versionMatch = url.pathname.match(/^\/api\/workbench\/documents\/([^/]+)\/versions$/); + const selectionMatch = url.pathname.match(/^\/api\/workbench\/documents\/([^/]+)\/selections$/); + const proposalMatch = url.pathname.match(/^\/api\/workbench\/documents\/([^/]+)\/proposals$/); + const decisionMatch = url.pathname.match(/^\/api\/workbench\/proposals\/([^/]+)\/decisions$/); + if (url.search !== "" || (!createMatch && !versionMatch && !selectionMatch && !proposalMatch && !decisionMatch)) { + request.resume(); + sendJson(response, 404, { error: "Not found" }); + return; + } + if (!reviewVerifier) { + request.resume(); + sendJson(response, 405, { error: "Workbench is read-only" }); + return; + } + let body: Buffer; + try { + body = await readBody(request, WORKBENCH_WRITE_BODY_LIMIT); + } catch { + request.resume(); + sendJson(response, 400, { error: "Workbench request is invalid" }); + return; + } + if (!reviewVerifier.verify(request.headers, "POST", url.pathname, body)) { + sendJson(response, 403, { error: "Workbench request could not be verified" }); + return; + } + try { + const parsed: unknown = JSON.parse(body.toString("utf8")); + if (createMatch) { + const input = createDocumentRequestSchema.parse(parsed); + const head = await workbench.createWorkingDocument(input); + sendJson(response, 201, { documentId: head.documentId, versionId: head.versionId, sha256: head.sha256 }); + return; + } + if (versionMatch) { + const input = saveEditRequestSchema.parse(parsed); + const head = await workbench.saveOperatorEdit({ documentId: decodeURIComponent(versionMatch[1]!), ...input }); + sendJson(response, 201, { documentId: head.documentId, versionId: head.versionId, sha256: head.sha256 }); + return; + } + if (selectionMatch) { + const input = selectContextRequestSchema.parse(parsed); + const selection = await workbench.selectContext({ documentId: decodeURIComponent(selectionMatch[1]!), ...input }); + sendJson(response, 201, { selectionId: selection.selectionId, snapshotVersionId: selection.snapshotVersionId, eventId: selection.eventId }); + return; + } + if (proposalMatch) { + const input = requestProposalRequestSchema.parse(parsed); + const result = await workbench.requestProposal({ documentId: decodeURIComponent(proposalMatch[1]!), ...input }); + sendJson(response, 201, result); + return; + } + const input = decideProposalRequestSchema.parse(parsed); + const result = await workbench.decideProposal({ proposalEventId: decodeURIComponent(decisionMatch![1]!), ...input }); + sendJson(response, 201, result); + } catch (error) { + if (error instanceof StaleBaseError) { + sendJson(response, 409, { error: "This document changed since you opened it", stale: true, headVersionId: error.headVersionId }); + } else if (error instanceof WorkbenchNotFoundError) { + sendJson(response, 404, { error: error.message }); + } else if (error instanceof WorkbenchConflictError) { + sendJson(response, 409, { error: error.message }); + } else if (error instanceof WorkbenchRunnerError) { + sendJson(response, 502, { error: "The proposal runner failed; no proposal was recorded", runId: error.runId }); + } else if (error instanceof z.ZodError) { + sendJson(response, 400, { error: "Workbench request is invalid" }); + } else { + throw error; + } + } +} + async function postTrainingQuestionStatus(store: JazzThoughtStore, eventId: string): Promise> { const event = await store.getEvent(eventId); if (!event || event.type !== COURSE_QUESTION_EVENT_TYPE || !parseCourseQuestionEvent(event)) { @@ -1179,6 +1315,22 @@ export function renderInspectorHtml(): string { .suggestion-content { border:1px solid var(--line); border-radius:var(--radius-content); background:var(--surface); padding:15px; margin:8px 0 14px; white-space:pre-wrap; overflow-wrap:anywhere; font:14px/1.6 var(--font-body) } .suggestion-compare { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:8px 0 14px } .suggestion-compare > div { min-width:0 } .suggestion-actions { display:grid; gap:10px; padding-top:14px; margin-top:16px } .suggestion-buttons { display:flex; flex-wrap:wrap; gap:8px } .suggestion-buttons button { border:1px solid var(--accent); background:var(--accent-soft); color:var(--text); padding:9px 12px; font:inherit; cursor:pointer } .suggestion-buttons button.reject { border-color:var(--red) } + .wb-card { border:1px solid var(--line); border-radius:var(--radius-content); background:var(--surface); padding:14px; margin:8px 0 14px; overflow-wrap:anywhere } + .wb-card + .wb-card { margin-top:10px } + .wb-head { display:flex; flex-wrap:wrap; align-items:center; gap:6px 8px; margin-bottom:6px } .wb-head strong { color:var(--accent-strong) } + .wb-form { display:grid; gap:10px; margin:8px 0 14px } + .wb-body { min-height:260px; font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace } + .wb-note { color:var(--muted); font-size:12px; margin-top:6px } .wb-warn { color:var(--amber); font-size:12px; margin-top:8px } .wb-error { color:var(--red); font-size:12px } + .wb-result { min-height:18px; font-size:12px; color:var(--muted); overflow-wrap:anywhere } + .wb-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-top:10px } .wb-actions button { min-height:40px; border:1px solid var(--accent); border-radius:var(--radius-control); background:var(--accent-soft); color:var(--text); padding:8px 13px; cursor:pointer } .wb-actions button.reject { border-color:var(--red); background:transparent } .wb-actions button.quiet { border-color:var(--line-strong); background:var(--surface-control) } .wb-actions button:disabled { opacity:.5; cursor:not-allowed } + .wb-actions select { width:auto; min-width:160px; min-height:40px; padding:7px 10px } + .wb-panel[hidden] { display:none } .wb-panel { margin-top:10px } + .wb-check { display:flex; align-items:flex-start; gap:9px; padding:8px 0; color:var(--text) } .wb-check input { width:auto; min-height:0; margin-top:4px; flex:none } .wb-check .meta { display:block } + .wb-source { border-left:2px solid var(--accent); padding:6px 0 6px 10px; margin:6px 0; font-size:13px } .wb-source .mono { display:block; margin-top:2px } + .wb-list { display:grid; gap:6px } .wb-version { border:1px solid var(--line); border-radius:12px; background:var(--surface); padding:10px 12px; font-size:13px } + .wb-version.head { border-color:var(--accent) } + .diff { max-height:420px; overflow:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace } .diff span { display:block; white-space:pre-wrap } .diff .add { color:var(--accent-strong); background:var(--accent-soft) } .diff .del { color:var(--red) } .diff .hunk,.diff .file { color:var(--muted) } + .wb-create { margin:16px 0 4px; border:1px solid var(--line); border-radius:var(--radius-content); background:var(--surface); padding:13px 14px } .wb-create .form-row { margin-bottom:8px } .spinner { display:inline-block; width:10px; height:10px; margin-right:7px; border:1px solid var(--line); border-top-color:var(--link); border-radius:50%; animation:spin .8s linear infinite; vertical-align:-1px } .retry { min-height:40px; margin-top:12px; border:1px solid var(--red); border-radius:var(--radius-control); background:transparent; color:var(--text); padding:8px 13px; cursor:pointer } .observation-head h2 { margin-bottom:4px } .observation-card { margin:15px 0; border:0; background:transparent; padding:0; font:15px/1.58 var(--font-body); overflow-wrap:anywhere } .observation-card .bluesky-inlay,.observation-card .x-inlay { border:0; border-radius:0; background:transparent; padding:0 } .observation-card p { margin:0; white-space:pre-wrap } .observation-links { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px } .observation-links a { border:1px solid var(--line); border-radius:var(--radius-control); color:var(--link); padding:7px 10px; text-decoration:none; font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace } .processing-state { display:flex; align-items:center; gap:8px; margin:15px 0 4px; color:var(--muted); font-size:12px } .status-dot { width:7px; height:7px; border-radius:50%; background:var(--line-strong); flex:none } .technical-record { margin-top:26px } .technical-record > summary { padding:3px 0 8px } .technical-section { margin-top:16px } .technical-section h3 { margin-top:0 } @@ -1283,14 +1435,14 @@ export function renderInspectorHtml(): string { -

Stream

+

Stream

Recent activity

Loading recent activity…

Filter feed

summary" }, + })).event; + const server = await startInspectorServer(store, { + port: 0, + proposalActor: "operator:test", + ...(options.reviewCapability ? { reviewCapability: options.reviewCapability } : {}), + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing inspector address"); + return { store, base: `http://127.0.0.1:${address.port}`, origin }; +} + +function signedHeaders(key: Buffer, pathName: string, body: Buffer): Record { + const signed = signReviewRequest(key, { method: "POST", path: pathName, body }); + return { + "content-type": "application/json", + [REVIEW_TIMESTAMP_HEADER]: signed.timestamp, + [REVIEW_NONCE_HEADER]: signed.nonce, + [REVIEW_SIGNATURE_HEADER]: signed.signature, + }; +} + +async function signedPost(base: string, key: Buffer, pathName: string, payload: unknown): Promise<{ status: number; body: Record }> { + const body = Buffer.from(JSON.stringify(payload), "utf8"); + const response = await fetch(`${base}${pathName}`, { method: "POST", headers: signedHeaders(key, pathName, body), body }); + return { status: response.status, body: (await response.json()) as Record }; +} + +describe("workbench inspector routes", () => { + test("returns 405 for every workbench write when no Review verifier is configured", async () => { + const { base, origin } = await fixture(); + for (const pathName of [ + "/api/workbench/documents", + "/api/workbench/documents/doc/versions", + "/api/workbench/documents/doc/selections", + "/api/workbench/documents/doc/proposals", + "/api/workbench/proposals/evt/decisions", + ]) { + const response = await fetch(`${base}${pathName}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ originEventId: origin.id, title: "x", body: "y", requestId: "req-00000001" }), + }); + expect(response.status, pathName).toBe(405); + } + const unknown = await fetch(`${base}/api/workbench/unknown`, { method: "POST", body: "{}" }); + expect(unknown.status).toBe(404); + const list = await fetch(`${base}/api/workbench/documents`); + expect(list.status).toBe(200); + expect(await list.json()).toMatchObject({ items: [], runners: [{ id: FIXTURE_RUNNER_ID, inference: "none" }] }); + const missing = await fetch(`${base}/api/workbench/documents/missing`); + expect(missing.status).toBe(404); + const candidates = await fetch(`${base}/api/workbench/candidates?documentId=missing`); + expect(candidates.status).toBe(404); + const badCandidates = await fetch(`${base}/api/workbench/candidates`); + expect(badCandidates.status).toBe(400); + }); + + test("rejects unsigned writes and applies signed writes through the trusted workflow", async () => { + const key = Buffer.alloc(32, 31); + const { store, base, origin } = await fixture({ reviewCapability: key }); + const createBody = Buffer.from(JSON.stringify({ originEventId: origin.id, title: "Notes", body: "# Notes\n", requestId: "req-create-0001" }), "utf8"); + const unsigned = await fetch(`${base}/api/workbench/documents`, { method: "POST", headers: { "content-type": "application/json" }, body: createBody }); + expect(unsigned.status).toBe(403); + expect(await store.listCurrentDocuments(WORKBENCH_DOCUMENT_SOURCE)).toEqual([]); + const wrongPath = await fetch(`${base}/api/workbench/documents`, { + method: "POST", + headers: signedHeaders(key, "/api/workbench/documents/other/versions", createBody), + body: createBody, + }); + expect(wrongPath.status).toBe(403); + const malformed = await signedPost(base, key, "/api/workbench/documents", { originEventId: origin.id, title: "", body: "x", requestId: "short" }); + expect(malformed.status).toBe(400); + const missingOrigin = await signedPost(base, key, "/api/workbench/documents", { originEventId: "evt_missing", title: "x", body: "y", requestId: "req-create-0009" }); + expect(missingOrigin.status).toBe(404); + + const created = await signedPost(base, key, "/api/workbench/documents", { originEventId: origin.id, title: "Notes", body: "# Notes\n", requestId: "req-create-0001" }); + expect(created.status).toBe(201); + const documentId = String(created.body.documentId); + const headVersionId = String(created.body.versionId); + const documentPath = `/api/workbench/documents/${encodeURIComponent(documentId)}`; + + const list = (await (await fetch(`${base}/api/workbench/documents`)).json()) as { items: Array> }; + expect(list.items).toHaveLength(1); + expect(list.items[0]).toMatchObject({ documentId, title: "Notes", originEventId: origin.id, headVersionId }); + + const candidates = (await (await fetch(`${base}/api/workbench/candidates?documentId=${encodeURIComponent(documentId)}`)).json()) as Record; + expect((candidates.events as Array>).some((event) => event.eventId === origin.id && event.origin === true)).toBe(true); + expect(candidates.versions).toEqual([expect.objectContaining({ versionId: headVersionId, head: true })]); + + const selection = await signedPost(base, key, `${documentPath}/selections`, { eventIds: [origin.id], versionIds: [headVersionId], requestId: "req-select-0001" }); + expect(selection.status).toBe(201); + const selectionId = String(selection.body.selectionId); + + const proposal = await signedPost(base, key, `${documentPath}/proposals`, { selectionId, runnerId: FIXTURE_RUNNER_ID, requestId: "req-prop-00001" }); + expect(proposal.status).toBe(201); + const proposalEventId = String(proposal.body.proposalEventId); + const unknownRunner = await signedPost(base, key, `${documentPath}/proposals`, { selectionId, runnerId: "missing", requestId: "req-prop-00002" }); + expect(unknownRunner.status).toBe(404); + + const detail = (await (await fetch(`${base}${documentPath}`)).json()) as Record; + expect(detail.head).toMatchObject({ documentId, title: "Notes", body: "# Notes\n", versionId: headVersionId }); + expect(detail.origin).toMatchObject({ eventId: origin.id }); + expect(detail.selections).toHaveLength(1); + expect(detail.selections[0].selectedEvents[0]).toMatchObject({ eventId: origin.id, payloadHash: origin.payloadHash }); + expect(detail.selections[0].selectedEvents[0].excerpt).toContain(""); + expect(detail.proposals).toHaveLength(1); + expect(detail.proposals[0]).toMatchObject({ eventId: proposalEventId, status: "pending", stale: false, inference: "none" }); + expect(detail.proposals[0].diff).toContain("+## Sources"); + + const edit = await signedPost(base, key, `${documentPath}/versions`, { baseVersionId: headVersionId, title: "Notes", body: "# Notes\n\nOperator edit.\n", requestId: "req-edit-00001" }); + expect(edit.status).toBe(201); + const editedVersionId = String(edit.body.versionId); + const staleEdit = await signedPost(base, key, `${documentPath}/versions`, { baseVersionId: headVersionId, title: "Notes", body: "# Other\n", requestId: "req-edit-00002" }); + expect(staleEdit.status).toBe(409); + expect(staleEdit.body).toMatchObject({ stale: true, headVersionId: editedVersionId }); + + const staleAccept = await signedPost(base, key, `/api/workbench/proposals/${encodeURIComponent(proposalEventId)}/decisions`, { disposition: "accept", submissionId: "sub-accept-0001" }); + expect(staleAccept.status).toBe(409); + expect(staleAccept.body).toMatchObject({ stale: true, headVersionId: editedVersionId }); + const afterStale = (await (await fetch(`${base}${documentPath}`)).json()) as Record; + expect(afterStale.head.versionId).toBe(editedVersionId); + expect(afterStale.head.body).toBe("# Notes\n\nOperator edit.\n"); + expect(afterStale.proposals[0]).toMatchObject({ status: "stale", stale: true }); + + const rejected = await signedPost(base, key, `/api/workbench/proposals/${encodeURIComponent(proposalEventId)}/decisions`, { disposition: "reject", submissionId: "sub-reject-0001" }); + expect(rejected.status).toBe(201); + expect(rejected.body).toMatchObject({ disposition: "reject", replayed: false }); + const replay = await signedPost(base, key, `/api/workbench/proposals/${encodeURIComponent(proposalEventId)}/decisions`, { disposition: "reject", submissionId: "sub-reject-0001" }); + expect(replay.status).toBe(201); + expect(replay.body).toMatchObject({ decisionEventId: rejected.body.decisionEventId, replayed: true }); + const conflict = await signedPost(base, key, `/api/workbench/proposals/${encodeURIComponent(proposalEventId)}/decisions`, { disposition: "accept", submissionId: "sub-accept-0002" }); + expect(conflict.status).toBe(409); + + const selection2 = await signedPost(base, key, `${documentPath}/selections`, { eventIds: [origin.id], versionIds: [], requestId: "req-select-0002" }); + const proposal2 = await signedPost(base, key, `${documentPath}/proposals`, { selectionId: String(selection2.body.selectionId), runnerId: FIXTURE_RUNNER_ID, requestId: "req-prop-00003" }); + expect(proposal2.status).toBe(201); + const accepted = await signedPost(base, key, `/api/workbench/proposals/${encodeURIComponent(String(proposal2.body.proposalEventId))}/decisions`, { disposition: "accept", submissionId: "sub-accept-0003" }); + expect(accepted.status).toBe(201); + expect(accepted.body).toMatchObject({ disposition: "accept", replayed: false }); + expect(accepted.body.judgmentEventId).toBeDefined(); + const final = (await (await fetch(`${base}${documentPath}`)).json()) as Record; + expect(final.head.versionId).toBe(accepted.body.resultVersionId); + expect(final.head.body).toContain("## Sources"); + expect(final.versions.map((version: { reason: string }) => version.reason)).toEqual(["created", "operator-edit", "proposal-accepted"]); + expect((await store.listEvents({ types: [PROPOSAL_PROPOSED_EVENT_TYPE] }))).toHaveLength(2); + + // Signature replay is refused: the nonce was consumed. + const body = Buffer.from(JSON.stringify({ disposition: "accept", submissionId: "sub-accept-0003" }), "utf8"); + const headers = signedHeaders(key, `/api/workbench/proposals/${encodeURIComponent(String(proposal2.body.proposalEventId))}/decisions`, body); + const first = await fetch(`${base}/api/workbench/proposals/${encodeURIComponent(String(proposal2.body.proposalEventId))}/decisions`, { method: "POST", headers, body }); + expect(first.status).toBe(201); + const second = await fetch(`${base}/api/workbench/proposals/${encodeURIComponent(String(proposal2.body.proposalEventId))}/decisions`, { method: "POST", headers, body }); + expect(second.status).toBe(403); + }); +}); diff --git a/test/workbench-workflow.test.ts b/test/workbench-workflow.test.ts new file mode 100644 index 0000000..0f7b4fb --- /dev/null +++ b/test/workbench-workflow.test.ts @@ -0,0 +1,358 @@ +import fs from "node:fs/promises"; +import { afterEach, describe, expect, test } from "vitest"; +import { sha256 } from "../src/core/json.js"; +import type { ThoughtEvent } from "../src/events/types.js"; +import { + CONTEXT_SELECTED_EVENT_TYPE, + DOCUMENT_CREATED_EVENT_TYPE, + DOCUMENT_VERSION_EVENT_TYPE, + PROPOSAL_DECISION_EVENT_TYPE, + PROPOSAL_PROPOSED_EVENT_TYPE, + PROPOSAL_REQUESTED_EVENT_TYPE, + WORKBENCH_CONTEXT_SOURCE, + WORKBENCH_DOCUMENT_SOURCE, + decodeDocumentContent, + encodeDocumentContent, + plainExcerpt, +} from "../src/workbench/contracts.js"; +import { FIXTURE_RUNNER_ID, fixtureDeterministicRunner } from "../src/workbench/runners.js"; +import { + StaleBaseError, + WorkbenchConflictError, + WorkbenchRunnerError, + WorkbenchWorkflow, + workingDocumentId, +} from "../src/workbench/workflow.js"; +import { JazzThoughtStore } from "../src/jazz/store.js"; +import { temporaryProject, testStore } from "./helpers.js"; + +const stores: JazzThoughtStore[] = []; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.close())); + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function openStore(appId?: string): Promise<{ root: string; store: JazzThoughtStore }> { + const projectRoot = await temporaryProject("thoughtstream-workbench-"); + roots.push(projectRoot); + const store = appId + ? await JazzThoughtStore.open({ projectRoot, appId, runtimeRevision: "test" }) + : await testStore(projectRoot); + stores.push(store); + return { root: projectRoot, store }; +} + +async function seedOrigin(store: JazzThoughtStore, suffix = "1"): Promise { + return (await store.appendEvent({ + type: "stream.thought.source.rss.item", + schemaVersion: 1, + source: "rss:fixture", + sourceKind: "rss", + externalId: `item-${suffix}`, + idempotencyKey: `rss:item-${suffix}`, + occurredAt: `2026-07-14T00:0${suffix}:00.000Z`, + actor: "rss:fixture", + correlationId: "poll-1", + privacy: "public-source", + payload: { title: `Fixture item ${suffix}`, summary: `Synthetic summary ${suffix} with markup` }, + })).event; +} + +async function eventsOfType(store: JazzThoughtStore, type: string): Promise { + return store.listEvents({ types: [type], source: WORKBENCH_DOCUMENT_SOURCE }); +} + +async function judgments(store: JazzThoughtStore): Promise { + return store.listEvents({ types: ["stream.thought.judgment.training-example"] }); +} + +describe("workbench document workflow", () => { + test("frontmatter encoding is self-describing and reversible", () => { + const content = encodeDocumentContent('A "quoted" title', "# Body\n\ntext"); + expect(content.startsWith("---\ntitle: ")).toBe(true); + expect(decodeDocumentContent(content)).toEqual({ title: 'A "quoted" title', body: "# Body\n\ntext" }); + expect(decodeDocumentContent("no frontmatter")).toEqual({ title: "Untitled", body: "no frontmatter" }); + expect(plainExcerpt({ title: "T", text: "x".repeat(5_000) }).length).toBe(2_000); + expect(plainExcerpt({ nested: { deep: true } })).toBe('{"nested":{"deep":true}}'); + }); + + test("creates a working document whose origin reference survives store reopen", async () => { + const { root, store } = await openStore(); + const origin = await seedOrigin(store); + const workflow = new WorkbenchWorkflow(store, { actor: "operator:test" }); + const head = await workflow.createWorkingDocument({ + originEventId: origin.id, + title: "Notes on fixture item", + body: "# Notes\n\nFirst draft.\n", + requestId: "req-create-0001", + }); + expect(head.documentId).toBe(workingDocumentId(origin.id, "req-create-0001")); + const replay = await workflow.createWorkingDocument({ + originEventId: origin.id, + title: "Notes on fixture item", + body: "# Notes\n\nFirst draft.\n", + requestId: "req-create-0001", + }); + expect(replay.versionId).toBe(head.versionId); + await expect(workflow.createWorkingDocument({ + originEventId: origin.id, + title: "Different", + body: "content", + requestId: "req-create-0001", + })).rejects.toBeInstanceOf(WorkbenchConflictError); + await expect(workflow.createWorkingDocument({ + originEventId: "evt_missing", + title: "x", + body: "y", + requestId: "req-create-0002", + })).rejects.toThrow("Origin event was not found"); + + const created = await eventsOfType(store, DOCUMENT_CREATED_EVENT_TYPE); + expect(created).toHaveLength(1); + expect(created[0]!.rootEventId).toBe(origin.rootEventId); + expect(created[0]!.parentEventId).toBe(origin.id); + expect(created[0]!.privacy).toBe("sensitive"); + expect(await eventsOfType(store, DOCUMENT_VERSION_EVENT_TYPE)).toHaveLength(1); + + await store.close(); + stores.splice(stores.indexOf(store), 1); + const reopened = await testStore(root); + stores.push(reopened); + const detail = await new WorkbenchWorkflow(reopened).getDocument(head.documentId); + expect(detail?.origin?.eventId).toBe(origin.id); + expect(detail?.head.title).toBe("Notes on fixture item"); + expect(detail?.head.body).toBe("# Notes\n\nFirst draft.\n"); + expect(detail?.head.versionId).toBe(head.versionId); + const version = await reopened.getDocumentVersion(head.versionId); + expect(version?.source).toBe(WORKBENCH_DOCUMENT_SOURCE); + expect(version?.path).toBe(`documents/${encodeURIComponent(head.documentId)}.md`); + expect(version?.contentType).toBe("text/markdown"); + const list = await new WorkbenchWorkflow(reopened).listDocuments(); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ documentId: head.documentId, originEventId: origin.id, versionCount: 1, proposalCount: 0 }); + }); + + test("operator edits require the exact head and are idempotent per request", async () => { + const { store } = await openStore(); + const origin = await seedOrigin(store); + const workflow = new WorkbenchWorkflow(store); + const head = await workflow.createWorkingDocument({ originEventId: origin.id, title: "T", body: "one\n", requestId: "req-create-0001" }); + const edited = await workflow.saveOperatorEdit({ documentId: head.documentId, baseVersionId: head.versionId, title: "T2", body: "two\n", requestId: "req-edit-00001" }); + expect(edited.versionId).not.toBe(head.versionId); + const stale = workflow.saveOperatorEdit({ documentId: head.documentId, baseVersionId: head.versionId, title: "T3", body: "three\n", requestId: "req-edit-00002" }); + await expect(stale).rejects.toBeInstanceOf(StaleBaseError); + await expect(stale).rejects.toMatchObject({ headVersionId: edited.versionId }); + const replay = await workflow.saveOperatorEdit({ documentId: head.documentId, baseVersionId: head.versionId, title: "T2", body: "two\n", requestId: "req-edit-00001" }); + expect(replay.versionId).toBe(edited.versionId); + const noop = await workflow.saveOperatorEdit({ documentId: head.documentId, baseVersionId: edited.versionId, title: "T2", body: "two\n", requestId: "req-edit-00003" }); + expect(noop.versionId).toBe(edited.versionId); + expect(await eventsOfType(store, DOCUMENT_VERSION_EVENT_TYPE)).toHaveLength(2); + const detail = await workflow.getDocument(head.documentId); + expect(detail?.head.title).toBe("T2"); + expect(detail?.versions.map((version) => version.reason)).toEqual(["created", "operator-edit"]); + }); + + test("selects exact context and stores a bounded plain-text snapshot", async () => { + const { store } = await openStore(); + const origin = await seedOrigin(store, "1"); + const other = await seedOrigin(store, "2"); + const workflow = new WorkbenchWorkflow(store); + const head = await workflow.createWorkingDocument({ originEventId: origin.id, title: "T", body: "body\n", requestId: "req-create-0001" }); + await expect(workflow.selectContext({ documentId: head.documentId, eventIds: ["evt_missing"], versionIds: [], requestId: "req-select-001" })) + .rejects.toThrow("Selected event was not found"); + const selection = await workflow.selectContext({ + documentId: head.documentId, + eventIds: [other.id, origin.id], + versionIds: [head.versionId], + requestId: "req-select-002", + }); + expect(selection.baseVersionId).toBe(head.versionId); + expect(selection.selectedEvents.map((event) => event.eventId)).toEqual([other.id, origin.id]); + expect(selection.selectedEvents[0]).toMatchObject({ type: other.type, source: other.source, payloadHash: other.payloadHash }); + expect(selection.selectedEvents[0]!.excerpt).toContain("Synthetic summary 2 with markup"); + expect(selection.selectedVersions).toEqual([{ documentId: head.documentId, versionId: head.versionId, sha256: head.sha256, path: `documents/${encodeURIComponent(head.documentId)}.md` }]); + const snapshot = await store.getDocumentVersion(selection.snapshotVersionId); + expect(snapshot?.source).toBe(WORKBENCH_CONTEXT_SOURCE); + expect(snapshot?.contentType).toBe("application/json"); + expect(snapshot?.sha256).toBe(sha256(snapshot!.content)); + expect(JSON.parse(snapshot!.content)).toMatchObject({ documentId: head.documentId, baseVersionId: head.versionId }); + const replay = await workflow.selectContext({ documentId: head.documentId, eventIds: [other.id, origin.id], versionIds: [head.versionId], requestId: "req-select-002" }); + expect(replay.eventId).toBe(selection.eventId); + expect(await eventsOfType(store, CONTEXT_SELECTED_EVENT_TYPE)).toHaveLength(1); + const candidates = await workflow.listCandidates(head.documentId); + expect(candidates?.events.some((event) => event.eventId === origin.id && event.origin)).toBe(true); + expect(candidates?.events.some((event) => event.eventId === other.id)).toBe(true); + expect(candidates?.versions).toEqual([{ versionId: head.versionId, sha256: head.sha256, reason: "created", createdAt: expect.any(String), head: true }]); + }); + + test("deterministic fixture proposal, diff, accept once, duplicate submission, judgment lineage", async () => { + const appId = "thoughtstream-workbench-attach-test"; + const { root, store } = await openStore(appId); + const origin = await seedOrigin(store); + const workflow = new WorkbenchWorkflow(store, { actor: "operator:test" }); + const head = await workflow.createWorkingDocument({ originEventId: origin.id, title: "Doc", body: "# Doc\n\nIntro.\n", requestId: "req-create-0001" }); + const selection = await workflow.selectContext({ documentId: head.documentId, eventIds: [origin.id], versionIds: [head.versionId], requestId: "req-select-001" }); + await expect(workflow.requestProposal({ documentId: head.documentId, selectionId: selection.selectionId, runnerId: "missing", requestId: "req-prop-00001" })) + .rejects.toThrow("Unknown proposal runner"); + const first = await workflow.requestProposal({ documentId: head.documentId, selectionId: selection.selectionId, runnerId: FIXTURE_RUNNER_ID, requestId: "req-prop-00001" }); + const replay = await workflow.requestProposal({ documentId: head.documentId, selectionId: selection.selectionId, runnerId: FIXTURE_RUNNER_ID, requestId: "req-prop-00001" }); + expect(replay.proposalEventId).toBe(first.proposalEventId); + expect(await eventsOfType(store, PROPOSAL_REQUESTED_EVENT_TYPE)).toHaveLength(1); + expect(await eventsOfType(store, PROPOSAL_PROPOSED_EVENT_TYPE)).toHaveLength(1); + const proposal = (await store.getEvent(first.proposalEventId))!; + expect(proposal.parentEventId).toBe(first.requestedEventId); + expect(proposal.privacy).toBe("sensitive"); + expect(proposal.payload).toMatchObject({ + proposalState: "runner-proposed", + operation: "replace-document", + publicationEligible: false, + reason: "fixture: append sources section", + evidenceEventIds: [origin.id], + target: { documentId: head.documentId, baseVersionId: head.versionId, baseSha256: head.sha256 }, + }); + const run = (await store.getRun(first.runId))!; + expect(run.status).toBe("completed"); + expect(run.triggerEventId).toBe(first.requestedEventId); + expect(run.outputEventIds).toEqual([first.proposalEventId]); + expect(run.provider).toBe("fixture"); + expect(run.contextManifest.contextSnapshot).toMatchObject({ id: selection.snapshotVersionId, storage: "jazz-document-version" }); + + const direct = await fixtureDeterministicRunner.propose({ + documentId: head.documentId, + baseVersionId: head.versionId, + baseSha256: head.sha256, + title: "Doc", + baseText: "# Doc\n\nIntro.\n", + snapshot: { documentId: head.documentId, baseVersionId: head.versionId, selectedEvents: selection.selectedEvents, selectedVersions: selection.selectedVersions }, + maxProposedChars: 64_000, + }); + expect(direct.proposedText).toBe(proposal.payload.proposedText); + expect(direct.proposedText).toContain("## Sources"); + expect(direct.proposedText).toContain("Summary: this document cites 2 selected sources."); + + const detailBefore = (await workflow.getDocument(head.documentId))!; + expect(detailBefore.proposals).toHaveLength(1); + expect(detailBefore.proposals[0]).toMatchObject({ status: "pending", stale: false, headVersionId: head.versionId, inference: "none" }); + expect(detailBefore.proposals[0]!.diff).toContain("+## Sources"); + expect(detailBefore.proposals[0]!.diff).toContain(`base ${head.versionId}`); + + const accepted = await workflow.decideProposal({ proposalEventId: first.proposalEventId, disposition: "accept", submissionId: "sub-accept-0001" }); + expect(accepted.replayed).toBe(false); + expect(accepted.resultVersionId).toBeDefined(); + expect(accepted.judgmentEventId).toBeDefined(); + const duplicate = await workflow.decideProposal({ proposalEventId: first.proposalEventId, disposition: "accept", submissionId: "sub-accept-0001" }); + expect(duplicate).toMatchObject({ decisionEventId: accepted.decisionEventId, resultVersionId: accepted.resultVersionId, judgmentEventId: accepted.judgmentEventId, replayed: true }); + await expect(workflow.decideProposal({ proposalEventId: first.proposalEventId, disposition: "reject", submissionId: "sub-accept-0001" })) + .rejects.toThrow("submission id conflicts"); + await expect(workflow.decideProposal({ proposalEventId: first.proposalEventId, disposition: "reject", submissionId: "sub-reject-0002" })) + .rejects.toThrow("already has a human decision"); + + expect(await eventsOfType(store, DOCUMENT_VERSION_EVENT_TYPE)).toHaveLength(2); + expect(await eventsOfType(store, PROPOSAL_DECISION_EVENT_TYPE)).toHaveLength(1); + const allJudgments = await judgments(store); + expect(allJudgments).toHaveLength(1); + expect(allJudgments[0]!.payload).toMatchObject({ + runId: first.runId, + outputEventId: first.proposalEventId, + kind: "accept", + criterion: "workbench-document-proposal", + qualityEligible: false, + externalExportEligible: false, + feedbackSourceEventId: accepted.decisionEventId, + }); + expect(allJudgments[0]!.parentEventId).toBe(accepted.decisionEventId); + expect(allJudgments[0]!.rootEventId).toBe(origin.rootEventId); + + const detail = (await workflow.getDocument(head.documentId))!; + expect(detail.head.versionId).toBe(accepted.resultVersionId); + expect(detail.head.title).toBe("Doc"); + expect(detail.head.body).toBe(direct.proposedText); + expect(detail.proposals[0]).toMatchObject({ status: "accepted", decision: { disposition: "accept", resultVersionId: accepted.resultVersionId, judgmentEventId: accepted.judgmentEventId } }); + expect(detail.versions.at(-1)).toMatchObject({ reason: "proposal-accepted", proposalEventId: first.proposalEventId, decisionEventId: accepted.decisionEventId, baseVersionId: head.versionId }); + + // A second independent client over the same project root sees the accepted head. + const second = await JazzThoughtStore.open({ projectRoot: root, appId, runtimeRevision: "test" }); + stores.push(second); + const seen = await new WorkbenchWorkflow(second).getDocument(head.documentId); + expect(seen?.head.versionId).toBe(accepted.resultVersionId); + expect(seen?.head.body).toBe(direct.proposedText); + expect((await second.listCurrentDocuments(WORKBENCH_DOCUMENT_SOURCE))[0]?.versionId).toBe(accepted.resultVersionId); + }); + + test("reject records a decision and judgment and leaves the document unchanged", async () => { + const { store } = await openStore(); + const origin = await seedOrigin(store); + const workflow = new WorkbenchWorkflow(store); + const head = await workflow.createWorkingDocument({ originEventId: origin.id, title: "Doc", body: "body\n", requestId: "req-create-0001" }); + const selection = await workflow.selectContext({ documentId: head.documentId, eventIds: [origin.id], versionIds: [], requestId: "req-select-001" }); + const proposal = await workflow.requestProposal({ documentId: head.documentId, selectionId: selection.selectionId, runnerId: FIXTURE_RUNNER_ID, requestId: "req-prop-00001" }); + const rejected = await workflow.decideProposal({ proposalEventId: proposal.proposalEventId, disposition: "reject", submissionId: "sub-reject-0001" }); + expect(rejected.resultVersionId).toBeUndefined(); + expect(rejected.judgmentEventId).toBeDefined(); + const detail = (await workflow.getDocument(head.documentId))!; + expect(detail.head.versionId).toBe(head.versionId); + expect(detail.head.body).toBe("body\n"); + expect(detail.versions).toHaveLength(1); + expect(detail.proposals[0]).toMatchObject({ status: "rejected", decision: { disposition: "reject" } }); + expect((await judgments(store))[0]!.payload).toMatchObject({ kind: "reject", qualityEligible: false, externalExportEligible: false }); + const replay = await workflow.decideProposal({ proposalEventId: proposal.proposalEventId, disposition: "reject", submissionId: "sub-reject-0001" }); + expect(replay).toMatchObject({ decisionEventId: rejected.decisionEventId, replayed: true }); + expect(await eventsOfType(store, PROPOSAL_DECISION_EVENT_TYPE)).toHaveLength(1); + expect(await judgments(store)).toHaveLength(1); + }); + + test("an operator edit makes an older proposal stale; accept fails closed and the edit survives", async () => { + const { store } = await openStore(); + const origin = await seedOrigin(store); + const workflow = new WorkbenchWorkflow(store); + const head = await workflow.createWorkingDocument({ originEventId: origin.id, title: "Doc", body: "body\n", requestId: "req-create-0001" }); + const selection = await workflow.selectContext({ documentId: head.documentId, eventIds: [origin.id], versionIds: [], requestId: "req-select-001" }); + const proposal = await workflow.requestProposal({ documentId: head.documentId, selectionId: selection.selectionId, runnerId: FIXTURE_RUNNER_ID, requestId: "req-prop-00001" }); + const edited = await workflow.saveOperatorEdit({ documentId: head.documentId, baseVersionId: head.versionId, title: "Doc", body: "newer operator work\n", requestId: "req-edit-00001" }); + const stale = workflow.decideProposal({ proposalEventId: proposal.proposalEventId, disposition: "accept", submissionId: "sub-accept-0001" }); + await expect(stale).rejects.toBeInstanceOf(StaleBaseError); + await expect(stale).rejects.toMatchObject({ headVersionId: edited.versionId }); + const detail = (await workflow.getDocument(head.documentId))!; + expect(detail.head.versionId).toBe(edited.versionId); + expect(detail.head.body).toBe("newer operator work\n"); + expect(detail.proposals[0]).toMatchObject({ status: "stale", stale: true, headVersionId: edited.versionId }); + expect(detail.proposals[0]!.decision).toBeUndefined(); + expect(await eventsOfType(store, PROPOSAL_DECISION_EVENT_TYPE)).toHaveLength(0); + expect(await eventsOfType(store, DOCUMENT_VERSION_EVENT_TYPE)).toHaveLength(2); + expect(await judgments(store)).toHaveLength(0); + // Rejecting a stale proposal remains allowed. + const rejected = await workflow.decideProposal({ proposalEventId: proposal.proposalEventId, disposition: "reject", submissionId: "sub-reject-0001" }); + expect(rejected.disposition).toBe("reject"); + expect((await workflow.getDocument(head.documentId))!.head.versionId).toBe(edited.versionId); + }); + + test("runner failure records a failed run and no proposal", async () => { + const { store } = await openStore(); + const origin = await seedOrigin(store); + const failing = new Map([["failing", { + id: "failing", + revision: "1", + label: "Failing fixture", + inference: "none" as const, + async propose() { + throw new Error("synthetic runner failure"); + }, + }]]); + const workflow = new WorkbenchWorkflow(store, { runners: failing }); + const head = await workflow.createWorkingDocument({ originEventId: origin.id, title: "Doc", body: "body\n", requestId: "req-create-0001" }); + const selection = await workflow.selectContext({ documentId: head.documentId, eventIds: [origin.id], versionIds: [], requestId: "req-select-001" }); + const attempt = workflow.requestProposal({ documentId: head.documentId, selectionId: selection.selectionId, runnerId: "failing", requestId: "req-prop-00001" }); + await expect(attempt).rejects.toBeInstanceOf(WorkbenchRunnerError); + await expect(attempt).rejects.toThrow("synthetic runner failure"); + expect(await eventsOfType(store, PROPOSAL_REQUESTED_EVENT_TYPE)).toHaveLength(1); + expect(await eventsOfType(store, PROPOSAL_PROPOSED_EVENT_TYPE)).toHaveLength(0); + const runs = await store.listRuns(); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ status: "failed", errorText: "runner-failed: synthetic runner failure" }); + await expect(workflow.requestProposal({ documentId: head.documentId, selectionId: selection.selectionId, runnerId: "failing", requestId: "req-prop-00001" })) + .rejects.toBeInstanceOf(WorkbenchRunnerError); + expect(await store.listRuns()).toHaveLength(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 69f9aae..63f4c3e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "types": ["node"], "jsx": "react-jsx" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "scripts/serve-inspector-proxy.ts", "scripts/configure-inspector-co-chat.ts", "scripts/test-co-chat-browser.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "scripts/serve-inspector-proxy.ts", "scripts/configure-inspector-co-chat.ts", "scripts/test-co-chat-browser.ts", "scripts/workbench-demo.ts"] }