From c91eca1e66d43bda67021d82571187c68a9f1a09 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:43:34 +0100 Subject: [PATCH 1/9] Publish a completed investigation as a child document Completion now promotes the candidate and creates a research-shaped child in one fenced commit: the report, an Experiment embed per view, method, limitations and evidence. The record links the child (child_channel_id), the inline card becomes a compact link to it, and the child's embeds read the parent's record by id so selections and decisions stay shared. Reports may no longer embed Investigation. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/experiments/document.test.ts | 76 +++++++++++ apps/server/src/experiments/document.ts | 123 ++++++++++++++++++ apps/server/src/experiments/routes.test.ts | 34 +++++ apps/server/src/experiments/routes.ts | 80 ++++++++---- apps/server/src/experiments/service.ts | 49 +++++-- apps/server/src/research/publication.ts | 2 +- .../server/src/storage/experiment-contract.ts | 45 +++++++ apps/server/src/storage/experiments.ts | 21 ++- apps/server/src/storage/memory/adapter.ts | 11 ++ apps/server/src/storage/memory/experiments.ts | 46 ++++++- .../src/storage/postgres/adapter.test.ts | 1 + apps/server/src/storage/postgres/adapter.ts | 1 + .../src/storage/postgres/experiments.ts | 61 ++++++++- .../server/src/storage/postgres/migrations.ts | 3 + .../migrations/019_investigation_children.sql | 3 + apps/web/src/child-provenance.tsx | 72 ++++++---- apps/web/src/experiments/api.ts | 17 +++ apps/web/src/experiments/card.test.tsx | 31 +++-- apps/web/src/experiments/card.tsx | 101 +++----------- apps/web/src/experiments/evidence.tsx | 23 ++-- apps/web/src/experiments/store.ts | 31 +++-- apps/web/src/navigation-shell.tsx | 27 ++-- apps/web/src/room-workspace.tsx | 38 +++++- docs/architecture.md | 4 +- docs/local-experiments.md | 30 +++-- e2e/experiments.e2e.ts | 75 ++++++----- packages/editor/src/index.ts | 1 + packages/editor/src/styles.css | 6 + packages/editor/src/widget-options.ts | 2 + packages/editor/src/widgets/experiment.tsx | 1 + packages/editor/src/widgets/investigation.tsx | 45 ++++++- packages/experiment/src/records.ts | 21 ++- skills/local-investigations/SKILL.md | 3 +- 33 files changed, 843 insertions(+), 241 deletions(-) create mode 100644 apps/server/src/experiments/document.test.ts create mode 100644 apps/server/src/experiments/document.ts create mode 100644 apps/server/src/storage/postgres/migrations/019_investigation_children.sql 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..62831ebb7 100644 --- a/apps/server/src/experiments/routes.test.ts +++ b/apps/server/src/experiments/routes.test.ts @@ -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,36 @@ 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); + 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..2388bbe07 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,30 @@ 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; + } + async function published(value: Investigation) { + let child = value.childId ? await auth.storage.channels.get(value.childId) : undefined; + return { + ...publicInvestigation(value), + ...(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 +204,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 +237,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 +333,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 +350,8 @@ 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); + 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 +361,8 @@ 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); + if (!value.result?.views.some(view => view.key === input.view)) fail("not-found"); if ( input.decision && !value.decisions.some(decision => @@ -340,17 +377,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 +570,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..6e9d6e61b 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,49 @@ 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!); + } 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..6d641b750 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(); diff --git a/apps/server/src/storage/memory/experiments.ts b/apps/server/src/storage/memory/experiments.ts index 5a0a77d53..07b1f9dd2 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,10 @@ 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; + } async active() { return [...this.#records.values()].filter(value => this.options.exists(value.documentId) @@ -45,4 +52,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..fcca91181 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, diff --git a/apps/server/src/storage/postgres/experiments.ts b/apps/server/src/storage/postgres/experiments.ts index 303305589..787e18de4 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,12 @@ 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 payload FROM experiments WHERE child_channel_id = ${childId}`; + return row ? decode(row.payload) : undefined; + } async active() { let rows = await this.sql< { payload: unknown }[] @@ -58,4 +66,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/experiment/src/react.tsx b/packages/experiment/src/react.tsx index 2fa9d8153..32f116870 100644 --- a/packages/experiment/src/react.tsx +++ b/packages/experiment/src/react.tsx @@ -45,8 +45,8 @@ export function ExperimentView( }; return (
-

{view.title}

-

+

{view.title}

+

{dataset.basis} · {rows.length} observations · {dataset.method}

@@ -55,11 +55,11 @@ export function ExperimentView( let choices = [...new Set(dataset.rows.map(row => JSON.stringify(row.values[key])))]; let chosen = state.fields[`filter:${key}`]?.values[0]; return ( -