diff --git a/.lane/memory/apps/server/src/experiments/connections.ts/01M4JHF89WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md b/.lane/memory/apps/server/src/experiments/connections.ts/01M4JHF89WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md new file mode 100644 index 000000000..5afad058a --- /dev/null +++ b/.lane/memory/apps/server/src/experiments/connections.ts/01M4JHF89WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md @@ -0,0 +1,12 @@ +--- +id: 01M4JHF89WXY4D1JD3785H1TEJ +anchor: method assign +created: 2026-10-10T09:14:28Z +norm: '1' +sig: f8675142fa786e15 +body_hash: 0b2572e2b4c95f54 +raw_hash: 6cfbb54a446f4e21 +lines: 124-126 +--- + +A connection serves every document in its repository. Implementation pickup finds its build through assign()/assigned() (connection -> document of its last queued build), set by the Build POST; run tokens carry their own documentId. Both are in-memory, like connections themselves. diff --git a/.lane/memory/apps/server/src/experiments/document.ts/01M4QMTZTXKP2R3JPG6TY4GFA1-emit-list-items-joined-by-si.md b/.lane/memory/apps/server/src/experiments/document.ts/01M4QMTZTXKP2R3JPG6TY4GFA1-emit-list-items-joined-by-si.md new file mode 100644 index 000000000..a660038be --- /dev/null +++ b/.lane/memory/apps/server/src/experiments/document.ts/01M4QMTZTXKP2R3JPG6TY4GFA1-emit-list-items-joined-by-si.md @@ -0,0 +1,8 @@ +--- +id: 01M4QMTZTXKP2R3JPG6TY4GFA1 +anchor: function investigationDocument +created: 2026-10-12T08:49:30Z +norm: '1' +--- + +Emit list items joined by single newlines: blank-line-separated items make a loose list that fails the Lexical round trip (room.validate). diff --git a/.lane/memory/apps/server/src/experiments/routes.ts/01M4QMTZX6RSHR0KHHBEVS6F0P-a-published-investigation-is.md b/.lane/memory/apps/server/src/experiments/routes.ts/01M4QMTZX6RSHR0KHHBEVS6F0P-a-published-investigation-is.md new file mode 100644 index 000000000..28aa58448 --- /dev/null +++ b/.lane/memory/apps/server/src/experiments/routes.ts/01M4QMTZX6RSHR0KHHBEVS6F0P-a-published-investigation-is.md @@ -0,0 +1,8 @@ +--- +id: 01M4QMTZX6RSHR0KHHBEVS6F0P +anchor: function owned +created: 2026-10-12T08:49:30Z +norm: '1' +--- + +A published investigation is reachable from its child document id too (childId); run/retry/cancel stay parent-only. diff --git a/.lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md b/.lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md new file mode 100644 index 000000000..287ec3005 --- /dev/null +++ b/.lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md @@ -0,0 +1,8 @@ +--- +id: 01M4QJKMX1FHTVXFFGXY4P07EG +anchor: function newResearchProjections +created: 2026-10-12T08:10:32Z +norm: '1' +--- + +Investigation references share Research's browser authority path: room.apply asks the authorizer per type; plan/service.ts allows add only for an existing record in this document and removal only when not queued/running/publishing. Only Research changes are persisted as researchProjections. diff --git a/.lane/memory/e2e/experiments.e2e.ts/01M4JHF8ABX8VD8B85DTNNDWFW-connections-are-per-person-p.md b/.lane/memory/e2e/experiments.e2e.ts/01M4JHF8ABX8VD8B85DTNNDWFW-connections-are-per-person-p.md new file mode 100644 index 000000000..ef426296e --- /dev/null +++ b/.lane/memory/e2e/experiments.e2e.ts/01M4JHF8ABX8VD8B85DTNNDWFW-connections-are-per-person-p.md @@ -0,0 +1,12 @@ +--- +id: 01M4JHF8ABX8VD8B85DTNNDWFW +anchor: function person +created: 2026-10-10T09:14:28Z +norm: '1' +sig: f9b02acb18645e83 +body_hash: edef4e47aa3087f9 +raw_hash: 9ed184827227bfb3 +lines: 6-8 +--- + +Connections are per person per repository and every e2e test shares octo-org/score, so any test that pairs a connector must sign in as a unique person; a shared 'ana' lets another test's connector pick up the work. diff --git a/apps/connector/src/main.ts b/apps/connector/src/main.ts index 68b5c5031..6f7af116f 100644 --- a/apps/connector/src/main.ts +++ b/apps/connector/src/main.ts @@ -77,7 +77,8 @@ async function connect(args: string[]) { try { let { root: _root, ...source } = info; let pairing = await post("/api/connector/pairings", { ...source, label: basename(info.root) }); - console.error(`Approve this workspace in Chopin: ${pairing.url}`); + console.error(`Connect this checkout of ${info.repository} in Chopin: ${pairing.url}`); + console.error(`Confirm code ${pairing.code} in Chopin.`); let token: string | undefined; while (!abort.signal.aborted && !token) { let claim = await post(`/api/connector/pairings/${pairing.id}/claim`, { @@ -88,7 +89,9 @@ async function connect(args: string[]) { } if (!token) return; api = await remote(url, token); - console.error("Connected. Waiting for owner-authorized local work. Ctrl-C disconnects."); + console.error( + `Connected. Work you start in any ${info.repository} document runs here. Ctrl-C disconnects.`, + ); while (!abort.signal.aborted) { let offered = await api.call("wait_for_work", {}, abort.signal) as { id?: string; diff --git a/apps/server/src/agent/experiment-tools.ts b/apps/server/src/agent/experiment-tools.ts index cdf2f95cc..f6ddb582a 100644 --- a/apps/server/src/agent/experiment-tools.ts +++ b/apps/server/src/agent/experiment-tools.ts @@ -22,7 +22,7 @@ export const experimentTools = { contextSchema, inputSchema: z.object({}).strict(), description: - "List this document's local investigations and available workspace labels. This does not start work.", + "List this document's local investigations and their states. This does not start work.", execute: (_input, { context: { room } }) => answer(async () => { if (!room.investigations) throw new Error("Investigations unavailable"); @@ -51,7 +51,7 @@ export const experimentTools = { key: z.string().min(1).max(128), }).strict(), description: - "Propose a local investigation from the current member request. Explain the objective and required evidence. A workspace owner must explicitly authorize it in Investigations; this tool never executes code. Reuse key for retries.", + "Propose a local investigation from the current member request. Explain the objective and required evidence. A person must select Run on its card, which runs it on their own local agent; this tool never executes code. Reuse key for retries.", execute: (input, { context: { room } }) => answer(async () => { let request = room.currentMemberRequest?.(); diff --git a/apps/server/src/experiments/connections.ts b/apps/server/src/experiments/connections.ts index 4bed8251b..595a17c7a 100644 --- a/apps/server/src/experiments/connections.ts +++ b/apps/server/src/experiments/connections.ts @@ -3,9 +3,9 @@ import type { Source } from "@chopin/experiment"; import { fail, fingerprint } from "./service"; export type PairingInput = Omit & { label: string }; +/** One owner's checkout of one repository; it serves every document in that repository. */ export type Connection = { id: string; - documentId: string; owner: string; login: string; sessionId: string; @@ -17,21 +17,32 @@ type Pending = { id: string; input: PairingInput; proof: string; + /** Shown by both the connector and the pairing page so a person can cross-check them. */ + code: string; expiresAt: number; connection?: Connection; token?: string; }; export type Grant = { connectionId: string; - run?: { id: string; generation: number; kind?: "implementation" }; + run?: { id: string; documentId: string; generation: number; kind?: "implementation" }; }; +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +function confirmation() { + let bytes = randomBytes(8); + let code = [...bytes].map(byte => CODE_ALPHABET[byte % CODE_ALPHABET.length]).join(""); + return `${code.slice(0, 4)}-${code.slice(4)}`; +} + export class Connections { #pending = new Map(); #connections = new Map(); #tokens = new Map(); #runTokens = new Map(); #waiters = new Map void>>(); + #builds = new Map(); + #used = new Map(); #locks = new Map>(); constructor(private now = () => Date.now()) {} create(input: PairingInput) { @@ -39,13 +50,15 @@ export class Connections { if (this.#pending.size >= 100) fail("pairing-capacity"); let secret = randomBytes(32).toString("base64url"); let id = crypto.randomUUID(); + let code = confirmation(); this.#pending.set(id, { id, input, proof: fingerprint(secret), + code, expiresAt: this.now() + 300_000, }); - return { id, secret }; + return { id, secret, code }; } pending(id: string) { let value = this.#pending.get(id); @@ -55,23 +68,20 @@ export class Connections { approve( id: string, owner: { id: string; login: string; sessionId: string }, - documentId: string, repositoryId: string, ) { let value = this.pending(id); if (value.connection) { - if (value.connection.owner !== owner.id || value.connection.documentId !== documentId) { - fail("already-paired"); - } + if ( + value.connection.owner !== owner.id + || value.connection.source.repositoryId !== repositoryId + ) fail("already-paired"); return value.connection; } - if (this.list(documentId).filter(item => item.owner === owner.id).length >= 8) { - fail("connection-limit"); - } + if (this.list(repositoryId, owner.id).length >= 8) fail("connection-limit"); let { label, ...source } = value.input; let connection: Connection = { id: crypto.randomUUID(), - documentId, owner: owner.id, login: owner.login, sessionId: owner.sessionId, @@ -103,12 +113,42 @@ export class Connections { touch(connection: Connection) { connection.expiresAt = this.now() + 90_000; } - list(documentId: string) { + /** Live connections for a repository, most recently heard from first. */ + list(repositoryId: string, owner?: string) { return [...this.#connections.values()].filter(value => - value.documentId === documentId && value.expiresAt > this.now() + value.source.repositoryId === repositoryId && value.expiresAt > this.now() + && (owner === undefined || value.owner === owner) + ).sort((a, b) => b.expiresAt - a.expiresAt); + } + /** Remember which document holds the build a connection was last given. */ + assign(connectionId: string, documentId: string) { + if (this.#connections.has(connectionId)) this.#builds.set(connectionId, documentId); + } + assigned(connectionId: string) { + return this.#builds.get(connectionId); + } + /** Forget a finished, deleted, or unreachable build so it cannot strand the connection. */ + release(connectionId: string, documentId: string) { + if (this.#builds.get(connectionId) === documentId) this.#builds.delete(connectionId); + } + /** Remember the connection that last ran work for a document, to prefer it next time. */ + use(documentId: string, connectionId: string) { + this.#used.set(documentId, connectionId); + } + /** Live connections for a repository, the one last used for `documentId` first. */ + candidates(repositoryId: string, owner: string, documentId: string) { + let last = this.#used.get(documentId); + return this.list(repositoryId, owner).sort((a, b) => + Number(b.id === last) - Number(a.id === last) ); } - runToken(connectionId: string, id: string, generation: number, kind?: "implementation") { + runToken( + connectionId: string, + documentId: string, + id: string, + generation: number, + kind?: "implementation", + ) { let key = `${connectionId}:${id}:${generation}:${kind ?? "experiment"}`; let existing = this.#runTokens.get(key); if (existing) return existing; @@ -116,18 +156,20 @@ export class Connections { this.#runTokens.set(key, token); this.#tokens.set(fingerprint(token), { connectionId, - run: { id, generation, ...(kind ? { kind } : {}) }, + run: { id, documentId, generation, ...(kind ? { kind } : {}) }, }); return token; } revoke(id: string) { let connection = this.#connections.get(id); this.#connections.delete(id); + this.#builds.delete(id); + for (let [document, used] of this.#used) if (used === id) this.#used.delete(document); for (let key of this.#runTokens.keys()) { if (key.startsWith(`${id}:`)) this.#runTokens.delete(key); } for (let [key, grant] of this.#tokens) if (grant.connectionId === id) this.#tokens.delete(key); - if (connection) this.wake(connection.documentId); + if (connection) this.wake(connection.source.repositoryId); } revokeSession(sessionId: string) { for (let connection of this.#connections.values()) { @@ -142,17 +184,18 @@ export class Connections { if (connection.expiresAt <= this.now()) this.revoke(id); } } - wake(documentId: string) { - for (let resolve of this.#waiters.get(documentId) ?? []) resolve(); + /** Wake every connector waiting for work in a repository. */ + wake(repositoryId: string) { + for (let resolve of this.#waiters.get(repositoryId) ?? []) resolve(); } - wait(documentId: string, signal: AbortSignal) { + wait(repositoryId: string, signal: AbortSignal) { return new Promise(resolve => { - let set = this.#waiters.get(documentId) ?? new Set(); - this.#waiters.set(documentId, set); + let set = this.#waiters.get(repositoryId) ?? new Set(); + this.#waiters.set(repositoryId, set); let finish = () => { clearTimeout(timer); set.delete(finish); - if (!set.size) this.#waiters.delete(documentId); + if (!set.size) this.#waiters.delete(repositoryId); signal.removeEventListener("abort", finish); resolve(); }; diff --git a/apps/server/src/experiments/document.test.ts b/apps/server/src/experiments/document.test.ts new file mode 100644 index 000000000..05923d217 --- /dev/null +++ b/apps/server/src/experiments/document.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { limits, parse } from "@chopin/dialect"; +import { parseResult } from "@chopin/experiment"; +import { performance } from "@chopin/experiment/fixtures"; +import { canonical } from "../mcp/create"; +import { investigationDocument, investigationSummary } from "./document"; + +const ID = "6f1c3c2e-8b0a-4c43-9d55-2d6f3c1b9a10"; + +test("a completed result becomes a research-shaped child that round-trips the dialect", () => { + let document = investigationDocument(ID, "Does caching help startup?", performance); + expect(document.title).toBe("Startup comparison"); + expect(canonical(document.source)).toEqual({ source: document.source }); + let headings = document.source.split("\n").filter(line => line.startsWith("#")); + expect(headings).toEqual(["# Startup comparison", "## Views", "## Method", "## Limitations"]); + expect(document.source).toContain("Caching reduced median startup time"); + let embeds = JSON.stringify(parse(document.source)).match(/"name":"Experiment"/g); + expect(embeds).toHaveLength(1); + expect(document.source).toMatch( + new RegExp(``), + ); + expect(investigationSummary("Does caching help startup?", performance)).toBe( + "Caching reduced median startup time in the captured workload.", + ); +}); + +test("the brief names a report without its own heading, and markup stays inert", () => { + let result = parseResult({ + ...performance, + report: "Plain finding with `code` and {braces}.\n", + views: [], + evidence: [{ key: "log", title: "Run ", format: "plain", text: "```\n{x}\n```" }], + provenance: { + environment: "Laptop {env}", + checks: ["ran", "Compared medians"], + limitations: ["One", "Two"], + }, + }); + let document = investigationDocument(ID, "Measure *startup*\nwith detail", result); + expect(document.title).toBe("Measure startup"); + expect(document.source.startsWith("# Measure startup\n")).toBe(true); + expect(canonical(document.source)).toEqual({ source: document.source }); + expect(document.source).not.toContain(" { + let result = parseResult({ + ...performance, + evidence: [..."abcdefgh"].map(key => ({ + key, + title: key, + format: "plain", + text: "x".repeat(64 * 1024), + })), + report: `# Big\n\n${"word ".repeat(20_000)}\n`, + }); + let document = investigationDocument(ID, "Big", result); + expect(Buffer.byteLength(document.source)).toBeLessThanOrEqual(limits.MAX_SOURCE_BYTES); + expect(document.source).toContain("## Evidence"); +}); + +test("a report cannot embed record-backed components into its child", () => { + for ( + let component of [ + ``, + ``, + ``, + ] + ) { + let result = parseResult({ ...performance, report: `# Report\n\n${component}\n` }); + expect(() => investigationDocument(ID, "Brief", result)).toThrow( + "A report cannot embed record-backed components.", + ); + } +}); diff --git a/apps/server/src/experiments/document.ts b/apps/server/src/experiments/document.ts new file mode 100644 index 000000000..7ef0315bb --- /dev/null +++ b/apps/server/src/experiments/document.ts @@ -0,0 +1,123 @@ +import { limits, parse } from "@chopin/dialect"; +import { ExperimentError } from "@chopin/experiment"; +import type { Result } from "@chopin/experiment"; +import { MAX_TITLE_LENGTH } from "../channels/title"; +import { canonical } from "../mcp/create"; +import { reportText } from "../research/publication"; + +const SUMMARY_LENGTH = 240; +// Room for the ids canonicalization adds to each embed. +const SOURCE_BUDGET = limits.MAX_SOURCE_BYTES - 16 * 1024; +// Records own these; a submitted report may not mint their projections. +const PROTECTED = /"name":"(Questionnaire|Question|Decision|Research|Investigation|Experiment)"/; +const LANGUAGES = { plain: "", csv: "", json: "json", diff: "diff" } as const; + +/** Markdown inline source as the words a reader sees. */ +function plain(value: string): string { + return value + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\\(.)|[*`~]/g, (_match, escaped?: string) => escaped ?? "") + .replace(/\s+/g, " ") + .trim(); +} + +function clip(value: string, length: number): string { + let points = [...value]; + return points.length <= length ? value : `${points.slice(0, length - 1).join("").trimEnd()}…`; +} + +/** The report's own top heading leads the child; otherwise the brief names it. */ +function split(brief: string, report: string): { title: string; lead: string; body: string } { + let match = /^# (.+)\n+/.exec(report); + let heading = match ? plain(match[1]) : ""; + if (match && heading) { + return { + title: clip(heading, MAX_TITLE_LENGTH), + lead: match[0].trim(), + body: report.slice(match[0].length), + }; + } + let title = clip(plain(brief.split("\n").find(line => line.trim()) ?? "") || "Investigation", 80); + return { title, lead: `# ${reportText(title)}`, body: report }; +} + +function fence(text: string, language: string): string { + let longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(run => run[0].length)); + let marks = "`".repeat(Math.max(3, longest + 1)); + return `${marks}${language}\n${text.replace(/\n$/, "")}\n${marks}`; +} + +/** One tight list; blank lines between items would make it loose and fail the round trip. */ +function list(items: string[]): string { + return items.map(item => `- ${reportText(item)}`).join("\n"); +} + +function bytes(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +/** The first prose paragraph of a completed report, for the card that links to its child. */ +export function investigationSummary(brief: string, result: Result): string { + let { body } = split(brief, result.report); + let paragraph = body.split(/\n{2,}/).find(block => + block.trim() && !/^\s*(#|[-*+] |\d+[.)] |>|```|~~~|<|\|)/.test(block) + ); + return paragraph ? clip(plain(paragraph), SUMMARY_LENGTH) : ""; +} + +/** + * A completed investigation as a research-shaped child document: its report, an embed for each + * native view, then how the work was done. Raw evidence fills what room the source limit leaves. + */ +export function investigationDocument( + id: string, + brief: string, + result: Result, +): { title: string; source: string } { + if (PROTECTED.test(JSON.stringify(parse(result.report)))) { + throw new ExperimentError( + "protected-report", + "A report cannot embed record-backed components.", + ); + } + let { title, lead, body } = split(brief, result.report); + let blocks = [lead, body.trim()]; + if (result.views.length > 0) { + blocks.push("## Views"); + for (let view of result.views) { + blocks.push(``); + } + } + let { environment, checks, limitations } = result.provenance; + if (environment.trim() || checks.length > 0) { + blocks.push("## Method"); + if (environment.trim()) blocks.push(reportText(environment)); + if (checks.length > 0) blocks.push(list(checks)); + } + if (limitations.length > 0) { + blocks.push("## Limitations"); + blocks.push(list(limitations)); + } + let used = bytes(blocks.join("\n\n")); + let evidence: string[] = []; + for (let item of result.evidence) { + let heading = `### ${reportText(item.title)}`; + let room = SOURCE_BUDGET - used - bytes(heading) - 64; + if (room < 256) break; + let text = bytes(item.text) <= room + ? item.text + : `${Buffer.from(item.text).subarray(0, room).toString("utf8").replace(/�$/, "")}\n…`; + let block = `${heading}\n\n${fence(text, LANGUAGES[item.format])}`; + evidence.push(block); + used += bytes(block) + 2; + } + if (evidence.length > 0) blocks.push("## Evidence", ...evidence); + let source = canonical(`${blocks.filter(Boolean).join("\n\n")}\n`); + if ("issues" in source) { + throw new ExperimentError( + "invalid-report", + source.issues.map(issue => issue.message).join("; ").slice(0, 4000), + ); + } + return { title, source: source.source }; +} diff --git a/apps/server/src/experiments/routes.test.ts b/apps/server/src/experiments/routes.test.ts index c7297c552..de2fa4cb5 100644 --- a/apps/server/src/experiments/routes.test.ts +++ b/apps/server/src/experiments/routes.test.ts @@ -8,20 +8,32 @@ import { MemoryStorage } from "../storage/memory/adapter"; import { registerExperimentRoutes } from "./routes"; import { Connections } from "./connections"; -test("pairing binds the browser owner; only that owner dispatches and scoped claims publish", async () => { +function text(value: { result: { content: Array<{ text: string }> } }) { + return JSON.parse(value.result.content[0].text); +} + +function error(value: { result: { isError?: boolean; content: Array<{ text: string }> } }) { + return value.result.isError ? value.result.content[0].text : ""; +} + +async function setup() { let storage = new MemoryStorage(); - let documentId = crypto.randomUUID(); let now = new Date(); for (let id of ["alice", "bob"]) await storage.users.put({ id, login: id, avatarUrl: "", now }); - await storage.channels.create({ - id: documentId, - repositoryId: "repo", - repositoryOwner: "org", - repositoryName: "repo", - title: "Investigate", - createdBy: "alice", - now, - }); + async function document(repository = "repo") { + let id = crypto.randomUUID(); + await storage.channels.create({ + id, + repositoryId: repository, + repositoryOwner: "org", + repositoryName: repository, + title: `Investigate ${id}`, + createdBy: "alice", + now, + }); + return id; + } + let documentId = await document(); let lease = (await storage.leases.acquire("writer", "test", 60_000))!; let sessions = new Sessions(storage, true); let grant = { @@ -33,8 +45,10 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla let alice = await sessions.issue("alice", grant); let bob = await sessions.issue("bob", grant); let github = { - repositoryAccess: async () => ({ - id: "repo", + repositoryAccess: async (_token: string, owner: string, name: string) => ({ + id: name, + owner, + name, permissions: { pull: true, push: true, admin: false }, }), } as unknown as GitHub; @@ -46,7 +60,8 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla encryptionKey: new Uint8Array(32), }; let router = new Router(); - registerExperimentRoutes(router, { + let lock = { locked: false, documents: new Set() }; + let runtime = registerExperimentRoutes(router, { storage, sessions, github, @@ -57,6 +72,7 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla lease: () => lease, changed: () => {}, context: async () => ({ source: "# Context", revision: 0 }), + canMutate: async id => !lock.locked && !lock.documents.has(id), }); async function request(path: string, body?: unknown, cookie?: string, token?: string) { return (await router.handle( @@ -72,6 +88,46 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla }), ))!; } + async function tool(token: string, name: string, args: unknown) { + return (await request( + "/connector/mcp", + { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, + undefined, + token, + )).json(); + } + async function pair(cookie: string, repository = "org/repo") { + let pending = await (await request("/api/connector/pairings", { + repository, + commit: "a".repeat(40), + label: "Laptop", + })).json(); + await request(`/api/connector/pairings/${pending.id}/approve`, {}, cookie); + return { + ...pending, + ...await (await request(`/api/connector/pairings/${pending.id}/claim`, { + secret: pending.secret, + })).json(), + }; + } + return { + storage, + sessions, + config, + alice, + bob, + document, + documentId, + request, + tool, + pair, + lock, + runtime, + }; +} + +test("work runs on the clicker's own connection; scoped claims publish", async () => { + let { sessions, config, alice, bob, documentId, request, lock } = await setup(); let pair = await (await request("/api/connector/pairings", { repository: "org/repo", commit: "a".repeat(40), @@ -80,10 +136,9 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla expect( (await request(`/api/connector/pairings/${pair.id}/claim`, { secret: "x".repeat(32) })).status, ).toBe(403); - expect( - (await request(`/api/connector/pairings/${pair.id}/approve`, { documentId }, alice.cookie)) - .status, - ).toBe(200); + let approved = await request(`/api/connector/pairings/${pair.id}/approve`, {}, alice.cookie); + expect(approved.status).toBe(200); + expect((await approved.json()).url).toBe("/documents/org/repo"); let paired = await (await request(`/api/connector/pairings/${pair.id}/claim`, { secret: pair.secret })) .json(); @@ -94,11 +149,12 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla bob.cookie, ); let runPath = `/api/documents/${documentId}/experiments/${id}/run`; - expect((await request(runPath, { connectionId: paired.connection.id }, bob.cookie)).status).toBe( - 403, - ); + let refused = await request(runPath, {}, bob.cookie); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("no-workspace"); expect((await request(runPath, { connectionId: paired.connection.id }, alice.cookie)).status) - .toBe(200); + .toBe(409); + expect((await request(runPath, {}, alice.cookie)).status).toBe(200); async function tool(name: string, args: unknown, token = paired.token) { return (await request( "/connector/mcp", @@ -111,6 +167,10 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla expect(claim.input.requester).toBe("bob"); expect(claim.input.authorizer).toBe("alice"); expect((await tool("claim_experiment", { id }, claim.runToken)).result.isError).toBe(true); + let smuggled = await tool("submit_investigation_result", { + result: { ...performance, report: `# Report\n\n\n` }, + }, claim.runToken); + expect(error(smuggled)).toStartWith("protected-report"); expect( (await tool("submit_experiment_result", { result: performance }, claim.runToken)).result .isError, @@ -126,6 +186,63 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla expect(value.state).toBe("completed"); expect(value.result.datasets[0].key).toBe("startup"); expect(value.candidate).toBeUndefined(); + expect(value.child).toMatchObject({ + title: "Startup comparison", + summary: "Caching reduced median startup time in the captured workload.", + }); + let child = value.child.id; + let reached = await request(`/api/documents/${child}/experiments/${id}`, undefined, bob.cookie); + expect((await reached.json()).id).toBe(id); + let listed = await (await request(`/api/documents/${child}/experiments`, undefined, bob.cookie)) + .json(); + expect(listed.experiments.map((item: { id: string }) => item.id)).toEqual([id]); + expect( + await (await request(`/api/documents/${child}/investigation-provenance`, undefined, bob.cookie)) + .json(), + ).toEqual({ + investigationId: id, + parentChannelId: documentId, + brief: "Measure startup", + viewCount: 1, + startedBy: "bob", + }); + expect( + (await request(`/api/documents/${documentId}/investigation-provenance`, undefined, bob.cookie)) + .status, + ).toBe(404); + // The child's evidence belongs to the parent's record, so the parent's build lock applies. + lock.documents.add(documentId); + for ( + let [action, input] of [ + ["decision", { + id: crypto.randomUUID(), + view: "comparison", + revision: 0, + conclusion: "Use cached startup", + rationale: "", + }], + ["placement", { view: "comparison" }], + ] as const + ) { + let refused = await request( + `/api/documents/${child}/experiments/${id}/${action}`, + input, + bob.cookie, + ); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("document-locked"); + } + expect( + (await (await request(`/api/documents/${child}/experiments/${id}`, undefined, bob.cookie)) + .json()).locked, + ).toBe(true); + lock.documents.delete(documentId); + expect((await tool("complete_experiment", { id, generation: claim.generation })).result.isError) + .not.toBe(true); + expect( + (await (await request(`/api/documents/${documentId}/experiments/${id}`, undefined, bob.cookie)) + .json()).child.id, + ).toBe(child); await sessions.revoke( new Request(config.origin, { headers: { cookie: alice.cookie.split(";")[0] } }), ); @@ -139,6 +256,133 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla ).toBe(401); }); +test("one connection serves every document in its repository and no other", async () => { + let { alice, bob, document, documentId, request, tool, pair } = await setup(); + let second = await document(); + let elsewhere = await document("other"); + let connection = await pair(alice.cookie); + for (let target of [documentId, second]) { + let id = crypto.randomUUID(); + await request(`/api/documents/${target}/experiments`, { id, brief: "Measure" }, alice.cookie); + let run = await request(`/api/documents/${target}/experiments/${id}/run`, {}, alice.cookie); + expect((await run.json()).connectionId).toBe(connection.connection.id); + expect(text(await tool(connection.token, "wait_for_experiment", {}))).toEqual({ id }); + let claim = text(await tool(connection.token, "claim_experiment", { id })); + expect(claim.input.documentId).toBe(target); + let read = text(await tool(claim.runToken, "read_experiment", {})); + expect(read.input.documentId).toBe(target); + await tool(connection.token, "fail_experiment", { + id, + generation: claim.generation, + error: "Stopped", + }); + } + let id = crypto.randomUUID(); + await request(`/api/documents/${elsewhere}/experiments`, { id, brief: "Measure" }, alice.cookie); + let refused = await request( + `/api/documents/${elsewhere}/experiments/${id}/run`, + {}, + alice.cookie, + ); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("no-workspace"); + let foreign = crypto.randomUUID(); + await request(`/api/documents/${second}/experiments`, { id: foreign, brief: "Bob" }, bob.cookie); + expect( + (await request(`/api/documents/${second}/experiments/${foreign}/run`, {}, bob.cookie)) + .status, + ).toBe(409); + let claimed = await tool(connection.token, "claim_experiment", { id: foreign }); + expect(claimed.result.isError).toBe(true); + expect(claimed.result.content[0].text).toStartWith("connection-forbidden"); +}); + +test("a claim refuses a document in another repository and interrupts archived or locked ones", async () => { + let { alice, document, documentId, request, tool, pair, lock, runtime, storage } = await setup(); + let connection = await pair(alice.cookie); + async function queued(target: string) { + let id = crypto.randomUUID(); + await request(`/api/documents/${target}/experiments`, { id, brief: "Measure" }, alice.cookie); + expect( + (await request(`/api/documents/${target}/experiments/${id}/run`, {}, alice.cookie)) + .status, + ).toBe(200); + return id; + } + + // A record bound to this connection in another repository is never claimable through it. + let elsewhere = await document("other"); + let foreign = crypto.randomUUID(); + await runtime.service.create(elsewhere, "alice", "Foreign", foreign); + await runtime.service.authorize(foreign, connection.connection.id, { + id: foreign, + documentId: elsewhere, + requester: "alice", + authorizer: "alice", + brief: "Foreign", + source: { repositoryId: "other", repository: "org/other", commit: "a".repeat(40) }, + context: "Document revision 0", + }); + expect(error(await tool(connection.token, "claim_experiment", { id: foreign }))) + .toStartWith("repository-forbidden"); + await runtime.service.stop(foreign, "cancelled"); + + let locked = await queued(documentId); + lock.locked = true; + expect(error(await tool(connection.token, "claim_experiment", { id: locked }))) + .toStartWith("invalid-state"); + expect((await runtime.service.store.get(locked))?.state).toBe("interrupted"); + lock.locked = false; + + let second = await document(); + let archived = await queued(second); + await storage.channels.archive({ id: second, now: new Date() }); + expect(error(await tool(connection.token, "claim_experiment", { id: archived }))) + .toStartWith("invalid-state"); + expect((await runtime.service.store.get(archived))?.state).toBe("interrupted"); +}); + +test("logging out revokes a run already in progress", async () => { + let { alice, documentId, request, tool, pair, sessions, config } = await setup(); + let connection = await pair(alice.cookie); + let id = crypto.randomUUID(); + await request(`/api/documents/${documentId}/experiments`, { id, brief: "Measure" }, alice.cookie); + await request(`/api/documents/${documentId}/experiments/${id}/run`, {}, alice.cookie); + let claim = text(await tool(connection.token, "claim_experiment", { id })); + expect(text(await tool(claim.runToken, "read_experiment", {})).input.id).toBe(id); + await sessions.revoke( + new Request(config.origin, { headers: { cookie: alice.cookie.split(";")[0] } }), + ); + let refused = await request( + "/connector/mcp", + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "read_experiment", arguments: {} }, + }, + undefined, + claim.runToken, + ); + expect(refused.status).toBe(401); +}); + +test("a pairing carries a confirmation code the page can show", async () => { + let { alice, request } = await setup(); + let pending = await (await request("/api/connector/pairings", { + repository: "org/repo", + commit: "a".repeat(40), + label: "Laptop", + })).json(); + expect(pending.code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/); + expect(pending.url).not.toContain(pending.code); + let shown = + await (await request(`/api/connector/pairings/${pending.id}`, undefined, alice.cookie)) + .json(); + expect(shown.code).toBe(pending.code); + expect(shown.documents).toBeUndefined(); +}); + test("expired pairing and connections cannot be revived by replay", () => { let now = 0; let connections = new Connections(() => now); @@ -147,7 +391,7 @@ test("expired pairing and connections cannot be revived by replay", () => { commit: "a".repeat(40), label: "Laptop", }); - connections.approve(pending.id, { id: "alice", login: "alice", sessionId: "s" }, "d", "repo"); + connections.approve(pending.id, { id: "alice", login: "alice", sessionId: "s" }, "repo"); let result = connections.claim(pending.id, pending.secret); if (!("token" in result)) throw new Error("missing token"); now = 91_000; @@ -155,3 +399,55 @@ test("expired pairing and connections cannot be revived by replay", () => { now = 301_000; expect(() => connections.claim(pending.id, pending.secret)).toThrow("pairing-expired"); }); + +test("a child document refuses new investigations", async () => { + let { storage, documentId, request, alice } = await setup(); + let child = crypto.randomUUID(); + await storage.channels.create({ + id: child, + repositoryId: "repo", + repositoryOwner: "org", + repositoryName: "repo", + parentChannelId: documentId, + title: "Child", + createdBy: "alice", + now: new Date(), + }); + let refused = await request( + `/api/documents/${child}/experiments`, + { id: crypto.randomUUID(), brief: "Measure startup" }, + alice.cookie, + ); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("child-document"); +}); + +test("a stale Ready to run cannot cancel another person's queued run", async () => { + let { documentId, request, alice, bob, pair, runtime } = await setup(); + await pair(alice.cookie); + let id = crypto.randomUUID(); + await request(`/api/documents/${documentId}/experiments`, { id, brief: "Measure" }, alice.cookie); + let ready = await runtime.service.store.get(id); + expect( + (await request(`/api/documents/${documentId}/experiments/${id}/run`, {}, alice.cookie)) + .status, + ).toBe(200); + // Bob's request reads the record before Alice's Run lands, then writes after it. + let get = runtime.service.store.get.bind(runtime.service.store); + let stale = true; + runtime.service.store.get = async (value: string) => { + if (stale && value === id) { + stale = false; + return structuredClone(ready); + } + return get(value); + }; + let refused = await request( + `/api/documents/${documentId}/experiments/${id}/cancel`, + {}, + bob.cookie, + ); + expect(refused.status).toBe(403); + expect((await refused.json()).error).toBe("connection-forbidden"); + expect((await get(id))?.state).toBe("queued"); +}); diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index cf9e6176f..9625126bc 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -1,15 +1,18 @@ import { z } from "zod"; import { capabilities, ExperimentError, limits, sourceSchema } from "@chopin/experiment"; import { publicInvestigation } from "@chopin/experiment/records"; +import type { Investigation, InvestigationProvenance } from "@chopin/experiment/records"; import type { HostedAuth } from "../auth/routes"; import type { AuthenticatedSession } from "../auth/session"; import type { Router } from "../http/router"; import { canonical as canonicalReport } from "../mcp/create"; import { parse } from "@chopin/dialect"; -import { childDocumentPath, documentPath } from "@chopin/protocol/document-url"; +import { documentsPath } from "@chopin/protocol/document-url"; import { Connections } from "./connections"; +import type { Connection } from "./connections"; import { Experiments, fail } from "./service"; import { datasetCsv } from "./export"; +import { investigationSummary } from "./document"; import { implementationSchemas } from "../tasks/connector"; import type { ImplementationConnector } from "../tasks/connector"; import { connectorSchemas } from "./mcp-schema"; @@ -55,7 +58,10 @@ function json(value: unknown, status = 200) { export function registerExperimentRoutes(router: Router, auth: HostedAuth, options: Options) { let connections = new Connections(); let changed = (id: string) => { - connections.wake(id); + void auth.storage.channels.get(id).then( + channel => channel && connections.wake(channel.repositoryId), + () => {}, + ); options.changed(id); }; let service = new Experiments(auth.storage.experiments, options.lease, changed); @@ -74,9 +80,68 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio if (write && channel.archivedAt) fail("document-archived"); return { channel, repository, session }; } + /** Push or admin on a repository named by a checkout, resolved through the session. */ + async function repositoryWrite(session: AuthenticatedSession | undefined, fullName: string) { + if (!session) fail("authentication-required"); + let [owner, name] = fullName.split("/"); + let { value: repository } = await auth.sessions.use( + session, + token => auth.github.repositoryAccess(token, owner, name), + ); + if (!repository || !(repository.permissions.push || repository.permissions.admin)) { + fail("repository-forbidden"); + } + return repository; + } + /** Work only ever runs on the clicker's own live connection for the document's repository. */ + async function busy(connection: Connection) { + return (await service.store.active()).some(item => + item.connectionId === connection.id && ["running", "publishing"].includes(item.state) + ) || !!await options.implementations?.()?.busy(connection); + } + /** + * The clicker's idle connection, preferring the one last used for this document. When every + * connection is busy the work queues behind the first. + */ + async function choose(owner: string, repositoryId: string, documentId: string) { + let live = connections.candidates(repositoryId, owner, documentId); + if (!live.length) fail("no-workspace"); + for (let connection of live) if (!await busy(connection)) return connection; + return live[0]; + } async function mutationAllowed(id: string) { if (options.canMutate && !await options.canMutate(id)) fail("document-locked"); } + /** A published investigation's views and decisions are also reachable from its child. */ + async function owned(id: string, documentId: string) { + let value = await service.store.get(id); + if (!value || value.documentId !== documentId && value.childId !== documentId) { + fail("not-found"); + } + return value; + } + /** A child's evidence decisions belong to the parent's record, so its build lock applies too. */ + async function parentAllowed(value: Investigation, documentId: string) { + if (value.documentId !== documentId) await mutationAllowed(value.documentId); + } + async function published(value: Investigation) { + let child = value.childId ? await auth.storage.channels.get(value.childId) : undefined; + let locked = !!options.canMutate && !await options.canMutate(value.documentId); + return { + ...publicInvestigation(value), + ...(locked ? { locked } : {}), + ...(child && value.result + ? { + child: { + id: child.id, + title: child.title, + slug: child.slug, + summary: investigationSummary(value.brief, value.result), + }, + } + : {}), + }; + } function route( method: string, path: string, @@ -121,50 +186,21 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let session = await auth.sessions.authenticate(request); if (!session) fail("authentication-required"); let pending = connections.pending(params.id); - let [owner, name] = pending.input.repository.split("/"); - let { value: repository } = await auth.sessions.use( - session, - token => auth.github.repositoryAccess(token, owner, name), - ); - if (!repository || !(repository.permissions.push || repository.permissions.admin)) { - fail("repository-forbidden"); - } - let documents = await auth.storage.channels.list(repository.id, 100); - return json({ input: pending.input, documents: documents.channels, owner: session.user.login }); + await repositoryWrite(session, pending.input.repository); + return json({ input: pending.input, owner: session.user.login, code: pending.code }); }); route("POST", "/api/connector/pairings/:id/approve", async (request, params) => { - let { documentId } = z.object({ documentId: z.string().uuid() }).strict().parse( - await body(request), - ); - let { session, channel } = await access( - await auth.sessions.authenticate(request), - documentId, - true, - ); + z.object({}).strict().parse(await body(request)); + let session = await auth.sessions.authenticate(request); let pending = connections.pending(params.id); - if ( - pending.input.repository.toLowerCase() - !== `${channel.repositoryOwner}/${channel.repositoryName}`.toLowerCase() - ) fail("repository-forbidden"); + let repository = await repositoryWrite(session, pending.input.repository); let connection = connections.approve( params.id, - { id: session.user.id, login: session.user.login, sessionId: session.session.id }, - documentId, - channel.repositoryId, + { id: session!.user.id, login: session!.user.login, sessionId: session!.session.id }, + repository.id, ); - changed(documentId); - let parent = channel.parentChannelId - ? await auth.storage.channels.get(channel.parentChannelId) - : undefined; - let url = parent - ? childDocumentPath( - channel.repositoryOwner, - channel.repositoryName, - parent.slug, - channel.slug, - ) - : documentPath(channel.repositoryOwner, channel.repositoryName, channel.slug); - return json({ id: connection.id, documentId, url }); + connections.wake(repository.id); + return json({ id: connection.id, url: documentsPath(repository.owner, repository.name) }); }); route("POST", "/api/connector/pairings/:id/claim", async (request, params) => { let { secret } = z.object({ secret: z.string().min(32).max(100) }).strict().parse( @@ -174,10 +210,12 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio }); route("GET", "/api/documents/:id/experiments", async (request, params) => { await access(await auth.sessions.authenticate(request), params.id, false); - connections.sweep(); + let source = await service.store.published(params.id); return json({ - connections: connections.list(params.id).map(({ sessionId: _session, ...value }) => value), - experiments: (await service.store.list(params.id)).map(value => ({ + experiments: [ + ...(await service.store.list(params.id)), + ...(source ? [source] : []), + ].map(value => ({ id: value.id, brief: value.brief, state: value.state, @@ -190,41 +228,58 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio }); }); route("POST", "/api/documents/:id/experiments", async (request, params) => { - let { session } = await access(await auth.sessions.authenticate(request), params.id, true); + let { session, channel } = await access( + await auth.sessions.authenticate(request), + params.id, + true, + ); + // Like Research, investigations start only in a top-level document. + if (channel.parentChannelId) fail("child-document"); await mutationAllowed(params.id); let input = z.object({ id: z.string().uuid(), brief: z.string().min(1).max(limits.brief), - parentId: z.string().uuid().optional(), }).strict().parse(await body(request)); return json( publicInvestigation( - await service.create(params.id, session.user.id, input.brief, input.id, input.parentId), + await service.create(params.id, session.user.id, input.brief, input.id), ), ); }); route("GET", "/api/documents/:id/experiments/:experiment", async (request, params) => { await access(await auth.sessions.authenticate(request), params.id, false); - let value = await service.store.get(params.experiment); - if (!value || value.documentId !== params.id) fail("not-found"); - return json(publicInvestigation(value)); + return json(await published(await owned(params.experiment, params.id))); + }); + route("GET", "/api/documents/:id/investigation-provenance", async (request, params) => { + await access(await auth.sessions.authenticate(request), params.id, false); + let value = await service.store.published(params.id); + if (!value) fail("not-found"); + let user = await auth.storage.users.get(value.requester); + return json( + { + investigationId: value.id, + parentChannelId: value.documentId, + brief: value.brief, + viewCount: value.result?.views.length ?? 0, + ...(user ? { startedBy: user.login } : {}), + } satisfies InvestigationProvenance, + ); }); route("POST", "/api/documents/:id/experiments/:experiment/run", async (request, params) => { - let { session } = await access(await auth.sessions.authenticate(request), params.id, true); - await mutationAllowed(params.id); - let { connectionId } = z.object({ connectionId: z.string().uuid() }).strict().parse( - await body(request), + let { session, channel } = await access( + await auth.sessions.authenticate(request), + params.id, + true, ); - let connection = connections.get(connectionId); - if ( - !connection || connection.owner !== session.user.id || connection.documentId !== params.id - ) fail("connection-forbidden"); + await mutationAllowed(params.id); + z.object({}).strict().parse(await body(request)); let value = await service.store.get(params.experiment); if (!value || value.documentId !== params.id) fail("not-found"); let context = await options.context(params.id); if (!context) fail("not-found"); let previous = (await service.store.list(params.id)).toReversed().find(item => item.input); - let result = await service.authorize(value.id, connectionId, { + let connection = await choose(session.user.id, channel.repositoryId, params.id); + let result = await service.authorize(value.id, connection.id, { id: value.id, documentId: params.id, requester: value.requester, @@ -233,39 +288,66 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio source: previous?.input?.source ?? connection.source, context: `Document revision ${context.revision}\n${context.source}`, }); + connections.use(params.id, connection.id); return json(publicInvestigation(result)); }); + route("POST", "/api/documents/:id/experiments/:experiment/retry", async (request, params) => { + await access(await auth.sessions.authenticate(request), params.id, true); + await mutationAllowed(params.id); + z.object({}).strict().parse(await body(request)); + let value = await service.store.get(params.experiment); + if (!value || value.documentId !== params.id) fail("not-found"); + return json(publicInvestigation(await service.retry(value.id))); + }); route("POST", "/api/documents/:id/experiments/:experiment/cancel", async (request, params) => { let { session } = await access(await auth.sessions.authenticate(request), params.id, true); let value = await service.store.get(params.experiment); if (!value || value.documentId !== params.id) fail("not-found"); - if ((value.input?.authorizer ?? value.requester) !== session.user.id) { - fail("connection-forbidden"); - } - return json( - publicInvestigation(await service.stop(value.id, "cancelled", "Cancelled by owner.")), - ); + // Withdrawing a ready card runs nothing, so any writer may; running work is its runner's. + // Checked on the record being written: a stale "Ready to run" must not stop someone's run. + let stopped = await service.stop(value.id, "cancelled", "Cancelled by owner.", current => { + if ( + current.state !== "requested" + && (current.input?.authorizer ?? current.requester) !== session.user.id + ) fail("connection-forbidden"); + }); + return json(publicInvestigation(stopped)); }); + /** Re-authorize the connection's owner for its repository, and for a run's document. */ async function connector(token: string) { let found = connections.lookup(token); - await access( - await auth.sessions.resolve(found.connection.sessionId), - found.connection.documentId, - true, - ); + let session = await auth.sessions.resolve(found.connection.sessionId); + let repository = await repositoryWrite(session, found.connection.source.repository); + if (repository.id !== found.connection.source.repositoryId) fail("repository-forbidden"); + if (found.grant.run) await target(session, found.connection, found.grant.run.documentId); if (!connections.get(found.connection.id)) fail("connection-unavailable"); connections.touch(found.connection); - return found; + return { ...found, session: session! }; + } + /** Write access to a run's document, which must belong to the connection's repository. */ + async function target( + session: AuthenticatedSession | undefined, + connection: Connection, + documentId: string, + ) { + let { channel } = await access(session, documentId, true); + if (channel.repositoryId !== connection.source.repositoryId) fail("repository-forbidden"); + } + /** The connection's queued, running, or publishing investigations across its repository. */ + async function work(connection: Connection, states: string[]) { + return (await service.store.active()).filter(item => + item.connectionId === connection.id && states.includes(item.state) + ); } route( "GET", "/api/documents/:id/experiments/:experiment/export/:dataset", async (request, params) => { await access(await auth.sessions.authenticate(request), params.id, false); - let value = await service.store.get(params.experiment); - let dataset = value?.result?.datasets.find(item => item.key === params.dataset); - if (!value || value.documentId !== params.id || !dataset) fail("not-found"); + let value = await owned(params.experiment, params.id); + let dataset = value.result?.datasets.find(item => item.key === params.dataset); + if (!dataset) fail("not-found"); let csv = new URL(request.url).searchParams.get("format") === "csv"; return new Response(csv ? datasetCsv(dataset) : JSON.stringify(dataset, null, 2), { headers: { @@ -280,11 +362,9 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio route("POST", "/api/documents/:id/experiments/:experiment/decision", async (request, params) => { let { session } = await access(await auth.sessions.authenticate(request), params.id, true); await mutationAllowed(params.id); - let value = await service.store.get(params.experiment); - if (!value || value.documentId !== params.id) fail("not-found"); - return json( - publicInvestigation(await service.decide(value.id, session.user, await body(request))), - ); + let value = await owned(params.experiment, params.id); + await parentAllowed(value, params.id); + return json(await published(await service.decide(value.id, session.user, await body(request)))); }); route("POST", "/api/documents/:id/experiments/:experiment/placement", async (request, params) => { await access(await auth.sessions.authenticate(request), params.id, true); @@ -294,11 +374,9 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio decision: z.string().uuid().optional(), remove: z.boolean().optional(), }).strict().parse(await body(request)); - let value = await service.store.get(params.experiment); - if ( - !value || value.documentId !== params.id - || !value.result?.views.some(view => view.key === input.view) - ) fail("not-found"); + let value = await owned(params.experiment, params.id); + await parentAllowed(value, params.id); + if (!value.result?.views.some(view => view.key === input.view)) fail("not-found"); if ( input.decision && !value.decisions.some(decision => @@ -313,17 +391,16 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio input.decision ?? "", input.remove ?? false, ); - return json(publicInvestigation(value)); + return json(await published(value)); }); route("POST", "/api/documents/:id/experiments/:experiment/state", async (request, params) => { let { session } = await access(await auth.sessions.authenticate(request), params.id, true); - let value = await service.store.get(params.experiment); - if (!value || value.documentId !== params.id) fail("not-found"); + let value = await owned(params.experiment, params.id); let input = z.object({ view: z.string().max(64), patch: z.unknown() }).strict().parse( await body(request), ); return json( - publicInvestigation(await service.select(value.id, session.user.id, input.view, input.patch)), + await published(await service.select(value.id, session.user.id, input.view, input.patch)), ); }); route("GET", "/connector/mcp", async () => new Response(null, { status: 405 })); @@ -332,12 +409,11 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio (request.headers.get("authorization") ?? "").replace(/^Bearer /, ""), ); if (!grant.run) connections.revoke(connection.id); - changed(connection.documentId); return new Response(null, { status: 204 }); }); route("POST", "/connector/mcp", async request => { let token = (request.headers.get("authorization") ?? "").replace(/^Bearer /, ""); - let { connection, grant } = await connector(token); + let { connection, grant, session } = await connector(token); let implementation = options.implementations?.(); let implementationRun = grant.run?.kind === "implementation"; let schemas = implementationRun ? implementationSchemas(true) : { @@ -385,50 +461,52 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let value: unknown; if (name === "disconnect_workspace") { connections.revoke(connection.id); - changed(connection.documentId); - for (let active of await service.store.list(connection.documentId)) { - if ( - active.connectionId === connection.id - && ["queued", "running", "publishing"].includes(active.state) - ) await service.stop(active.id, "interrupted", "Workspace disconnected."); + for (let active of await work(connection, ["queued", "running", "publishing"])) { + await service.stop(active.id, "interrupted", "Workspace disconnected."); } value = { disconnected: true }; } else if (name === "wait_for_work") { + // A build in an archived document cannot be claimed; its expiry fails it in the sweep. + let build = async () => { + let offered = await implementation!.waiting(connection); + let documentId = connections.assigned(connection.id); + let channel = offered && documentId + ? await auth.storage.channels.get(documentId) + : undefined; + return channel && !channel.archivedAt ? offered : undefined; + }; let queued = async () => - await implementation!.waiting(connection) + await build() ?? (await implementation!.busy(connection) ? undefined - : (await service.store.list(connection.documentId)).filter(item => - item.connectionId === connection.id && item.state === "queued" - ).map(item => ({ id: item.id, kind: "experiment" }))[0]); + : (await work(connection, ["queued"])).map(item => ({ + id: item.id, + kind: "experiment", + }))[0]); value = await queued(); if (!value) { - await connections.wait(connection.documentId, request.signal); + await connections.wait(connection.source.repositoryId, request.signal); await connector(token); value = await queued(); } value ??= { waiting: true }; } else if (implementationRun || Object.hasOwn(implementationSchemas(false), name)) { if (!implementation) fail("tool-forbidden"); + let documentId = grant.run?.documentId ?? connections.assigned(connection.id); + if (documentId) await target(session, connection, documentId); if (name === "claim_implementation_build") { value = await connections.locked(connection.id, async () => { - if ( - (await service.store.list(connection.documentId)).some(item => - item.connectionId === connection.id - && ["running", "publishing"].includes(item.state) - ) - ) fail("workspace-busy"); + if ((await work(connection, ["running", "publishing"])).length) { + fail("workspace-busy"); + } return implementation.call(connection, grant, name, args); }); } else value = await implementation.call(connection, grant, name, args); } else if (name === "wait_for_experiment") { - let queued = async () => - (await service.store.list(connection.documentId)).find(item => - item.connectionId === connection.id && item.state === "queued" - ); + let queued = async () => (await work(connection, ["queued"]))[0]; value = await queued(); if (!value) { - await connections.wait(connection.documentId, request.signal); + await connections.wait(connection.source.repositoryId, request.signal); await connector(token); value = await queued(); } @@ -436,17 +514,38 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } else if (name === "claim_experiment") { let id = z.string().uuid().parse(args.id); value = await connections.locked(connection.id, async () => { - let active = (await service.store.list(connection.documentId)).find(item => - item.connectionId === connection.id && ["running", "publishing"].includes(item.state) - ); + let active = (await work(connection, ["running", "publishing"]))[0]; if (active && active.id !== id || await implementation?.busy(connection)) { fail("workspace-busy"); } + let offered = await service.store.get(id); + if (!offered || offered.connectionId !== connection.id) fail("connection-forbidden"); + try { + await target(session, connection, offered.documentId); + await mutationAllowed(offered.documentId); + } catch (error) { + // One archived or building document must not stall the repository's connector. + if ( + !(error instanceof ExperimentError) + || !["document-archived", "document-locked"].includes(error.code) + ) throw error; + await service.stop( + id, + "interrupted", + "The document changed before your local agent started. Propose a retry.", + ); + fail("invalid-state"); + } let claimed = await service.claim(id, connection.id); return { input: claimed.input, generation: claimed.generation, - runToken: connections.runToken(connection.id, id, claimed.generation), + runToken: connections.runToken( + connection.id, + claimed.documentId, + id, + claimed.generation, + ), }; }); } else { @@ -455,9 +554,10 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio ?? z.number().int().positive().parse(args.generation); let current = await service.store.get(id); if ( - !current || current.documentId !== connection.documentId - || current.connectionId !== connection.id + !current || current.connectionId !== connection.id + || grant.run && current.documentId !== grant.run.documentId ) fail("connection-forbidden"); + if (!grant.run) await target(session, connection, current.documentId); if (name === "complete_experiment") { value = await service.complete(id, connection.id, generation); } else { @@ -484,7 +584,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } if ( JSON.stringify(parse(source.source)).match( - /"name":"(Questionnaire|Question|Decision|Research|Experiment)"/, + /"name":"(Questionnaire|Question|Decision|Research|Investigation|Experiment)"/, ) ) fail("protected-report"); value = await service.candidate(id, connection.id, generation, { diff --git a/apps/server/src/experiments/service.test.ts b/apps/server/src/experiments/service.test.ts index c31d81293..1b44619c7 100644 --- a/apps/server/src/experiments/service.test.ts +++ b/apps/server/src/experiments/service.test.ts @@ -83,3 +83,43 @@ test("claims expire, stale workers cannot publish and completion replays atomica expect((await storage.experiments.get(another.id))?.state).toBe("interrupted"); expect(published).not.toContain(channelId); }); + +test("retry returns settled work to its card under the same identity and fences the old claim", async () => { + let storage = new MemoryStorage(); + let { userId, lease } = await userAndChannel(storage); + let documentId = crypto.randomUUID(); + await storage.channels.create({ + id: documentId, + repositoryId: "repo", + repositoryOwner: "org", + repositoryName: "repo", + title: "Experiment", + createdBy: userId, + now: new Date(), + }); + let service = new Experiments(storage.experiments, () => lease); + let draft = await service.create(documentId, userId, "Measure startup"); + await expect(service.retry(draft.id)).resolves.toMatchObject({ state: "requested" }); + let input = { + id: draft.id, + documentId, + requester: userId, + authorizer: userId, + brief: draft.brief, + context: "", + source: { repositoryId: "repo", repository: "org/repo", commit: "a".repeat(40) }, + }; + await service.authorize(draft.id, "connection", input); + let claimed = await service.claim(draft.id, "connection"); + await expect(service.retry(draft.id)).rejects.toThrow("invalid-state"); + await service.stop(draft.id, "failed", "Agent crashed."); + let retried = await service.retry(draft.id); + expect(retried).toMatchObject({ id: draft.id, state: "requested", progress: "" }); + expect(retried.input).toBeUndefined(); + expect(retried.connectionId).toBeUndefined(); + await expect(service.renew(draft.id, "connection", claimed.generation)).rejects.toThrow( + "stale-claim", + ); + await service.authorize(draft.id, "connection", input); + expect((await service.claim(draft.id, "connection")).generation).toBe(claimed.generation + 1); +}); diff --git a/apps/server/src/experiments/service.ts b/apps/server/src/experiments/service.ts index 44c067814..202d6ab80 100644 --- a/apps/server/src/experiments/service.ts +++ b/apps/server/src/experiments/service.ts @@ -12,6 +12,8 @@ import { applySelection, selectionPatchSchema } from "@chopin/experiment/selecti import type { ExperimentStore } from "../storage/experiments"; import type { Lease } from "../storage/model"; import { z } from "zod"; +import * as Plan from "../plan/service"; +import { investigationDocument } from "./document"; export const CLAIM_MS = 60_000; export function fingerprint(value: unknown) { @@ -33,7 +35,6 @@ export class Experiments { requester: string, brief: string, id: string = crypto.randomUUID(), - parentId?: string, ) { let existing = await this.store.get(id); if (existing) { @@ -51,7 +52,6 @@ export class Experiments { documentId, requester, brief, - parentId, revision: 0, state: "requested", generation: 0, @@ -78,6 +78,7 @@ export class Experiments { value.updatedAt = this.now(); if (await this.store.save(value, current.revision, this.lease())) { this.changed(value.documentId); + if (value.childId) this.changed(value.childId); return value; } } @@ -128,40 +129,78 @@ export class Experiments { let parsed = parseResult(result); return this.mutate(id, value => { this.assertClaim(value, connectionId, generation); + // Completion must not fail on content, so the child document is proven here. + investigationDocument(value.id, value.brief, parsed); value.candidate = parsed; value.state = "publishing"; }); } - complete(id: string, connectionId: string, generation: number) { - return this.mutate(id, value => { + /** + * Promote the candidate and publish it as a child document in the same commit, so a ready card + * never links a child that is not there yet. + */ + async complete(id: string, connectionId: string, generation: number) { + for (let attempt = 0; attempt < 5; attempt++) { + let current = await this.store.get(id); + if (!current) fail("not-found"); if ( - value.state === "completed" && value.connectionId === connectionId - && value.generation === generation - ) return; - this.assertClaim(value, connectionId, generation); - if (!value.candidate) fail("missing-result"); - value.result = value.candidate; + current.state === "completed" && current.connectionId === connectionId + && current.generation === generation + ) return current; + this.assertClaim(current, connectionId, generation); + if (!current.candidate) fail("missing-result"); + let value = structuredClone(current); + let result = current.candidate; + value.result = result; delete value.candidate; - value.views = Object.fromEntries( - value.result.views.map(view => [view.key, initialState(view)]), - ); + value.views = Object.fromEntries(result.views.map(view => [view.key, initialState(view)])); value.state = "completed"; value.expiresAt = 0; - }); + value.revision++; + value.updatedAt = this.now(); + let document = investigationDocument(value.id, value.brief, result); + let saved = await this.store.publish(value, current.revision, { + title: document.title, + initial: await Plan.initial(document.source), + now: new Date(value.updatedAt), + }, this.lease()); + if (saved) { + this.changed(saved.documentId); + if (saved.childId) this.changed(saved.childId); + return saved; + } + } + fail("revision-conflict"); } stop( id: string, state: Extract, progress = "", + /** Checked against the current record inside the write, so a stale read cannot stop work. */ + allowed?: (value: Investigation) => void, ) { return this.mutate(id, value => { if (["completed", "cancelled", "failed", "interrupted"].includes(value.state)) return; + allowed?.(value); value.state = state; value.progress = progress.slice(0, 2000); value.expiresAt = 0; delete value.candidate; }); } + /** Return settled, unfinished work to its card under the same identity; Run starts it again. */ + retry(id: string) { + return this.mutate(id, value => { + if (value.state === "requested") return; + if (!["failed", "cancelled", "interrupted"].includes(value.state)) fail("invalid-state"); + value.state = "requested"; + value.progress = ""; + value.expiresAt = 0; + delete value.connectionId; + delete value.input; + delete value.candidate; + }); + } async recover(all = false) { for (let value of await this.store.active()) { if (all || value.state !== "queued" && value.expiresAt <= this.now()) { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 45d7e4504..e9ea002cd 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -249,6 +249,20 @@ function placeResearchReference( }); } +/** + * Put a Planner-proposed investigation's card in its open document. Closing waits for Chat + * while holding the document lock, so a host tool skips placement rather than wait behind it. + */ +async function placeInvestigationReference(channelId: string, id: string): Promise { + if (Rooms.get(channelId)?.closing) return false; + return withDocumentLock(channelId, async () => { + let active = Rooms.get(channelId)?.plan; + if (!active || Rooms.get(channelId)?.closing || active.persistence.closing) return false; + await Service.placeInvestigationReference(active, id); + return true; + }); +} + function scheduleResearchRecovery(deferred: number): void { if (deferred === 0 || researchRecoveryTimer || draining) return; researchRecoveryTimer = setTimeout(() => { @@ -386,10 +400,6 @@ function conversation( investigations: (await experiments!.service.store.list(room.id)).slice(0, 20).map( value => ({ id: value.id, brief: value.brief.slice(0, 400), state: value.state }), ), - workspaces: experiments!.connections.list(room.id).map(value => ({ - label: value.label, - owner: value.login, - })), }; }, async read(id, dataset, offset = 0) { @@ -436,6 +446,10 @@ function conversation( }; }, async propose(request) { + // Like Research, investigations start only in a top-level document. + if (room.parentChannelId) { + throw new Error("Investigations start only in a top-level document, not a child."); + } await experiments!.access( await hostedAuth.sessions.forUser(request.userId), room.id, @@ -447,10 +461,32 @@ function conversation( hash.slice(17, 20) }-${hash.slice(20, 32)}`; let value = await experiments!.service.create(room.id, request.userId, request.brief, id); + let placed: boolean | "locked"; + try { + placed = await placeInvestigationReference(room.id, value.id); + } catch (error) { + if (!(error instanceof Service.ImplementationActiveError)) throw error; + placed = "locked"; + } + if (placed === true) { + return { + id: value.id, + state: value.state, + message: "Its card is now in the document. A person selects Run on it to execute.", + }; + } + // A record without a card has nowhere to run from, so withdraw it. + let withdrawn = await experiments!.service.stop( + value.id, + "cancelled", + "Its card could not be placed.", + ).catch(() => value); return { id: value.id, - state: value.state, - message: "Open Investigations and authorize Run on my workspace to execute.", + state: withdrawn.state, + message: placed === "locked" + ? "A build locked the document before its card was placed, so the proposal was withdrawn. Propose it again after the build." + : "The document was not open to place its card, so the proposal was withdrawn. Propose it again while the document is open.", }; }, } @@ -1964,10 +2000,10 @@ registerMcpRoutes(router, hostedAuth, { }); implementations = registerImplementationRoutes(router, hostedAuth, { connections: experiments.connections, - busy: async id => - (await experiments!.service.store.list(id)).filter(item => - ["running", "publishing"].includes(item.state) - ).map(item => item.connectionId!), + busy: async connectionId => + (await experiments!.service.store.active()).some(item => + item.connectionId === connectionId && ["running", "publishing"].includes(item.state) + ), withPlan: (id, action) => withDocumentTransition(id, async () => { if (deletingChannels.has(id)) throw new Error("document is unavailable"); diff --git a/apps/server/src/plan/edit.test.ts b/apps/server/src/plan/edit.test.ts index 3e66f50cc..3256965b7 100644 --- a/apps/server/src/plan/edit.test.ts +++ b/apps/server/src/plan/edit.test.ts @@ -413,6 +413,27 @@ describe("refusing a batch", () => { expect(room.project(held.document)).toContain("Context."); }); + it("refuses to author, move, or remove an investigation card", async () => { + let card = ''; + let authored = edit.apply(subject, 1, [{ op: "insert", index: 0, source: `${card}\n` }]); + expect(authored).toMatchObject({ ok: false, reason: "invalid" }); + if (!authored.ok && authored.reason === "invalid") { + expect(authored.message).toContain("proposing an investigation"); + } + + let held = await plan(`# Title\n\n${card}\n\nAfter.\n`); + for ( + let operations of [ + [{ op: "delete", index: 1 }], + [{ op: "move", index: 1, to: 0 }], + [{ op: "replace_root", source: "# Replacement\n" }], + ] as edit.Operation[][] + ) { + expect(edit.apply(held, 1, operations)).toMatchObject({ ok: false, reason: "invalid" }); + } + expect(room.project(held.document)).toContain(card); + }); + it("protects inline research nested inside another Planner block", async () => { let held = await plan( '\n' diff --git a/apps/server/src/plan/edit.ts b/apps/server/src/plan/edit.ts index 650a2849d..3f90a03b3 100644 --- a/apps/server/src/plan/edit.ts +++ b/apps/server/src/plan/edit.ts @@ -239,7 +239,7 @@ export function apply(plan: Plan, revision: number, operations: Operation[]): Re }; } -const PROTECTED_PROJECTIONS = new Set(["Questionnaire", "Decision", "Research"]); +const PROTECTED_PROJECTIONS = new Set(["Questionnaire", "Decision", "Research", "Investigation"]); export function replace(plan: Plan, revision: number, nextSource: string): Result { if (revision !== plan.revision) { @@ -725,6 +725,7 @@ const RESERVED_COMPONENTS = new Set([ "Decision", "Note", "Research", + "Investigation", ]); /** Parse an operation's source as a run of top-level blocks. */ @@ -764,6 +765,8 @@ function identify(root: Root): string | undefined { if (RESERVED_COMPONENTS.has(spec.name)) { refused = spec.name === "Research" ? "`Research` is created by starting inline research, not by editing the plan." + : spec.name === "Investigation" + ? "`Investigation` is created by proposing an investigation, not by editing the plan." : spec.name === "Decision" || spec.name === "Note" ? `\`${spec.name}\` is written when the room accepts a comment, not by editing the plan.` : `\`${spec.name}\` is created by asking a question, not by editing the plan.`; @@ -817,7 +820,7 @@ function protects(index: number): string { } function protectsResearch(index: number): string { - return `block ${index} is inline research owned by its request record; ` + return `block ${index} is inline research or an investigation owned by its record; ` + "it cannot be edited, moved, or removed."; } @@ -844,7 +847,9 @@ function decision(node: RootContent): boolean { } function research(node: Nodes): boolean { - if (node.type === "mdxJsxFlowElement" && node.name === "Research") return true; + if ( + node.type === "mdxJsxFlowElement" && (node.name === "Research" || node.name === "Investigation") + ) return true; return "children" in node && node.children.some(child => research(child as Nodes)); } diff --git a/apps/server/src/plan/investigation-projection.test.ts b/apps/server/src/plan/investigation-projection.test.ts new file mode 100644 index 000000000..d35ba1728 --- /dev/null +++ b/apps/server/src/plan/investigation-projection.test.ts @@ -0,0 +1,87 @@ +/** + * An Investigation reference is an atomic decorator that only its record can + * authorize. A browser may add one for an existing record and remove one once + * its work has settled; the server's headless mirror must sync it like any node. + */ + +import { describe, expect, it } from "bun:test"; +import { $createInvestigationNode, InvestigationNode } from "@chopin/dialect"; +import { $getRoot, $nodesOfType } from "lexical"; +import * as Y from "yjs"; + +import { peer } from "../testing/peer"; +import * as room from "./room"; + +const ID = "2b7e0c4a-5f1d-4e2a-9c3b-7d8e9f0a1b2c"; + +async function connected(source: string) { + let document = await room.create(source); + room.mark(document); + let client = peer(); + Y.applyUpdate(client.doc, room.sync(document), "remote"); + await room.settle(); + return { document, client }; +} + +function edit(client: ReturnType, change: () => void) { + let before = Y.encodeStateVector(client.doc); + client.editor.update(change, { discrete: true }); + return Y.encodeStateAsUpdate(client.doc, before); +} + +describe("Investigation references", () => { + it("accept a browser insertion only when the record authorizes it", async () => { + let seen: string[] = []; + let { document, client } = await connected("# Title\n"); + let update = edit(client, () => $getRoot().append($createInvestigationNode(ID))); + let outcome = await room.apply(document, [update], async (id, action, type) => { + seen.push(`${type}:${action}:${id}`); + return true; + }); + expect(outcome).toMatchObject({ ok: true, researchProjections: [] }); + expect(seen).toEqual([`Investigation:add:${ID}`]); + expect(room.project(document)).toBe(`# Title\n\n\n`); + + let refused = await connected("# Title\n"); + let forged = edit(refused.client, () => $getRoot().append($createInvestigationNode(ID))); + expect(await room.apply(refused.document, [forged], async () => false)).toEqual({ + ok: false, + issues: ["protected-projection"], + }); + }); + + it("refuse removal while the record keeps the reference", async () => { + let { document, client } = await connected(`# Title\n\n\n`); + let update = edit(client, () => { + for (let node of $nodesOfType(InvestigationNode)) node.remove(); + }); + expect(await room.apply(document, [update], async () => false)).toEqual({ + ok: false, + issues: ["protected-projection"], + }); + }); + + it("round-trips through the server's headless Lexical and Yjs mirror", async () => { + let source = `# Title\n\nBefore.\n\n\n\nAfter.\n`; + let document = await room.create(source); + expect(room.project(document)).toBe(source); + let client = peer(); + Y.applyUpdate(client.doc, room.sync(document), "remote"); + await room.settle(); + let ids: string[] = []; + client.editor.getEditorState().read(() => { + ids = $nodesOfType(InvestigationNode).map(node => node.getId()); + }); + expect(ids).toEqual([ID]); + let restored = await room.restore( + document.epoch, + Y.encodeStateAsUpdate(document.doc), + room.project(document), + [], + ); + expect(room.project(restored)).toBe(source); + let inserted = room.insertInvestigation(restored, crypto.randomUUID()); + expect(inserted).toBeDefined(); + expect(room.insertInvestigation(restored, ID)).toBeUndefined(); + }); +}); diff --git a/apps/server/src/plan/projections.test.ts b/apps/server/src/plan/projections.test.ts index 7451095c8..754acd5b2 100644 --- a/apps/server/src/plan/projections.test.ts +++ b/apps/server/src/plan/projections.test.ts @@ -78,3 +78,15 @@ test("Research additions and removals require the exact IDs in the correct autho expect(protectProjections(before, after, new Set(["new"]), new Set(["old"]))) .toBeUndefined(); }); + +test("Investigation references follow the same authority sets, by kind", () => { + let before = nodes('\n\n'); + let after = nodes('\n\n'); + expect(newResearchProjections(before, after)).toEqual([]); + expect(newResearchProjections(before, after, "Investigation")).toEqual(["new"]); + expect(removedResearchProjections(before, after, "Investigation")).toEqual(["old"]); + expect(protectProjections(before, after)).toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(["new"]), new Set(["old"]))).toBeUndefined(); + expect(protectProjections(nodes(''), nodes(''))) + .toContain("cannot be altered"); +}); diff --git a/apps/server/src/plan/projections.ts b/apps/server/src/plan/projections.ts index 212e2aa45..2e2a45b3e 100644 --- a/apps/server/src/plan/projections.ts +++ b/apps/server/src/plan/projections.ts @@ -2,7 +2,11 @@ import { serialize } from "@chopin/dialect/serialize"; import type { RootContent } from "mdast"; -const PROTECTED = new Set(["Questionnaire", "Decision", "Research", "Experiment"]); +const PROTECTED = new Set(["Questionnaire", "Decision", "Research", "Investigation", "Experiment"]); + +/** Record-backed references a browser may add or remove once their record authorizes it. */ +export type Reference = "Research" | "Investigation"; +const REFERENCES = new Set(["Research", "Investigation"]); function collect(nodes: RootContent[]): Map | string { let found = new Map(); @@ -40,22 +44,30 @@ function collect(nodes: RootContent[]): Map - value.type === "Research" && !current.has(id) ? [id] : [] + value.type === type && !current.has(id) ? [id] : [] ); } /** Existing research references removed from a browser batch need terminal request authority. */ -export function removedResearchProjections(base: RootContent[], next: RootContent[]): string[] { +export function removedResearchProjections( + base: RootContent[], + next: RootContent[], + type: Reference = "Research", +): string[] { let current = collect(base); let proposed = collect(next); if (typeof current === "string" || typeof proposed === "string") return []; return [...current].flatMap(([id, value]) => - value.type === "Research" && !proposed.has(id) ? [id] : [] + value.type === type && !proposed.has(id) ? [id] : [] ); } @@ -73,7 +85,7 @@ export function protectProjections( for (let [id, value] of current) { let replacement = proposed.get(id); if (!replacement) { - if (value.type === "Research" && removableResearch.has(id)) continue; + if (REFERENCES.has(value.type) && removableResearch.has(id)) continue; return "Existing Questionnaire, Decision, and Research projections cannot be dropped."; } if (replacement.type !== value.type || replacement.source !== value.source) { @@ -82,7 +94,7 @@ export function protectProjections( } for (let id of proposed.keys()) { if (!current.has(id)) { - if (proposed.get(id)?.type === "Research" && allowedResearch.has(id)) continue; + if (REFERENCES.has(proposed.get(id)!.type) && allowedResearch.has(id)) continue; return "Questionnaire, Decision, and Research projections cannot be authored by rewriting the plan."; } } diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index b0d9bda92..7f58e88b5 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -20,6 +20,7 @@ import * as Y from "yjs"; import { $createDecisionNode, $createExperimentNode, + $createInvestigationNode, $createPlanNodes, $createResearchNode, $exportPlanTree, @@ -28,6 +29,7 @@ import { assertIntroducedUrls, ExperimentNode, exportPlan, + InvestigationNode, limits, parse, PlanParseError, @@ -58,6 +60,7 @@ import { $nodesOfType, } from "lexical"; +import type { Reference } from "./projections"; import type { ResearchProjectionChange } from "../storage/model"; import type { Binding, Provider } from "@lexical/yjs"; import type { LexicalEditor, LexicalNode } from "lexical"; @@ -356,7 +359,11 @@ export type Applied = export async function apply( target: Document, updates: Uint8Array[], - authorizeResearch?: (id: string, action: "add" | "remove") => Promise, + authorizeResearch?: ( + id: string, + action: "add" | "remove", + type: Reference, + ) => Promise, open = 0, ): Promise { if (updates.length === 0) return { ok: true, seq: target.seq, researchProjections: [] }; @@ -376,22 +383,24 @@ export async function apply( assertIntroducedUrls(before, after); let added = new Set(); let removed = new Set(); - for ( - let [action, ids, allowed] of [ - ["add", newResearchProjections(before, after), added], - ["remove", removedResearchProjections(before, after), removed], - ] as const - ) { - for (let id of ids) { - let authorized = false; - try { - authorized = !!authorizeResearch && await authorizeResearch(id, action); - } catch { - return { ok: false, issues: ["research-reference-unverified"] }; + for (let type of ["Research", "Investigation"] as const) { + for ( + let [action, ids, allowed] of [ + ["add", newResearchProjections(before, after, type), added], + ["remove", removedResearchProjections(before, after, type), removed], + ] as const + ) { + for (let id of ids) { + let authorized = false; + try { + authorized = !!authorizeResearch && await authorizeResearch(id, action, type); + } catch { + return { ok: false, issues: ["research-reference-unverified"] }; + } + if (!authorized) return { ok: false, issues: ["protected-projection"] }; + allowed.add(id); + if (type === "Research") researchProjections.push({ id, action }); } - if (!authorized) return { ok: false, issues: ["protected-projection"] }; - allowed.add(id); - researchProjections.push({ id, action }); } } if (protectProjections(before, after, added, removed)) { @@ -1262,6 +1271,14 @@ export function insertResearch(target: Document, id: string): Mutation | undefin }); } +export function insertInvestigation(target: Document, id: string): Mutation | undefined { + return mutate(target, () => { + if ($nodesOfType(InvestigationNode).some(node => node.getId() === id)) return false; + $getRoot().append($createInvestigationNode(id)); + return true; + }); +} + export function placeExperiment( target: Document, experiment: string, diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 2a3a04084..d092885f0 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1582,7 +1582,16 @@ async function commit(plan: Plan): Promise { let outcome = await room.apply( plan.document, batch.map(item => item.update), - async (id, action) => { + async (id, action, type) => { + if (type === "Investigation") { + let value = await plan.persistence.storage.experiments.get(id); + if (value && value.documentId !== plan.id) return false; + // A child document holds no investigations of its own. + if (action === "add") return !!value && !plan.parentChannelId; + // Only stopped or failed work leaves the document: a ready card is withdrawn first, + // and running or completed work keeps its card. + return !value || ["failed", "cancelled", "interrupted"].includes(value.state); + } let request = await plan.persistence.storage.research.get(plan.id, id); let initial = request?.turns.find(turn => turn.kind === "initial"); let jobId = initial?.answerJobId ?? initial?.evidenceJobId; @@ -1694,7 +1703,7 @@ export async function publish( } } -class ImplementationActiveError extends Error { +export class ImplementationActiveError extends Error { constructor() { super("implementation is active"); } @@ -2149,6 +2158,28 @@ export function placeResearchReference(plan: Plan, id: string): Promise<"placed" }); } +/** Append an investigation's card to the document; a later edit can move it. */ +export function placeInvestigationReference(plan: Plan, id: string): Promise { + return exclusive(plan, async () => { + if (implementationActive(plan)) throw new ImplementationActiveError(); + let document = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + document.seq = plan.document.seq; + try { + let mutation = room.insertInvestigation(document, id); + if (mutation) { + await publishStaged(plan, plan.server, plan.id, { ...plan, document }, mutation); + } + } finally { + document.doc.destroy(); + } + }); +} + export function placeExperimentReference( plan: Plan, experiment: string, diff --git a/apps/server/src/research/publication.ts b/apps/server/src/research/publication.ts index 639331464..3f248b4f5 100644 --- a/apps/server/src/research/publication.ts +++ b/apps/server/src/research/publication.ts @@ -7,7 +7,7 @@ import type { StorageAdapter } from "../storage/port"; const MARKDOWN_PUNCTUATION = new Set(`!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`); -function reportText(value: string): string { +export function reportText(value: string): string { return [...value.trim().replace(/\s+/g, " ")] .map(point => MARKDOWN_PUNCTUATION.has(point) ? `\\${point}` : point) .join(""); diff --git a/apps/server/src/storage/experiment-contract.ts b/apps/server/src/storage/experiment-contract.ts index ae8e5e81b..d0f7c84ab 100644 --- a/apps/server/src/storage/experiment-contract.ts +++ b/apps/server/src/storage/experiment-contract.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { performance } from "@chopin/experiment/fixtures"; import { Experiments } from "../experiments/service"; -import { openedStorage, userAndChannel } from "./contract-support"; +import { contractId, openedStorage, userAndChannel } from "./contract-support"; import type { StorageFactory } from "./contract-support"; export function experimentContract(name: string, factory: StorageFactory) { @@ -27,5 +28,91 @@ export function experimentContract(name: string, factory: StorageFactory) { await storage.close(); } }); + test("publishes a completed investigation as one child document, idempotently", async () => { + let storage = await openedStorage(factory); + try { + let { channelId, userId, lease } = await userAndChannel(storage); + let service = new Experiments(storage.experiments, () => lease); + let created = await service.create(channelId, userId, "Does caching help startup?"); + let publishing = { + ...created, + revision: 1, + state: "publishing" as const, + connectionId: "connection", + generation: 1, + expiresAt: Date.now() + 60_000, + candidate: performance, + }; + expect(await storage.experiments.save(publishing, 0, lease)).toBe(true); + let [first, second] = await Promise.all([ + service.complete(created.id, "connection", 1), + service.complete(created.id, "connection", 1), + ]); + expect(first.state).toBe("completed"); + expect(first.childId).toBeDefined(); + expect(second.childId).toBe(first.childId); + expect(first.result).toEqual(performance); + expect(first.candidate).toBeUndefined(); + let child = await storage.channels.get(first.childId!); + expect(child).toMatchObject({ parentChannelId: channelId, title: "Startup comparison" }); + let loaded = await storage.collaboration.load(first.childId!, new Date()); + expect(loaded?.snapshot?.source).toContain(`experiment="${created.id}"`); + expect((await storage.experiments.published(first.childId!))?.id).toBe(created.id); + expect(await storage.experiments.published(channelId)).toBeUndefined(); + let stored = await storage.experiments.get(created.id); + expect( + await storage.experiments.publish( + { ...stored!, revision: stored!.revision + 1 }, + stored!.revision, + { title: "Again", initial: loaded?.snapshot as never, now: new Date() }, + lease, + ), + ).toEqual(stored!); + await storage.channels.archive({ id: first.childId!, now: new Date() }); + await expect(storage.channels.delete(first.childId!)).rejects.toMatchObject({ + failure: "conflict", + message: expect.stringContaining("published by an investigation"), + }); + expect(await storage.channels.get(first.childId!)).toBeDefined(); + } finally { + await storage.close(); + } + }); + test("refuses to publish an investigation held by a child document", async () => { + let storage = await openedStorage(factory); + try { + let { channelId, repositoryId, userId, lease } = await userAndChannel(storage); + let childId = contractId("channel"); + await storage.channels.create({ + id: childId, + repositoryId, + repositoryOwner: "octo", + repositoryName: "repo", + parentChannelId: channelId, + title: "Child", + createdBy: userId, + now: new Date("2026-01-02T03:04:05.000Z"), + }); + let service = new Experiments(storage.experiments, () => lease); + let created = await service.create(childId, userId, "Does caching help startup?"); + let completed = { + ...created, + revision: 1, + state: "completed" as const, + result: performance, + }; + await expect( + storage.experiments.publish( + completed, + 0, + { title: "Nested", initial: undefined as never, now: new Date() }, + lease, + ), + ).rejects.toMatchObject({ failure: "conflict" }); + expect((await storage.experiments.get(created.id))?.childId).toBeUndefined(); + } finally { + await storage.close(); + } + }); }); } diff --git a/apps/server/src/storage/experiments.ts b/apps/server/src/storage/experiments.ts index 618f78728..34b9aa626 100644 --- a/apps/server/src/storage/experiments.ts +++ b/apps/server/src/storage/experiments.ts @@ -1,10 +1,29 @@ import type { Investigation } from "@chopin/experiment/records"; -import type { Lease } from "./model"; +import type { InitialChannel, Lease } from "./model"; + +/** The child document a completed investigation is published as. */ +export type InvestigationChildInput = { + title: string; + initial: InitialChannel; + now: Date; +}; export interface ExperimentStore { get(id: string): Promise; list(documentId: string): Promise; active(): Promise; + /** The investigation a child document was published from. */ + published(childId: string): Promise; /** Revision-checked, writer-fenced replacement. False means the revision changed. */ save(value: Investigation, expected: number | undefined, lease: Lease): Promise; + /** + * Save a completed investigation and create its child document in one fenced commit. A record + * that already links a child is returned unchanged; false means the revision changed. + */ + publish( + value: Investigation, + expected: number, + child: InvestigationChildInput, + lease: Lease, + ): Promise; } diff --git a/apps/server/src/storage/memory/adapter.ts b/apps/server/src/storage/memory/adapter.ts index fefa08928..0ba5f276e 100644 --- a/apps/server/src/storage/memory/adapter.ts +++ b/apps/server/src/storage/memory/adapter.ts @@ -132,6 +132,17 @@ export class MemoryStorage implements StorageAdapter { exists: id => this.#channels.has(id), active: id => !this.#channels.get(id)?.archivedAt, fence: lease => this.#assertLease(lease), + channel: id => this.#channels.get(id), + createChannel: input => + this.#createChannel({ + ...input, + title: availableChannelTitle( + input.title, + [...this.#channels.values()] + .filter(channel => channel.repositoryId === input.repositoryId) + .map(channel => channel.title), + ), + }), }); #users = new Map(); @@ -549,6 +560,9 @@ export class MemoryStorage implements StorageAdapter { if (this.#research.referencesChannel(id)) { throw conflict(`channel ${id} is still published by a research workspace`); } + if (this.experiments.referencesChannel(id)) { + throw conflict(`channel ${id} is still published by an investigation`); + } this.#channels.delete(id); await this.#research.deleteChannel(id); diff --git a/apps/server/src/storage/memory/experiments.ts b/apps/server/src/storage/memory/experiments.ts index 5a0a77d53..bdef6db93 100644 --- a/apps/server/src/storage/memory/experiments.ts +++ b/apps/server/src/storage/memory/experiments.ts @@ -1,7 +1,8 @@ import { investigationSchema } from "@chopin/experiment/records"; import type { Investigation } from "@chopin/experiment/records"; -import type { ExperimentStore } from "../experiments"; -import type { Lease } from "../model"; +import { deterministicChannelId } from "../../channels/id"; +import type { ExperimentStore, InvestigationChildInput } from "../experiments"; +import type { ChannelRecord, CreateChannel, Lease } from "../model"; import { conflict, missing } from "../errors"; export class MemoryExperimentStore implements ExperimentStore { @@ -11,6 +12,8 @@ export class MemoryExperimentStore implements ExperimentStore { exists: (id: string) => boolean; active: (id: string) => boolean; fence: (lease: Lease) => void; + channel: (id: string) => ChannelRecord | undefined; + createChannel: (input: CreateChannel) => ChannelRecord; }, ) {} async get(id: string) { @@ -22,6 +25,13 @@ export class MemoryExperimentStore implements ExperimentStore { return [...this.#records.values()].filter(value => value.documentId === documentId) .sort((a, b) => b.createdAt - a.createdAt).slice(0, 100).map(value => structuredClone(value)); } + async published(childId: string) { + let value = [...this.#records.values()].find(item => item.childId === childId); + return value && this.options.exists(value.documentId) ? structuredClone(value) : undefined; + } + referencesChannel(channelId: string): boolean { + return [...this.#records.values()].some(value => value.childId === channelId); + } async active() { return [...this.#records.values()].filter(value => this.options.exists(value.documentId) @@ -45,4 +55,41 @@ export class MemoryExperimentStore implements ExperimentStore { this.#records.set(value.id, structuredClone(investigationSchema.parse(value))); return true; } + async publish( + value: Investigation, + expected: number, + child: InvestigationChildInput, + lease: Lease, + ): Promise { + if (value.revision !== expected + 1 || value.state !== "completed" || !value.result) { + throw conflict("invalid experiment publication"); + } + this.options.fence(lease); + let channel = this.options.channel(value.documentId); + if (!channel) throw missing("experiment document not found"); + let current = this.#records.get(value.id); + if (!current || current.documentId !== value.documentId) throw missing("experiment not found"); + if (current.childId) return structuredClone(current); + if (current.revision !== expected) return false; + if (channel.archivedAt) throw conflict("experiment document archived"); + // A child document never holds investigations, so it never publishes one either. + if (channel.parentChannelId) throw conflict("experiment document is a child"); + let saved = investigationSchema.parse({ + ...value, + childId: deterministicChannelId(channel.repositoryId, `investigation:${value.id}`), + }); + this.options.createChannel({ + id: saved.childId!, + repositoryId: channel.repositoryId, + repositoryOwner: channel.repositoryOwner, + repositoryName: channel.repositoryName, + parentChannelId: value.documentId, + title: child.title, + createdBy: value.requester, + now: child.now, + initial: child.initial, + }); + this.#records.set(saved.id, structuredClone(saved)); + return saved; + } } diff --git a/apps/server/src/storage/postgres/adapter.test.ts b/apps/server/src/storage/postgres/adapter.test.ts index dd58be2df..2e93e9317 100644 --- a/apps/server/src/storage/postgres/adapter.test.ts +++ b/apps/server/src/storage/postgres/adapter.test.ts @@ -1006,6 +1006,7 @@ if (url) { "017_experiments", "017_unanswered_decisions", "018_plan_images", + "019_investigation_children", ]); expect(await sql<{ table: string | null }[]>`SELECT to_regclass('channel_slugs') AS table`) .toEqual([ diff --git a/apps/server/src/storage/postgres/adapter.ts b/apps/server/src/storage/postgres/adapter.ts index b62e2a851..c3ce2c186 100644 --- a/apps/server/src/storage/postgres/adapter.ts +++ b/apps/server/src/storage/postgres/adapter.ts @@ -523,6 +523,7 @@ export class PostgresStorage implements StorageAdapter { this.experiments = new PostgresExperimentStore( this.#sql, (sql, lease) => this.#assertLease(sql, lease), + (transaction, input) => this.#createAvailableChannelInTransaction(transaction, input), ); this.navigation = new PostgresNavigationStore( this.#sql, @@ -966,6 +967,10 @@ export class PostgresStorage implements StorageAdapter { if (publication) { throw conflict(`channel ${id} is still published by a research workspace`); } + let [investigation] = await transaction<{ id: string }[]>` + SELECT id FROM experiments WHERE child_channel_id = ${id} LIMIT 1 + `; + if (investigation) throw conflict(`channel ${id} is still published by an investigation`); let deleted = await transaction<{ id: string }[]>` DELETE FROM channels WHERE id = ${id} RETURNING id `; diff --git a/apps/server/src/storage/postgres/experiments.ts b/apps/server/src/storage/postgres/experiments.ts index 303305589..7e1f1333c 100644 --- a/apps/server/src/storage/postgres/experiments.ts +++ b/apps/server/src/storage/postgres/experiments.ts @@ -1,8 +1,9 @@ import { investigationSchema } from "@chopin/experiment/records"; import type { Investigation } from "@chopin/experiment/records"; import type { SQL, TransactionSQL } from "bun"; -import type { ExperimentStore } from "../experiments"; -import type { Lease } from "../model"; +import { deterministicChannelId } from "../../channels/id"; +import type { ExperimentStore, InvestigationChildInput } from "../experiments"; +import type { ChannelRecord, CreateChannel, Lease } from "../model"; import { conflict, missing } from "../errors"; function decode(value: unknown): Investigation { @@ -13,6 +14,7 @@ export class PostgresExperimentStore implements ExperimentStore { constructor( private sql: SQL, private fence: (sql: TransactionSQL, lease: Lease) => Promise, + private createChannel: (sql: TransactionSQL, input: CreateChannel) => Promise, ) {} async get(id: string) { let [row] = await this.sql< @@ -26,6 +28,14 @@ export class PostgresExperimentStore implements ExperimentStore { >`SELECT payload FROM experiments WHERE channel_id = ${documentId} ORDER BY created_at DESC LIMIT 100`; return rows.map(row => decode(row.payload)); } + async published(childId: string) { + let [row] = await this.sql< + { payload: unknown }[] + >`SELECT experiments.payload FROM experiments + JOIN channels ON channels.id = experiments.channel_id + WHERE experiments.child_channel_id = ${childId}`; + return row ? decode(row.payload) : undefined; + } async active() { let rows = await this.sql< { payload: unknown }[] @@ -58,4 +68,55 @@ export class PostgresExperimentStore implements ExperimentStore { return rows.length === 1; }) as boolean; } + async publish( + value: Investigation, + expected: number, + child: InvestigationChildInput, + lease: Lease, + ): Promise { + if (value.revision !== expected + 1 || value.state !== "completed" || !value.result) { + throw conflict("invalid experiment publication"); + } + return await this.sql.begin(async sql => { + await this.fence(sql, lease); + let [channel] = await sql<{ + repositoryId: string; + repositoryOwner: string; + repositoryName: string; + parentChannelId: string | null; + archivedAt: Date | null; + }[]>`SELECT repository_id AS "repositoryId", repository_owner AS "repositoryOwner", + repository_name AS "repositoryName", parent_channel_id AS "parentChannelId", + archived_at AS "archivedAt" + FROM channels WHERE id = ${value.documentId} FOR UPDATE`; + if (!channel) throw missing("experiment document not found"); + let [row] = await sql<{ revision: string | number; payload: unknown }[]>` + SELECT revision, payload FROM experiments + WHERE id = ${value.id} AND channel_id = ${value.documentId} FOR UPDATE`; + if (!row) throw missing("experiment not found"); + let current = decode(row.payload); + if (current.childId) return current; + if (Number(row.revision) !== expected) return false; + if (channel.archivedAt) throw conflict("experiment document archived"); + // A child document never holds investigations, so it never publishes one either. + if (channel.parentChannelId) throw conflict("experiment document is a child"); + let childId = deterministicChannelId(channel.repositoryId, `investigation:${value.id}`); + await this.createChannel(sql, { + id: childId, + repositoryId: channel.repositoryId, + repositoryOwner: channel.repositoryOwner, + repositoryName: channel.repositoryName, + parentChannelId: value.documentId, + title: child.title, + createdBy: value.requester, + now: child.now, + initial: child.initial, + }); + let saved = investigationSchema.parse({ ...value, childId }); + await sql`UPDATE experiments SET revision = ${saved.revision}, state = ${saved.state}, + child_channel_id = ${childId}, payload = ${JSON.stringify(saved)}::jsonb + WHERE id = ${saved.id}`; + return saved; + }) as Investigation | false; + } } diff --git a/apps/server/src/storage/postgres/migrations.ts b/apps/server/src/storage/postgres/migrations.ts index 4b893192d..d8d5b9e0d 100644 --- a/apps/server/src/storage/postgres/migrations.ts +++ b/apps/server/src/storage/postgres/migrations.ts @@ -76,6 +76,9 @@ const MIGRATIONS = [{ }, { id: "018_plan_images", path: join(import.meta.dir, "migrations/018_plan_images.sql"), +}, { + id: "019_investigation_children", + path: join(import.meta.dir, "migrations/019_investigation_children.sql"), }] satisfies Migration[]; /** Navigation shipped as 002 before document slugs claimed that number on main. */ diff --git a/apps/server/src/storage/postgres/migrations/019_investigation_children.sql b/apps/server/src/storage/postgres/migrations/019_investigation_children.sql new file mode 100644 index 000000000..8ae0a472a --- /dev/null +++ b/apps/server/src/storage/postgres/migrations/019_investigation_children.sql @@ -0,0 +1,3 @@ +ALTER TABLE experiments + ADD COLUMN child_channel_id text UNIQUE + REFERENCES channels(id) ON DELETE RESTRICT; diff --git a/apps/server/src/tasks/connector.ts b/apps/server/src/tasks/connector.ts index 78e5b2d09..b5487d418 100644 --- a/apps/server/src/tasks/connector.ts +++ b/apps/server/src/tasks/connector.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { Connection, Connections, Grant } from "../experiments/connections"; +import { ExperimentError } from "@chopin/experiment"; import { fail } from "../experiments/service"; import { LIFECYCLE_TOOLS, lifecycleCall } from "../mcp/lifecycle"; import { exclusive, source } from "../plan/service"; @@ -44,21 +45,97 @@ export function implementationSchemas( }; } +/** + * Next steps for a lifecycle refusal a coding agent can fix. `retry` marks refusals that a parallel + * call in the same batch may still resolve, so the server retries them before answering. + */ +const REFUSALS: Record> = { + start_task: { + dependency: { + retry: true, + advice: + "A task this one depends on is not complete. Finish it with report_pr then complete_task, and retry start_task with the same idempotencyKey.", + }, + "task-state": { + advice: + "The task is already in progress or completed. Continue with report_pr and complete_task.", + }, + }, + block_task: { + "task-state": { + advice: + "The task is already blocked or completed. Call start_task to resume a blocked task before blocking it again.", + }, + }, + report_pr: { + "task-state": { + retry: true, + advice: + "The task has not started. Call start_task for it, then retry report_pr with the same idempotencyKey.", + }, + "pull-request": { + advice: + "report_pr needs the pull request's full GitHub URL and a state of open, merged or closed. Fix the arguments and retry.", + }, + }, + complete_task: { + "already-complete": { + advice: + "The task is already complete. Nothing more to report for it; move on to the next task.", + }, + "pull-request": { + retry: true, + advice: + "The task has no pull request yet. Call report_pr for it, wait for that call to finish, then retry complete_task with the same idempotencyKey.", + }, + "task-state": { + retry: true, + advice: + "The task is not in progress. Call start_task (and report_pr) for it, then retry complete_task with the same idempotencyKey.", + }, + }, +}; + export function implementationConnector( withPlan: WithPlan, connections: Connections, documentUrl: (id: string) => Promise, documentExists: (id: string) => Promise, + options: { retryMs?: number; retries?: number } = {}, ) { let tracked = new Set(); - let pending = async (connection: Connection) => - withPlan(connection.documentId, async plan => { - let build = plan.builds.at(-1); - return build?.connectionId === connection.id && build.user === connection.owner - ? build - : undefined; + // Lifecycle calls of one run apply in arrival order, one at a time. + let chains = new Map>(); + let serial = (key: string, action: () => Promise): Promise => { + let operation = (chains.get(key) ?? Promise.resolve()).then(action, action); + let settled = operation.catch(() => {}); + chains.set(key, settled); + void settled.then(() => { + if (chains.get(key) === settled) chains.delete(key); }); - return { + return operation; + }; + let pending = async (connection: Connection) => { + let documentId = connections.assigned(connection.id); + if (!documentId) return undefined; + try { + let build = await documentExists(documentId) + ? await withPlan(documentId, async plan => { + let build = plan.builds.at(-1); + return build?.connectionId === connection.id && build.user === connection.owner + ? build + : undefined; + }) + : undefined; + if (!build || !["queued", "starting", "running"].includes(build.state)) { + connections.release(connection.id, documentId); + } + return build; + } catch { + return undefined; + } + }; + let api = { track(id: string) { tracked.add(id); }, @@ -72,9 +149,41 @@ export function implementationConnector( let build = await pending(connection); return !!build && ["queued", "starting", "running"].includes(build.state); }, + /** + * Agents often send start_task, report_pr and complete_task in one parallel batch. Serializing + * them per run and briefly retrying order-dependent refusals lets the batch land in order. + */ async call(connection: Connection, grant: Grant, name: string, args: Record) { - tracked.add(connection.documentId); - return withPlan(connection.documentId, async plan => { + let run = grant.run; + if (!run || !LIFECYCLE_TOOLS.some(tool => tool.name === name)) { + return api.invoke(connection, grant, name, args); + } + let retries = options.retries ?? 4; + for (let attempt = 0;; attempt++) { + try { + return await serial(run.id, () => api.invoke(connection, grant, name, args)); + } catch (error) { + let refusal = error instanceof ExperimentError ? REFUSALS[name]?.[error.code] : undefined; + if (!refusal) throw error; + if (refusal.retry && attempt < retries) { + await new Promise(resolve => setTimeout(resolve, options.retryMs ?? 250)); + continue; + } + fail((error as ExperimentError).code, refusal.advice); + } + } + }, + /** One connector call, unserialized. */ + async invoke( + connection: Connection, + grant: Grant, + name: string, + args: Record, + ) { + let documentId = grant.run?.documentId ?? connections.assigned(connection.id); + if (!documentId) fail("build-forbidden"); + tracked.add(documentId); + return withPlan(documentId, async plan => { let build = plan.builds.at(-1); if ( !build || build.connectionId !== connection.id || build.user !== connection.owner @@ -118,7 +227,7 @@ export function implementationConnector( build: picked, documentId: plan.id, source: { ...picked.checkout, repositoryId: picked.repositoryId }, - runToken: connections.runToken(connection.id, picked.id, 1, "implementation"), + runToken: connections.runToken(connection.id, plan.id, picked.id, 1, "implementation"), }; } if (!["starting", "running"].includes(build.state) || build.expiresAt <= Date.now()) { @@ -170,6 +279,7 @@ export function implementationConnector( await withPlan(id, async plan => { let build = plan.builds.at(-1); if (!build || !["queued", "starting", "running"].includes(build.state)) { + if (build) connections.release(build.connectionId, id); tracked.delete(id); return; } @@ -178,11 +288,13 @@ export function implementationConnector( state: "failed", error: "Workspace disconnected or agent stopped responding. No automatic replay.", }); + connections.release(build.connectionId, id); tracked.delete(id); } }); } }, }; + return api; } export type ImplementationConnector = ReturnType; diff --git a/apps/server/src/tasks/lifecycle.test.ts b/apps/server/src/tasks/lifecycle.test.ts index 415ec8a86..eef024745 100644 --- a/apps/server/src/tasks/lifecycle.test.ts +++ b/apps/server/src/tasks/lifecycle.test.ts @@ -719,6 +719,27 @@ describe("implementation task lifecycle", () => { }); }); + it("starts a queued task implicitly when it is blocked before start_task", async () => { + let module = await lifecycle() as { + transition: (state: any, input: any) => any; + progressFor: (graph: Graph, lifecycle: unknown, execution?: Run) => any; + }; + let state: any = { graph, execution, lifecycle: { history: [] } }; + let result = module.transition(state, { + kind: "block", + runId: execution.id, + taskId: "delivery", + reason: "Needs a product decision.", + idempotencyKey: "block-delivery", + }); + expect(result.kind).toBe("accepted"); + expect(module.progressFor(graph, result.state.lifecycle, execution).tasks[1]).toEqual({ + id: "delivery", + state: "blocked", + blocker: "Needs a product decision.", + }); + }); + it("preserves revision request as an unsuccessful release", async () => { let module = await lifecycle() as { transition: (state: any, input: any) => any; diff --git a/apps/server/src/tasks/lifecycle.ts b/apps/server/src/tasks/lifecycle.ts index d191c5ad7..3d2ab5561 100644 --- a/apps/server/src/tasks/lifecycle.ts +++ b/apps/server/src/tasks/lifecycle.ts @@ -312,13 +312,14 @@ function reduceEvent(state: ReducerState, stored: ProgressEvent): Reduction { }; break; case "block": - if (item.state !== "in_progress") return refused("task-state"); + // A blocker found before start_task implicitly starts the task, so it is never lost. + if (item.state !== "in_progress" && item.state !== "queued") return refused("task-state"); if (!text(stored.reason)) return refused("reason"); next = { id: item.id, state: "blocked", blocker: stored.reason, - ...(item.pullRequest ? { pullRequest: item.pullRequest } : {}), + ...(item.state !== "queued" && item.pullRequest ? { pullRequest: item.pullRequest } : {}), }; break; case "report_pr": { @@ -339,6 +340,8 @@ function reduceEvent(state: ReducerState, stored: ProgressEvent): Reduction { break; } case "complete": + // A repeated complete_task under a new key is not order-dependent; say so plainly. + if (item.state === "completed") return refused("already-complete"); if (item.state !== "in_progress") return refused("task-state"); if (!item.pullRequest) return refused("pull-request"); if (!text(stored.summary)) return refused("summary"); diff --git a/apps/server/src/tasks/routes.test.ts b/apps/server/src/tasks/routes.test.ts index d4455c029..04e0c3cdf 100644 --- a/apps/server/src/tasks/routes.test.ts +++ b/apps/server/src/tasks/routes.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { Sessions } from "../auth/session"; import { Admission } from "../auth/admission"; import { Router } from "../http/router"; -import { openPlan } from "../testing/plan"; +import { openPlan, openSiblingPlan } from "../testing/plan"; import * as Plan from "../plan/service"; import { registerExperimentRoutes } from "../experiments/routes"; import { implementationGraphs } from "./plan-graphs"; @@ -11,6 +11,24 @@ import type { GitHub } from "../github/client"; import type { HostedAuth } from "../auth/routes"; let checkout = { repository: "owner/repository", branch: "main", commit: "a".repeat(40) }; +async function prepare(plan: Plan.Plan) { + await implementationGraphs().revise(plan, { + planRevision: 0, + graphRevision: 0, + operations: [{ + op: "add", + task: { + id: "first", + title: "First", + context: "Tracer", + goal: "Launch", + acceptance: ["Starts", "Reports"], + dependsOn: [], + }, + }], + }); +} + async function setup() { let context = await openPlan("# Launcher plan\n"); let github = { @@ -77,27 +95,18 @@ async function setup() { }); implementations = registerImplementationRoutes(router, auth, { connections: experiments.connections, - busy: async id => - (await experiments.service.store.list(id)).filter(item => - ["running", "publishing"].includes(item.state) - ).map(item => item.connectionId!), - withPlan: async (_id, action) => action(context.plan), - }); - await implementationGraphs().revise(context.plan, { - planRevision: 0, - graphRevision: 0, - operations: [{ - op: "add", - task: { - id: "first", - title: "First", - context: "Tracer", - goal: "Launch", - acceptance: ["Starts", "Reports"], - dependsOn: [], - }, - }], + busy: async connectionId => + (await experiments.service.store.active()).some(item => + item.connectionId === connectionId && ["running", "publishing"].includes(item.state) + ), + withPlan: async (id, action) => { + let plan = plans.get(id); + if (!plan) throw new Error("document is unavailable"); + return action(plan); + }, }); + let plans = new Map([[context.plan.id, context.plan]]); + await prepare(context.plan); let path = `/api/channels/${context.plan.id}/implementation`; let call = async ( url: string, @@ -129,6 +138,7 @@ async function setup() { otherCookie: other.cookie, implementations, experiments, + plans, }; } @@ -137,9 +147,7 @@ async function paired(context: Awaited>, cookie?: strin await (await context.call("/api/connector/pairings", { ...checkout, label: "Laptop" })).json(); await context.call( `/api/connector/pairings/${pairing.id}/approve`, - { - documentId: context.plan.id, - }, + {}, undefined, undefined, cookie, @@ -168,10 +176,9 @@ test("a paired workspace claims a browser build once and reports through run-sco let context = await setup(); let connection = await paired(context); let snapshot = await (await context.call(context.path)).json(); - expect(snapshot.workspaces[0].label).toBe("Laptop"); + expect(snapshot.localAgent).toBe(true); + expect(JSON.stringify(snapshot)).not.toContain("Laptop"); let review = { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -230,27 +237,93 @@ test("a paired workspace claims a browser build once and reports through run-sco await Plan.close(context.plan); }); -test("build authorization rejects another owner's workspace and changed checkout", async () => { +test("a run's parallel lifecycle batch lands in order and refusals say what to do next", async () => { + let context = await setup(); + let connection = await paired(context); + let built = await (await context.call(context.path, { + planRevision: 0, + graphVersion: 1, + graphRevision: 1, + })).json(); + let claim = await tool(context, connection.token, "claim_implementation_build", { id: built.id }); + await tool(context, connection.token, "report_implementation_build", { + id: built.id, + state: "running", + session: "acp-session", + }); + let pr = "https://github.com/owner/repository/pull/7"; + // The agent sends the whole batch at once; complete_task arrives before report_pr. + let results = await Promise.all([ + tool(context, claim.runToken, "complete_task", { + taskId: "first", + summary: "Done.", + idempotencyKey: "complete-first", + }), + tool(context, claim.runToken, "report_pr", { + taskId: "first", + url: pr, + state: "open", + idempotencyKey: "pr-first", + }), + tool(context, claim.runToken, "start_task", { taskId: "first", idempotencyKey: "start-first" }), + ]); + expect(results.map(result => result.isError)).toEqual([undefined, undefined, undefined]); + let snapshot = await (await context.call(context.path)).json(); + expect(snapshot.lifecycle.activity.tasks[0]).toMatchObject({ state: "completed" }); + let refused = await tool(context, claim.runToken, "start_task", { + taskId: "first", + idempotencyKey: "start-again", + }); + expect(refused.content[0].text).toContain( + "task-state: The task is already in progress or completed", + ); + // Completing a completed task again is not order-dependent, so it answers at once. + let started = Date.now(); + let again = await tool(context, claim.runToken, "complete_task", { + taskId: "first", + summary: "Done again.", + idempotencyKey: "complete-again", + }); + expect(again.isError).toBe(true); + expect(again.content[0].text).toContain("already-complete: The task is already complete."); + expect(Date.now() - started).toBeLessThan(200); + // A refusal with no advice for the agent passes through unchanged. + let foreign = await tool(context, claim.runToken, "report_pr", { + taskId: "first", + url: "https://github.com/someone/else/pull/1", + state: "open", + idempotencyKey: "pr-foreign", + }); + expect(foreign.isError).toBe(true); + expect(foreign.content[0].text).toBe("repository: repository"); + await Plan.close(context.plan); +}); + +test("a build runs only on the clicker's own connection and records who started it", async () => { let context = await setup(); let foreign = await paired(context, context.otherCookie); let review = { - connectionId: foreign.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, }; - expect((await context.call(context.path, review)).status).toBe(409); - let own = await paired(context); - expect( - (await context.call(context.path, { - ...review, - connectionId: own.connection.id, - checkout: { ...checkout, commit: "b".repeat(40) }, - })).status, - ).toBe(409); + let refused = await context.call(context.path, review); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("no-workspace"); + expect((await (await context.call(context.path)).json()).localAgent).toBe(false); expect(context.plan.builds).toEqual([]); expect(context.plan.graph?.versions[0].state).toBe("draft"); + let own = await paired(context); + expect((await context.call(context.path, { ...review, planRevision: 1 })).status).toBe(409); + let built = await (await context.call(context.path, review)).json(); + expect(built.connectionId).toBe(own.connection.id); + expect(built.connectionId).not.toBe(foreign.connection.id); + expect(built.user).toBe("U_test"); + expect(built.checkout).toEqual(checkout); + expect( + (await tool(context, foreign.token, "claim_implementation_build", { id: built.id })).isError, + ) + .toBe(true); await Plan.close(context.plan); }); @@ -261,12 +334,8 @@ test("investigations and implementations cannot execute on the same workspace to id: crypto.randomUUID(), brief: "Inspect a fixture", })).json(); - await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, { - connectionId: connection.connection.id, - }); + await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, {}); let build = await (await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -291,8 +360,6 @@ test("disconnect reconciles a pending build without automatic replay", async () let context = await setup(); let connection = await paired(context); await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -309,8 +376,6 @@ test("a deleted document cannot block implementation recovery sweeps", async () let context = await setup(); let connection = await paired(context); await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -330,16 +395,12 @@ test("Build waits for an investigation-busy workspace before approving the graph id: crypto.randomUUID(), brief: "Inspect a fixture", })).json(); - await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, { - connectionId: connection.connection.id, - }); + await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, {}); await tool(context, connection.token, "claim_experiment", { id: investigation.id }); let snapshot = await (await context.call(context.path)).json(); - expect(snapshot.workspaces[0].available).toBe(false); + expect(snapshot.localAgent).toBe(true); expect( (await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -356,11 +417,9 @@ test("a committed build wakes the waiting connector and a failed pickup can be r let abort = new AbortController(); try { let woken = false; - let waiting = context.experiments.connections.wait(context.plan.id, abort.signal) + let waiting = context.experiments.connections.wait("R_test", abort.signal) .then(() => woken = true); let review = { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -390,8 +449,6 @@ test("returning a stopped implementation for changes durably unlocks it once", a let connection = await paired(context); try { let built = await (await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -429,3 +486,79 @@ test("returning a stopped implementation for changes durably unlocks it once", a await Plan.close(context.plan); } }); + +test("one connection builds two documents and a finished or deleted build never strands it", async () => { + let context = await setup(); + let second = await openSiblingPlan(context, "# Second plan\n"); + await prepare(second); + context.plans.set(second.id, second); + let secondPath = `/api/channels/${second.id}/implementation`; + let review = { planRevision: 0, graphVersion: 1, graphRevision: 1 }; + let connection = await paired(context); + try { + let first = await (await context.call(context.path, review)).json(); + expect((await context.call(secondPath, review)).status).toBe(409); + await tool(context, connection.token, "claim_implementation_build", { id: first.id }); + await tool(context, connection.token, "report_implementation_build", { + id: first.id, + state: "failed", + error: "Startup failed", + }); + let next = await (await context.call(secondPath, review)).json(); + expect(next.connectionId).toBe(connection.connection.id); + expect(second.builds.at(-1)?.id).toBe(next.id); + expect(context.plan.builds).toHaveLength(1); + expect(await tool(context, connection.token, "wait_for_work")).toEqual({ + id: next.id, + kind: "implementation", + }); + let claim = await tool(context, connection.token, "claim_implementation_build", { + id: next.id, + }); + expect(claim.documentId).toBe(second.id); + await tool(context, connection.token, "report_implementation_build", { + id: next.id, + state: "running", + session: "acp-session", + }); + expect((await tool(context, claim.runToken, "read_implementation")).document.id).toBe( + second.id, + ); + + // Deleting the document under a queued build must not break pickup or a new build. + await tool(context, connection.token, "report_implementation_build", { + id: next.id, + state: "stopped", + }); + let third = await openSiblingPlan(context, "# Third plan\n"); + await prepare(third); + context.plans.set(third.id, third); + let thirdPath = `/api/channels/${third.id}/implementation`; + let queued = await (await context.call(thirdPath, review)).json(); + expect(queued.state).toBe("queued"); + await Plan.close(third); + context.plans.delete(third.id); + await context.storage.channels.archive({ id: third.id, now: context.now }); + await context.storage.channels.delete(third.id); + let snapshot = await context.call(context.path); + expect(snapshot.status).toBe(200); + expect((await snapshot.json()).localAgent).toBe(true); + let investigation = await (await context.call( + `/api/documents/${context.plan.id}/experiments`, + { id: crypto.randomUUID(), brief: "Still reachable" }, + )).json(); + let run = await context.call( + `/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, + {}, + ); + expect(run.status).toBe(200); + expect((await run.json()).connectionId).toBe(connection.connection.id); + expect(await tool(context, connection.token, "wait_for_work")).toEqual({ + id: investigation.id, + kind: "experiment", + }); + } finally { + await Plan.close(second); + await Plan.close(context.plan); + } +}); diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index 103bb3fdf..d363b0d3c 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -1,6 +1,6 @@ import { documentPath } from "@chopin/protocol/document-url"; import { z } from "zod"; -import { checkoutSchema, queueBuild } from "./builds"; +import { queueBuild } from "./builds"; import { implementationLifecycle } from "./lifecycle"; import { implementationReadiness, reportImplementationLifecycle } from "./plan-graphs"; import { exclusive } from "../plan/service"; @@ -16,12 +16,11 @@ import type { ImplementationSnapshot } from "@chopin/protocol/implementation"; type Options = { withPlan(id: string, action: (plan: Plan) => Promise): Promise; connections: Connections; - busy?: (id: string) => Promise; + /** Whether an investigation is executing on a connection, in any document. */ + busy?: (connectionId: string) => Promise; }; let buildSchema = z.object({ retryOf: z.string().uuid().optional(), - connectionId: z.string().uuid(), - checkout: checkoutSchema, planRevision: z.number().int().nonnegative(), graphVersion: z.number().int().positive(), graphRevision: z.number().int().positive(), @@ -59,7 +58,7 @@ async function body(request: Request, schema: z.ZodType): Promise { } } -/** Browser approval dispatches through the existing owner-paired connector. */ +/** A browser build runs on the clicker's own connection for the document's repository. */ export function registerImplementationRoutes(router: Router, auth: HostedAuth, options: Options) { let connector = implementationConnector( options.withPlan, @@ -98,13 +97,32 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o } return { session, channel, repository }; } + /** + * The caller's live connections for a repository that can take a build for `id`: the one + * last used for this document first, then the most recently heard from. A build already + * waiting on this document does not make its connection busy, so a repeated request can + * return that build. + */ + async function available(owner: string, repositoryId: string, id: string) { + let free = []; + for (let connection of options.connections.candidates(repositoryId, owner, id)) { + let here = options.connections.assigned(connection.id) === id; + if (await options.busy?.(connection.id) || !here && await connector.busy(connection)) { + continue; + } + if (here) free.unshift(connection); + else free.push(connection); + } + return free; + } router.on( "GET", "/api/channels/:id/implementation", safe(async (request, _url, { id }) => { - let { session } = await browser(request, id); + let { session, channel } = await browser(request, id); connector.track(id); - let busy = await options.busy?.(id) ?? []; + // Cheap on purpose: whether one is free is decided when Build is pressed. + let localAgent = options.connections.list(channel.repositoryId, session.user.id).length > 0; let current = await options.withPlan(id, plan => exclusive(plan, async () => { let ready = implementationReadiness(plan, plan.revision); @@ -117,20 +135,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o return build.graphVersion === graph?.number && build.graphRevision === graph.revision; }), blockers: ready.ok ? [] : ready.blockers, - workspaces: options.connections.list(id).filter(item => item.owner === session.user.id) - .map(item => ({ - id: item.id, - label: item.label, - checkout: { - repository: item.source.repository, - commit: item.source.commit, - ...(item.source.branch ? { branch: item.source.branch } : {}), - }, - available: !busy.includes(item.id) && !plan.builds.some(build => - build.connectionId === item.id - && ["queued", "starting", "running"].includes(build.state) - ), - })), + localAgent, lifecycle: plan.graph ? implementationLifecycle({ graph: plan.graph, @@ -158,25 +163,21 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o throw new GitHubError("repository write access is required", 403); } let input = await body(request, buildSchema); - let connection = options.connections.get(input.connectionId); - if ( - !connection || connection.owner !== session.user.id || connection.documentId !== id - || connection.source.repositoryId !== channel.repositoryId - ) { - throw new GitHubError("workspace is unavailable", 409); - } - if ( - input.checkout.repository.toLowerCase() !== connection.source.repository.toLowerCase() - || input.checkout.branch !== connection.source.branch - || input.checkout.commit !== connection.source.commit - ) { - throw new GitHubError("workspace checkout changed; review again", 409); + if (!options.connections.list(channel.repositoryId, session.user.id).length) { + throw new GitHubError("no-workspace", 409); } + let [connection] = await available(session.user.id, channel.repositoryId, id); + if (!connection) throw new GitHubError("workspace is offline or busy", 409); + let checkout = { + repository: connection.source.repository, + commit: connection.source.commit, + ...(connection.source.branch ? { branch: connection.source.branch } : {}), + }; connector.track(id); return options.connections.locked(connection.id, async () => { if ( !options.connections.get(connection.id) - || (await options.busy?.(id) ?? []).includes(connection.id) + || !(await available(session.user.id, channel.repositoryId, id)).includes(connection) ) { throw new GitHubError("workspace is offline or busy", 409); } @@ -185,10 +186,13 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o let build = await queueBuild(plan, { ...input, user: session.user.id, + connectionId: connection.id, repositoryId: channel.repositoryId, - checkout: input.checkout, + checkout, }); - options.connections.wake(id); + options.connections.assign(connection.id, id); + options.connections.use(id, connection.id); + options.connections.wake(channel.repositoryId); return json(build); } catch (error) { throw new GitHubError(error instanceof Error ? error.message : "build refused", 409); diff --git a/apps/server/src/testing/plan.ts b/apps/server/src/testing/plan.ts index dd1c37829..1bec9207b 100644 --- a/apps/server/src/testing/plan.ts +++ b/apps/server/src/testing/plan.ts @@ -205,6 +205,25 @@ export async function checkpointSource( document.doc.destroy(); } +/** Open another document in the same repository and storage as `context`. */ +export async function openSiblingPlan( + context: Awaited>, + source = "", + state: SeedState = {}, +) { + let channel = await context.storage.channels.create({ + repositoryId: "R_test", + repositoryOwner: "owner", + repositoryName: "repository", + createdBy: "U_test", + now: context.now, + id: crypto.randomUUID(), + title: `Sibling ${crypto.randomUUID()}`, + }); + await checkpointSource(context.storage, context.lease, channel.id, source, state, context.now); + return Service.open(channel.id, context.backend, context.server); +} + export async function openPlan(source = "", state: SeedState = {}) { let now = new Date("2026-08-13T12:00:00.000Z"); let storage = new MemoryStorage(); diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index fe6dc30af..0144a36d5 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -36,7 +36,7 @@ function snapshot(change: Partial = {}): ImplementationS }], }, }, - workspaces: [], + localAgent: false, blockers: [], lifecycle: { execution: { state: "idle" }, history: [] }, ...change, diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index b7b5ce5d3..c4b92ac31 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -42,7 +42,7 @@ function sentence(text: string): string { /** The server's refusals for a connection it cannot reach mean the local agent is not running. */ function offline(error: unknown): boolean { return error instanceof ApiError && error.status === 409 - && /workspace is (?:offline|unavailable)|offline or busy/.test(error.message); + && /^no-workspace$|offline or busy/.test(error.message); } export function BuildView( @@ -254,12 +254,8 @@ export function BuildView( let start = async () => { if (!snapshot?.graph || busy) return; - let workspace = snapshot.workspaces.find(item => item.available); setActionError(undefined); - if (!workspace) { - setNeedsAgent(true); - return; - } + // The server picks the clicker's own connection; a snapshot can predate connecting. setNeedsAgent(false); setBusy(true); try { @@ -269,8 +265,6 @@ export function BuildView( headers: { "content-type": "application/json" }, body: JSON.stringify({ ...(snapshot.build ? { retryOf: snapshot.build.id } : {}), - connectionId: workspace.id, - checkout: workspace.checkout, planRevision: snapshot.planRevision, graphVersion: snapshot.graph.number, graphRevision: snapshot.graph.revision, @@ -447,13 +441,14 @@ export function BuildView( {since && {since}} {action} - {needsAgent && (phase.kind === "review" || phase.kind === "failed") && ( + {(needsAgent || snapshot?.localAgent === false) + && (phase.kind === "review" || phase.kind === "failed") && (

Start your local agent to build:

{`CHOPIN_URL=${location.origin} bun run connector connect /path/to/project -- copilot --acp`} -

Open the link it prints, then press {primaryLabel} again.

+

Open the link it prints, then press {primaryLabel}.

)} {phase.kind === "stopped" && returning && ( diff --git a/apps/web/src/child-provenance.tsx b/apps/web/src/child-provenance.tsx index 9252e6c26..26c416fe3 100644 --- a/apps/web/src/child-provenance.tsx +++ b/apps/web/src/child-provenance.tsx @@ -2,42 +2,75 @@ import { useEffect, useState } from "react"; import { SparkleIcon } from "@chopin/icons"; import { UnfoldPanel, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; +import { investigationProvenance } from "./experiments/api"; import { researchProvenance } from "./research-requests"; -import type { Research } from "@chopin/protocol"; import type { ChildParent } from "./workspace-model"; +/** What a child needs to say where it came from, whichever card published it. */ +type Provenance = { + kind: "Research" | "Investigation"; + parentChannelId: string; + brief: string; + startedBy?: string; + detail: string; + /** The card in the parent that published this child. */ + card: string; +}; + +async function provenance(channelId: string, signal: AbortSignal): Promise { + let research = await researchProvenance(channelId, signal); + if (research) { + return { + kind: "Research", + parentChannelId: research.parentChannelId, + brief: research.brief, + startedBy: research.startedBy, + detail: research.sourceCount === 0 ? "No sources" : plural(research.sourceCount, "source"), + card: `[data-research-request="${CSS.escape(research.requestId)}"]`, + }; + } + let investigation = await investigationProvenance(channelId, signal); + if (!investigation) return undefined; + return { + kind: "Investigation", + parentChannelId: investigation.parentChannelId, + brief: investigation.brief, + startedBy: investigation.startedBy, + detail: investigation.viewCount === 0 ? "No views" : plural(investigation.viewCount, "view"), + card: `[data-investigation="${CSS.escape(investigation.investigationId)}"]`, + }; +} + function plural(count: number, noun: string): string { return `${count} ${noun}${count === 1 ? "" : "s"}`; } -/** A quiet line above a research child that names its parent, brief, and sources. */ +/** A quiet line above a research or investigation child that names its parent and brief. */ export function ChildProvenance({ channelId, parent }: { channelId: string; parent: ChildParent }) { - let [provenance, setProvenance] = useState(); + let [origin, setOrigin] = useState(); let brief = useUnfold(); let setBriefOpen = brief.setOpen; useEffect(() => { let controller = new AbortController(); - setProvenance(undefined); + setOrigin(undefined); setBriefOpen(false); - researchProvenance(channelId, controller.signal) + provenance(channelId, controller.signal) .then(value => { - if (!controller.signal.aborted) setProvenance(value); + if (!controller.signal.aborted) setOrigin(value); }) // Provenance is context, not content; the document stays usable without it. .catch(() => {}); return () => controller.abort(); }, [channelId, setBriefOpen]); - if (!provenance || provenance.parentChannelId !== parent.id) return null; - let requestId = provenance.requestId; + if (!origin || origin.parentChannelId !== parent.id) return null; + let card = origin.card; let returnToCard = () => { // The parent card may still be loading while the child closes. parent.onReturn({ get current() { return document.querySelector( - `[data-workspace-room="${CSS.escape(parent.id)}"] [data-research-request="${ - CSS.escape(requestId) - }"]`, + `[data-workspace-room="${CSS.escape(parent.id)}"] ${card}`, ); }, }); @@ -49,27 +82,22 @@ export function ChildProvenance({ channelId, parent }: { channelId: string; pare - Research from{" "} + {origin.kind} from{" "} - {provenance.startedBy && ( + {origin.startedBy && ( <> {" "} - · by {provenance.startedBy} + · by {origin.startedBy} - )}{" "} - - · {provenance.sourceCount === 0 - ? "No sources" - : plural(provenance.sourceCount, "source")} - + )} · {origin.detail} @@ -77,7 +105,7 @@ export function ChildProvenance({ channelId, parent }: { channelId: string; pare

-
{provenance.brief}
+
{origin.brief}
); diff --git a/apps/web/src/experiments/api.ts b/apps/web/src/experiments/api.ts index 1548dba5c..bb4dc28cb 100644 --- a/apps/web/src/experiments/api.ts +++ b/apps/web/src/experiments/api.ts @@ -1,3 +1,5 @@ +import type { InvestigationProvenance } from "@chopin/experiment/records"; + export async function experimentRequest( path: string, value?: unknown, @@ -13,3 +15,18 @@ export async function experimentRequest( if (!response.ok) throw new Error(result.error ?? "Investigation request failed"); return result; } + +/** Where an investigation child came from; undefined for any other document. */ +export async function investigationProvenance( + documentId: string, + signal?: AbortSignal, +): Promise { + let response = await fetch( + `/api/documents/${encodeURIComponent(documentId)}/investigation-provenance`, + { signal }, + ); + if (response.status === 404) return undefined; + let result = await response.json(); + if (!response.ok) throw new Error(result.error ?? "Investigation request failed"); + return result; +} diff --git a/apps/web/src/experiments/card.test.tsx b/apps/web/src/experiments/card.test.tsx index c38073738..4b5a921d4 100644 --- a/apps/web/src/experiments/card.test.tsx +++ b/apps/web/src/experiments/card.test.tsx @@ -4,10 +4,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { initialState } from "@chopin/experiment"; import { performance } from "../../../../packages/experiment/src/fixtures"; import type { PublishedInvestigation } from "@chopin/experiment/records"; -import { InvestigationCard } from "./card"; +import { InvestigationReference, removableInvestigation } from "./card"; import { ExperimentStore } from "./store"; -test("a build lock preserves shared result selections while blocking document mutations", () => { +function setup(state: PublishedInvestigation["state"]) { let store = new ExperimentStore("document"); let item: PublishedInvestigation = { id: "investigation", @@ -15,29 +15,46 @@ test("a build lock preserves shared result selections while blocking document mu revision: 1, brief: "Compare startup approaches", requester: "ana", - state: "completed", + state, generation: 1, expiresAt: 0, createdAt: 0, updatedAt: 0, progress: "", - result: performance, - views: Object.fromEntries(performance.views.map(view => [view.key, initialState(view)])), + views: {}, decisions: [], }; store.values.set(item.id, item); - let markup = renderToStaticMarkup(createElement(InvestigationCard, { + store.items = [{ ...item, decisionCount: 0 }]; + return { store, item }; +} + +function render(store: ExperimentStore, userId = "ana", locked = false) { + return renderToStaticMarkup(createElement(InvestigationReference, { store, - summary: { ...item, decisionCount: 0 }, - userId: "ana", + id: "investigation", + userId, canEdit: true, - locked: true, + locked, + onRemove() {}, + onOpen() {}, })); - expect(markup.match(/]*aria-label="Filter Workload"[^>]*>/)?.[0]) - .not.toContain("disabled"); - expect(markup).toMatch(/]*disabled=""[^>]*>Record decision<\/button>/); - expect(markup).toMatch(/]*disabled=""[^>]*>Insert view in document<\/button>/); - item.state = "running"; +} + +test("a requested investigation offers Run and can be withdrawn", () => { + let { store } = setup("requested"); + let markup = render(store); + expect(markup).toContain('aria-label="Investigation"'); + expect(markup).toContain("Ready to run"); + expect(markup).toMatch(/]*disabled)[^>]*>Run<\/button>/); + expect(markup).toContain('aria-label="Remove investigation"'); + // Its card goes only after the record is withdrawn. + expect(removableInvestigation(store, "investigation")).toBe(false); + expect(render(store, "ana", true)).toMatch(/]*disabled=""[^>]*>Run<\/button>/); +}); + +test("only the person who ran it can stop running work, even under a build lock", () => { + let { store, item } = setup("running"); item.input = { id: item.id, documentId: item.documentId, @@ -47,86 +64,63 @@ test("a build lock preserves shared result selections while blocking document mu authorizer: "ana", source: { repositoryId: "R_score", repository: "octo-org/score", commit: "a".repeat(40) }, }; - markup = renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "ana", - canEdit: true, - locked: true, - })); - expect(markup).toMatch(/]*disabled)[^>]*>Cancel investigation<\/button>/); - let peer = renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "leo", - canEdit: true, - locked: true, - })); - expect(peer).not.toContain("Cancel investigation"); + expect(render(store, "ana", true)).toMatch(/]*disabled)[^>]*>Stop<\/button>/); + let peer = render(store, "leo", true); + expect(peer).not.toContain(">Stop<"); + expect(peer).not.toContain("Remove investigation"); + expect(removableInvestigation(store, "investigation")).toBe(false); }); -function investigation(state: PublishedInvestigation["state"]): PublishedInvestigation { - return { - id: `investigation-${state}`, - documentId: "document", - revision: 1, - brief: "Compare startup approaches", - requester: "ana", - state, - generation: 1, - expiresAt: 0, - createdAt: 0, - updatedAt: 0, - progress: "", - result: performance, - views: Object.fromEntries(performance.views.map(view => [view.key, initialState(view)])), - decisions: [], - }; -} - -function render(item: PublishedInvestigation): string { - let store = new ExperimentStore("document"); - store.values.set(item.id, item); - return renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "ana", - canEdit: true, - locked: false, - })); -} - -test("a completed investigation folds to its brief and status", () => { - let markup = render(investigation("completed")); - - expect(markup).toMatch( - /]*>]*aria-expanded="false"[^>]*>]*>Compare startup approaches<\/span>/, - ); - expect(markup).toMatch(/

]*>completed/); - expect(markup).toMatch(/]*data-slot="unfold"[^>]*inert=""/); - expect(markup).toMatch(/]*aria-expanded="false"[^>]*>Report<(?:svg)/); - expect(markup).not.toContain(" { + for (let state of ["failed", "cancelled", "interrupted"] as const) { + let { store } = setup(state); + expect(render(store)).toMatch(/]*disabled)[^>]*>Retry<\/button>/); + expect(render(store)).toContain("Remove investigation"); + expect(removableInvestigation(store, "investigation")).toBe(true); + } }); -test("an investigation that still needs work starts unfolded", () => { - let markup = render(investigation("requested")); - - expect(markup).toMatch(/]*>]*aria-expanded="true"/); - expect(markup).toContain("Run on my workspace"); +test("a completed investigation is a compact card that opens its child document", () => { + let { store, item } = setup("completed"); + item.result = performance; + item.views = Object.fromEntries(performance.views.map(view => [view.key, initialState(view)])); + item.child = { + id: "child", + title: "Startup comparison", + slug: "startup-comparison", + summary: "Caching reduced median startup time.", + }; + let markup = render(store); + expect(markup).toContain("Startup comparison"); + expect(markup).toContain("Caching reduced median startup time."); + expect(markup).toContain("1 view"); + expect(markup).toContain('data-investigation="investigation"'); + expect(markup).toContain('aria-label="Open Startup comparison"'); + expect(markup).not.toContain("Compare startup approaches"); + expect(markup).not.toContain(" { - let store = new ExperimentStore("document"); - let item = investigation("completed"); - let markup = renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "ana", - canEdit: true, - locked: false, - })); - - expect(markup).toContain("Compare startup approaches"); - expect(markup).not.toContain("aria-controls"); - expect(markup).not.toContain("aria-expanded"); +test("a completed investigation whose child is gone says so", () => { + let { store, item } = setup("completed"); + item.result = performance; + let markup = render(store); + expect(markup).toContain("Its published document is no longer available."); + expect(markup).not.toContain("Open "); }); diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index ef4c5b064..fd3b17ecc 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -1,200 +1,138 @@ -import { useEffect, useState } from "react"; -import { ExperimentView } from "@chopin/experiment/react"; -import { StaticPlanEditor } from "@chopin/editor/static"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { InvestigationCard, SidecarCard } from "@chopin/editor"; import type { ExperimentStore } from "./store"; -import { EvidenceActions } from "./evidence"; -import type { InvestigationSummary } from "@chopin/experiment/records"; -import { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; +import { ItemDecisions } from "./evidence"; +import type { InvestigationChild, InvestigationState } from "@chopin/experiment/records"; -export function InvestigationCard({ store, summary, userId, canEdit, locked }: { +const ACTIVE: ReadonlySet = new Set(["queued", "running", "publishing"]); +const SETTLED: ReadonlySet = new Set(["failed", "cancelled", "interrupted"]); + +/** The freshest state this browser knows, from the list or a loaded record. */ +function latest(store: ExperimentStore, id: string) { + let summary = store.items.find(item => item.id === id); + let value = store.get(id); + if (!summary) return value; + return value && value.revision > summary.revision ? value : summary; +} + +/** + * Whether a document may drop this investigation's card. Only stopped or failed work can go: + * a ready card is withdrawn first, and a completed one stays as the link to its child + * document. The server applies the same rule. + */ +export function removableInvestigation(store: ExperimentStore, id: string): boolean { + let state = latest(store, id)?.state; + return !!state && SETTLED.has(state); +} + +/** The inline card for one investigation, placed in the document by its id. */ +export function InvestigationReference({ store, id, userId, canEdit, locked, onRemove, onOpen }: { store: ExperimentStore; - summary: InvestigationSummary; + id: string; userId: string; canEdit: boolean; locked: boolean; + onRemove: () => void; + /** Open the child document a completed investigation was published as. */ + onOpen?: (child: InvestigationChild, opener: HTMLElement) => void; }) { - let [connectionId, setConnectionId] = useState(""); + useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); + let [needsAgent, setNeedsAgent] = useState(false); let [busy, setBusy] = useState(false); let [error, setError] = useState(""); - let [loadFailed, setLoadFailed] = useState(false); - let [loading, setLoading] = useState(!store.get(summary.id)); - let item = store.get(summary.id); - let owned = store.connections.filter(connection => connection.owner === userId); - // Work that still needs a person opens; a finished result folds to its brief. - let body = useUnfold(summary.state !== "completed", "card"); - let load = async () => { - setLoading(true); - await store.load(summary.id); - if (loadFailed && store.get(summary.id)) void store.refresh(); - setLoadFailed(!store.get(summary.id)); - setLoading(false); - }; + let summary = store.items.find(item => item.id === id); + let item = store.get(id); + let current = latest(store, id); + let revision = summary?.revision; useEffect(() => { - if (!store.get(summary.id)) void load(); - }, [store, summary.id]); + if (!store.get(id) || revision !== undefined && store.get(id)!.revision < revision) { + void store.load(id); + } + }, [store, id, revision]); async function act(action: () => Promise) { setBusy(true); setError(""); + setNeedsAgent(false); try { await action(); } catch (error) { - setError(String((error as Error).message)); + let message = String((error as Error).message); + if (message === "no-workspace") setNeedsAgent(true); + else setError(message); } finally { setBusy(false); } } + let state = current?.state; + let brief = current?.brief; + if (!state || !brief) { + return ( + +

+ {store.error ? "Investigation unavailable" : "Loading investigation…"} +

+ + ); + } + let stoppable = ACTIVE.has(state) && item?.input?.authorizer === userId; + let child = state === "completed" ? item?.child : undefined; + // A completed record loaded without a child has lost its document; say so plainly. + let orphaned = state === "completed" && !!item && item.revision >= (revision ?? 0) && !child; return ( -
+ : orphaned && ( +

+ Its published document is no longer available. +

+ )} + onOpen={child && onOpen ? opener => onOpen(child, opener) : undefined} + onRemove={canEdit && state === "requested" + ? () => + void act(async () => { + // Withdraw the record before its card leaves, so nothing is left unplaced. + await store.action(id, "cancel", {}); + onRemove(); + }) + : canEdit && SETTLED.has(state) + ? onRemove + : undefined} + onRetry={canEdit && SETTLED.has(state) + ? () => void act(() => store.action(id, "retry", {})) + : undefined} + onRun={canEdit && state === "requested" + ? () => void act(() => store.action(id, "run", {})) + : undefined} + onStop={canEdit && stoppable + ? () => void act(() => store.action(id, "cancel", {})) + : undefined} + progress={current?.progress} + ready={child && { + title: child.title, + summary: child.summary, + views: item?.result?.views.length ?? 0, + }} + state={state} > -

- {item - ? ( - - {summary.brief} - - ) - : {summary.brief}} -

-

- {summary.state} {summary.progress && `· ${summary.progress}`} -

- {error &&

{error}

} - {!item && loading &&

Loading investigation…

} - {loadFailed && !item && ( - - )} - {item && ( - -
- {canEdit && item.state === "requested" && ( -
- {owned.length === 0 && ( -

- Connect a local workspace to run this investigation. -

- )} - - -
- )} - {canEdit && ["queued", "running", "publishing"].includes(item.state) - && item.input?.authorizer === userId && ( - - )} - {canEdit && ["failed", "cancelled", "interrupted"].includes(item.state) && ( - - )} - {item.result && ( - <> - - - - {item.result.views.map(view => ( -
- - void act(async () => { - try { - await store.action(item.id, "state", { - view: view.key, - patch: { - mutationId: crypto.randomUUID(), - expected: { [field]: item.views[view.key].fields[field].revision }, - set: { [field]: values }, - }, - }); - } finally { - await store.load(item.id); - } - })} - /> - - -
- ))} -

{item.result.provenance.environment}

- {item.result.provenance.limitations.map((value, index) => ( -

{value}

- ))} - {item.result.evidence.map(value => ( - -
{value.text}
-
- ))} - - )} -
-
- )} -
+ {child && item && } + + ); +} + +function AgentHint() { + return ( +
+

Start your local agent to run this:

+ + {`CHOPIN_URL=${location.origin} bun run connector connect /path/to/project -- copilot --acp`} + +

Open the link it prints, then press Run again.

+
); } diff --git a/apps/web/src/experiments/evidence.tsx b/apps/web/src/experiments/evidence.tsx index a2efc0feb..e99eb4af5 100644 --- a/apps/web/src/experiments/evidence.tsx +++ b/apps/web/src/experiments/evidence.tsx @@ -36,14 +36,7 @@ export function EvidenceActions({ store, item, view, disabled }: { {error &&

{error}

}
- + )} + + ); +} + +/** The decisions recorded against one investigation, shown on its inline card. */ +export function ItemDecisions( + { store, item, canEdit }: { + store: ExperimentStore; + item: PublishedInvestigation; + canEdit: boolean; + }, +) { + let [error, setError] = useState(""); + if (!item.decisions.length) return null; + return ( +
+ {error &&

{error}

} + {item.decisions.toSorted((a, b) => b.at - a.at).map(decision => ( + + ))} +
+ ); +} + export function EvidenceDecisions( { store, canEdit }: { store: ExperimentStore; canEdit: boolean }, ) { @@ -122,36 +196,16 @@ export function EvidenceDecisions(

Investigation decisions

{error &&

{error}

} - {decisions.map(({ item, decision }) => { - let view = item.result?.views.find(view => view.key === decision.view); - return ( -
-

{decision.conclusion}

-

{decision.rationale}

-

- Recorded by {decision.by} · Fixed evidence -

- {view && item.result && ( - - - - )} - {canEdit && ( - - )} -
- ); - })} + {decisions.map(({ item, decision }) => ( + + ))}
); } diff --git a/apps/web/src/experiments/inline.tsx b/apps/web/src/experiments/inline.tsx deleted file mode 100644 index bca827a8f..000000000 --- a/apps/web/src/experiments/inline.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useState, useSyncExternalStore } from "react"; -import { UnfoldPanel, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; -import { InvestigationCard } from "./card"; -import { EvidenceDecisions } from "./evidence"; -import { RequestIdentity } from "./request-id"; -import type { ExperimentStore } from "./store"; - -export function InlineInvestigations({ store, userId, canEdit, locked }: { - store: ExperimentStore; - userId: string; - canEdit: boolean; - locked: boolean; -}) { - useSyncExternalStore(store.subscribe, store.snapshot); - let [brief, setBrief] = useState(""); - let propose = useUnfold(); - let [busy, setBusy] = useState(false); - let [error, setError] = useState(""); - let [creation] = useState(() => new RequestIdentity()); - if (!canEdit && !store.items.length && !store.error) return null; - return ( -
- {(error || store.error) && ( -

{error || store.error}

- )} - {store.items.map(summary => ( - - ))} - - {canEdit && !locked && ( -
- - Propose investigation - - -
{ - event.preventDefault(); - if (busy) return; - setBusy(true); - setError(""); - void store.action("", "", { id: creation.key({ brief }), brief }).then(() => { - creation.clear(); - setBrief(""); - propose.setOpen(false, { focusTrigger: true }); - }).catch(reason => setError(reason.message)).finally(() => setBusy(false)); - }} - > -