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/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 08ac1fa87..0c5793506 100644 --- a/apps/server/src/experiments/routes.test.ts +++ b/apps/server/src/experiments/routes.test.ts @@ -60,7 +60,7 @@ async function setup() { encryptionKey: new Uint8Array(32), }; let router = new Router(); - let lock = { locked: false }; + let lock = { locked: false, documents: new Set() }; let runtime = registerExperimentRoutes(router, { storage, sessions, @@ -72,7 +72,7 @@ async function setup() { lease: () => lease, changed: () => {}, context: async () => ({ source: "# Context", revision: 0 }), - canMutate: async () => !lock.locked, + canMutate: async id => !lock.locked && !lock.documents.has(id), }); async function request(path: string, body?: unknown, cookie?: string, token?: string) { return (await router.handle( @@ -127,7 +127,7 @@ async function setup() { } test("work runs on the clicker's own connection; scoped claims publish", async () => { - let { sessions, config, alice, bob, documentId, request } = await setup(); + 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), @@ -167,6 +167,10 @@ test("work runs on the clicker's own connection; scoped claims publish", async ( 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, @@ -182,6 +186,63 @@ test("work runs on the clicker's own connection; scoped claims publish", async ( 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] } }), ); diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index d905538b1..a9536a0ca 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -1,6 +1,7 @@ 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"; @@ -11,6 +12,7 @@ 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"; @@ -110,6 +112,36 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio 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, @@ -178,8 +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); + let source = await service.store.published(params.id); return json({ - 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, @@ -207,9 +243,22 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio }); 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, channel } = await access( @@ -290,9 +339,9 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio "/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: { @@ -307,11 +356,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); @@ -321,11 +368,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 => @@ -340,17 +385,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 })); @@ -534,7 +578,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.ts b/apps/server/src/experiments/service.ts index 4f82ebb62..26528f036 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) { @@ -78,6 +80,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,26 +131,48 @@ 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, 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..aedf226b1 100644 --- a/apps/server/src/storage/experiment-contract.ts +++ b/apps/server/src/storage/experiment-contract.ts @@ -1,4 +1,5 @@ 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 type { StorageFactory } from "./contract-support"; @@ -27,5 +28,55 @@ 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(); + } + }); }); } 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 b5e26acaf..ce08aed73 100644 --- a/apps/server/src/storage/memory/adapter.ts +++ b/apps/server/src/storage/memory/adapter.ts @@ -131,6 +131,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(); @@ -536,6 +547,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..4b6fa53e9 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,39 @@ 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"); + 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 a48063eff..1e6f5462e 100644 --- a/apps/server/src/storage/postgres/adapter.test.ts +++ b/apps/server/src/storage/postgres/adapter.test.ts @@ -696,6 +696,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 2afb96c64..c19a38882 100644 --- a/apps/server/src/storage/postgres/adapter.ts +++ b/apps/server/src/storage/postgres/adapter.ts @@ -481,6 +481,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, @@ -912,6 +913,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..41ae148b0 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,53 @@ 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"); + 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/web/src/child-provenance.tsx b/apps/web/src/child-provenance.tsx index ae363d172..e28f83a94 100644 --- a/apps/web/src/child-provenance.tsx +++ b/apps/web/src/child-provenance.tsx @@ -1,42 +1,75 @@ import { useEffect, useId, useState } from "react"; import { ChevronIcon, SparkleIcon } from "@chopin/icons"; +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 [briefOpen, setBriefOpen] = useState(false); let briefId = useId(); 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]); - 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}`, ); }, }); @@ -48,27 +81,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} - - )} +
+ {!decision && store.actions?.(experiment, viewKey)} + {canEdit && connected && ( + + )} +
); } diff --git a/packages/editor/src/widgets/investigation.tsx b/packages/editor/src/widgets/investigation.tsx index ac4acc74e..6455327e4 100644 --- a/packages/editor/src/widgets/investigation.tsx +++ b/packages/editor/src/widgets/investigation.tsx @@ -25,6 +25,8 @@ const WORDS: Record = { const TRACK: InvestigationState[] = ["queued", "running", "publishing"]; export type InvestigationCardProps = { + /** The investigation's id, so a child can find its way back to this card. */ + id?: string; brief: string; state: InvestigationState; /** The local agent's latest progress line. */ @@ -39,7 +41,10 @@ export type InvestigationCardProps = { onStop?: () => void; onRetry?: () => void; onRemove?: () => void; - /** The completed result, owned by the host. */ + /** The child document a completed investigation was published as. */ + ready?: { title: string; summary: string; views: number }; + onOpen?: (opener: HTMLElement) => void; + /** Host detail under a ready card, such as the decisions recorded from its views. */ children?: ReactNode; }; @@ -75,15 +80,19 @@ export function InvestigationCard( canEdit = true, children, error, + id, notice, + onOpen, onRemove, onRetry, onRun, onStop, progress, + ready, state, }: InvestigationCardProps, ) { + let shownSummary = useLast(ready?.summary || undefined); let footer = onRun ? "run" : onRetry ? "retry" : onStop ? "stop" : undefined; let lead = state === "completed" || state === "failed" || state === "interrupted"; let at = TRACK.indexOf(state); @@ -101,7 +110,9 @@ export function InvestigationCard( return (
- {WORDS[state]} + + {WORDS[state]} + {ready && ready.views > 0 && ( + + {"\u00a0· "} + {ready.views === 1 ? "1 view" : `${ready.views} views`} + + )} +
@@ -135,9 +154,12 @@ export function InvestigationCard(
-
- -
+ + {ready ? ready.title : } + + + {shownSummary !== undefined &&

{shownSummary}

} +
{shownLive !== undefined &&

{shownLive}

}
@@ -149,7 +171,7 @@ export function InvestigationCard( )} {notice} - {children} + {children &&
{children}
}
@@ -186,6 +208,15 @@ export function InvestigationCard(
+ {ready && onOpen && ( +