From d38a664d9abad43ec703a66e18c4c36998e388ee Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:10:32 +0100 Subject: [PATCH 1/4] Propose investigations from the slash menu as inline cards Typing / and choosing Investigate opens the research-style brief composer at the caret; submitting creates the investigation and places an id-only, atomic card there. The afterword proposal form and list are gone. Retry now returns settled work to Ready to run under the same id. Co-Authored-By: Claude Opus 5.5 --- ...FGXY4P07EG-investigation-references-sha.md | 8 + apps/server/src/experiments/routes.ts | 8 + apps/server/src/experiments/service.test.ts | 40 +++ apps/server/src/experiments/service.ts | 13 + apps/server/src/main.ts | 19 +- apps/server/src/plan/edit.test.ts | 21 ++ apps/server/src/plan/edit.ts | 11 +- .../src/plan/investigation-projection.test.ts | 87 ++++++ apps/server/src/plan/projections.test.ts | 12 + apps/server/src/plan/projections.ts | 26 +- apps/server/src/plan/room.ts | 49 ++- apps/server/src/plan/service.ts | 31 +- apps/web/src/experiments/card.test.tsx | 82 +++-- apps/web/src/experiments/card.tsx | 294 ++++++++---------- apps/web/src/experiments/inline.tsx | 75 ----- apps/web/src/room-workspace.tsx | 28 +- docs/architecture.md | 5 +- docs/local-experiments.md | 24 +- e2e/connector.e2e.ts | 14 +- e2e/experiments.e2e.ts | 127 ++++---- packages/dialect/src/dialect.test.ts | 15 + packages/dialect/src/dialect.ts | 11 + packages/dialect/src/index.ts | 5 + packages/dialect/src/nodes/investigation.ts | 96 ++++++ packages/dialect/src/registry.test.ts | 3 + packages/dialect/src/registry.ts | 17 + packages/editor/src/index.ts | 3 + packages/editor/src/plan-editor.tsx | 13 +- packages/editor/src/research-draft.ts | 36 ++- packages/editor/src/styles.css | 23 +- packages/editor/src/toolbar/index.tsx | 28 +- packages/editor/src/toolbar/research.tsx | 97 ++++-- packages/editor/src/toolbar/slash.test.ts | 9 +- packages/editor/src/toolbar/slash.tsx | 13 +- packages/editor/src/widget-options.ts | 14 + packages/editor/src/widgets/index.ts | 6 +- packages/editor/src/widgets/investigation.tsx | 211 +++++++++++++ packages/editor/src/widgets/research.tsx | 53 +++- skills/local-investigations/SKILL.md | 5 + 39 files changed, 1177 insertions(+), 455 deletions(-) create mode 100644 .lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md create mode 100644 apps/server/src/plan/investigation-projection.test.ts delete mode 100644 apps/web/src/experiments/inline.tsx create mode 100644 packages/dialect/src/nodes/investigation.ts create mode 100644 packages/editor/src/widgets/investigation.tsx 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/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index 7c1ef553c..54a331dbe 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -237,6 +237,14 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio 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); 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..4f82ebb62 100644 --- a/apps/server/src/experiments/service.ts +++ b/apps/server/src/experiments/service.ts @@ -162,6 +162,19 @@ export class Experiments { 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 785b42094..ec25b9879 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -202,6 +202,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(() => { @@ -368,10 +382,13 @@ 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 = await placeInvestigationReference(room.id, value.id); return { id: value.id, state: value.state, - message: "Open Investigations and authorize Run on my workspace to execute.", + message: placed + ? "Its card is now in the document. A person selects Run on it to execute." + : "The document is closing, so its card was not placed.", }; }, } 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 bcfcd170e..1bbd3ef29 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1577,7 +1577,14 @@ 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; + if (action === "add") return !!value; + // A reference to running work stays until it settles, so its card remains reachable. + return !value || !["queued", "running", "publishing"].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; @@ -2144,6 +2151,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/web/src/experiments/card.test.tsx b/apps/web/src/experiments/card.test.tsx index 5c0c69a09..b80cd8118 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,44 @@ 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() {}, })); - expect(markup.match(/]*aria-label="Filter Workload"[^>]*>/)?.[0]) - .not.toContain("disabled"); - expect(markup).toMatch(/]*disabled=""[^>]*>Record decision<\/button>/); - expect(markup).toMatch(/]*disabled=""[^>]*>Insert view in document<\/button>/); - item.state = "running"; +} + +test("a requested investigation offers Run and can be removed", () => { + let { store } = setup("requested"); + let markup = render(store); + expect(markup).toContain('aria-label="Investigation"'); + expect(markup).toContain("Ready to run"); + expect(markup).toMatch(/]*disabled)[^>]*>Run<\/button>/); + expect(markup).toContain('aria-label="Remove investigation"'); + expect(removableInvestigation(store, "investigation")).toBe(true); + expect(render(store, "ana", true)).toMatch(/]*disabled=""[^>]*>Run<\/button>/); +}); + +test("only the person who ran it can stop running work, even under a build lock", () => { + let { store, item } = setup("running"); item.input = { id: item.id, documentId: item.documentId, @@ -47,20 +62,27 @@ test("a build lock preserves shared result selections while blocking document mu authorizer: "ana", source: { repositoryId: "R_score", repository: "octo-org/score", commit: "a".repeat(40) }, }; - markup = renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "ana", - canEdit: true, - locked: true, - })); - expect(markup).toMatch(/]*disabled)[^>]*>Cancel investigation<\/button>/); - let peer = renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "leo", - canEdit: true, - locked: true, - })); - expect(peer).not.toContain("Cancel investigation"); + expect(render(store, "ana", true)).toMatch(/]*disabled)[^>]*>Stop<\/button>/); + let peer = render(store, "leo", true); + expect(peer).not.toContain(">Stop<"); + expect(peer).not.toContain("Remove investigation"); + expect(removableInvestigation(store, "investigation")).toBe(false); +}); + +test("settled work offers Retry", () => { + for (let state of ["failed", "cancelled", "interrupted"] as const) { + let { store } = setup(state); + expect(render(store)).toMatch(/]*disabled)[^>]*>Retry<\/button>/); + } +}); + +test("a build lock preserves shared result selections while blocking document mutations", () => { + let { store, item } = setup("completed"); + item.result = performance; + item.views = Object.fromEntries(performance.views.map(view => [view.key, initialState(view)])); + let markup = render(store, "ana", true); + expect(markup.match(/]*aria-label="Filter Workload"[^>]*>/)?.[0]) + .not.toContain("disabled"); + expect(markup).toMatch(/]*disabled=""[^>]*>Record decision<\/button>/); + expect(markup).toMatch(/]*disabled=""[^>]*>Insert view in document<\/button>/); }); diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index ccbefe9b5..534cfb3ac 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -1,44 +1,41 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { InvestigationCard, SidecarCard } from "@chopin/editor"; import { ExperimentView } from "@chopin/experiment/react"; import { StaticPlanEditor } from "@chopin/editor/static"; import type { ExperimentStore } from "./store"; import { EvidenceActions } from "./evidence"; -import type { InvestigationState, InvestigationSummary } from "@chopin/experiment/records"; +import type { InvestigationState, PublishedInvestigation } from "@chopin/experiment/records"; -const WORDS: Record = { - requested: "Ready to run", - queued: "Waiting for your local agent", - running: "Running", - publishing: "Writing up", - completed: "Done", - failed: "Failed", - cancelled: "Cancelled", - interrupted: "Interrupted", -}; +const ACTIVE: InvestigationState[] = ["queued", "running", "publishing"]; +const SETTLED: InvestigationState[] = ["failed", "cancelled", "interrupted"]; -export function InvestigationCard({ store, summary, userId, canEdit, locked }: { +/** Whether a document may drop this investigation's card: only once its work is not in flight. */ +export function removableInvestigation(store: ExperimentStore, id: string): boolean { + let state = store.items.find(item => item.id === id)?.state ?? store.get(id)?.state; + return !!state && !ACTIVE.includes(state); +} + +/** The inline card for one investigation, placed in the document by its id. */ +export function InvestigationReference({ store, id, userId, canEdit, locked, onRemove }: { store: ExperimentStore; - summary: InvestigationSummary; + id: string; userId: string; canEdit: boolean; locked: boolean; + onRemove: () => void; }) { + useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); let [needsAgent, setNeedsAgent] = useState(false); let [busy, setBusy] = useState(false); let [error, setError] = useState(""); - let [loadFailed, setLoadFailed] = useState(false); - let [loading, setLoading] = useState(!store.get(summary.id)); - let item = store.get(summary.id); - let load = async () => { - setLoading(true); - await store.load(summary.id); - if (loadFailed && store.get(summary.id)) void store.refresh(); - setLoadFailed(!store.get(summary.id)); - setLoading(false); - }; + let summary = store.items.find(item => item.id === id); + let item = store.get(id); + let revision = summary?.revision; useEffect(() => { - if (!store.get(summary.id)) void load(); - }, [store, summary.id]); + if (!store.get(id) || revision !== undefined && store.get(id)!.revision < revision) { + void store.load(id); + } + }, [store, id, revision]); async function act(action: () => Promise) { setBusy(true); setError(""); @@ -53,140 +50,125 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { setBusy(false); } } + let state = summary?.state ?? item?.state; + let brief = summary?.brief ?? item?.brief; + if (!state || !brief) { + return ( + +

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

+
+ ); + } + let stoppable = ACTIVE.includes(state) && item?.input?.authorizer === userId; return ( -
} + onRemove={canEdit && !ACTIVE.includes(state) ? onRemove : undefined} + onRetry={canEdit && SETTLED.includes(state) + ? () => void act(() => store.action(id, "retry", {})) + : undefined} + onRun={canEdit && state === "requested" + ? () => void act(() => store.action(id, "run", {})) + : undefined} + onStop={canEdit && stoppable + ? () => void act(() => store.action(id, "cancel", {})) + : undefined} + progress={summary?.progress ?? item?.progress} + state={state} > -

{summary.brief}

-

- {WORDS[summary.state] ?? summary.state} - {["running", "failed", "interrupted"].includes(summary.state) && summary.progress - && ` · ${summary.progress}`} -

- {error &&

{error}

} - {!item && loading &&

Loading investigation…

} - {loadFailed && !item && ( - + {item?.result && ( + )} - {item && ( - <> - {canEdit && item.state === "requested" && ( -
- - {needsAgent && ( -
-

Start your local agent to run this:

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

Open the link it prints, then press Run again.

-
- )} -
- )} - {canEdit && ["queued", "running", "publishing"].includes(item.state) - && item.input?.authorizer === userId && ( - - )} - {canEdit && ["failed", "cancelled", "interrupted"].includes(item.state) && ( - - )} - {item.result && ( - <> -
- Report - -
- {item.result.views.map(view => ( -
- ))} -

{item.result.provenance.environment}

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

{value}

- ))} - {item.result.evidence.map(value => ( -
- {value.title} -
{value.text}
-
- ))} - - )} - - )} -
+ Download JSON + + + Download CSV + + + + ))} +

{result.provenance.environment}

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

{value}

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

{error || store.error}

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