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