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}
- {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 af999c685..4b5a921d4 100644
--- a/apps/web/src/experiments/card.test.tsx
+++ b/apps/web/src/experiments/card.test.tsx
@@ -37,6 +37,7 @@ function render(store: ExperimentStore, userId = "ana", locked = false) {
canEdit: true,
locked,
onRemove() {},
+ onOpen() {},
}));
}
@@ -79,10 +80,26 @@ test("settled work offers Retry", () => {
}
});
-test("a build lock preserves shared result selections while blocking document mutations", () => {
+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("