Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 13 additions & 3 deletions apps/server/src/experiments/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,23 @@ 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);
if (!value || value.documentId !== params.id) fail("not-found");
if ((value.input?.authorizer ?? value.requester) !== session.user.id) {
fail("connection-forbidden");
}
// Withdrawing a ready card runs nothing, so any writer may; running work is its runner's.
if (
value.state !== "requested"
&& (value.input?.authorizer ?? value.requester) !== session.user.id
) fail("connection-forbidden");
return json(
publicInvestigation(await service.stop(value.id, "cancelled", "Cancelled by owner.")),
);
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/experiments/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
13 changes: 13 additions & 0 deletions apps/server/src/experiments/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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(() => {
Expand Down Expand Up @@ -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.",
};
},
}
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/plan/edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<Investigation id="2b7e0c4a-5f1d-4e2a-9c3b-7d8e9f0a1b2c" />';
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(
'<Callout id="01K0N4TR8K7JGM4R1J7PW4R8YK" type="note">\n'
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/plan/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.`;
Expand Down Expand Up @@ -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.";
}

Expand All @@ -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));
}

Expand Down
87 changes: 87 additions & 0 deletions apps/server/src/plan/investigation-projection.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof peer>, 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<Investigation id="${ID}" />\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<Investigation id="${ID}" />\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<Investigation id="${ID}" />\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();
});
});
12 changes: 12 additions & 0 deletions apps/server/src/plan/projections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<Investigation id="old" />\n\n<Research id="kept" />');
let after = nodes('<Research id="kept" />\n\n<Investigation id="new" />');
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('<Investigation id="a" />'), nodes('<Research id="a" />')))
.toContain("cannot be altered");
});
26 changes: 19 additions & 7 deletions apps/server/src/plan/projections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(["Research", "Investigation"]);

function collect(nodes: RootContent[]): Map<string, { type: string; source: string }> | string {
let found = new Map<string, { type: string; source: string }>();
Expand Down Expand Up @@ -40,22 +44,30 @@ function collect(nodes: RootContent[]): Map<string, { type: string; source: stri
}

/** IDs of new atomic references whose records must be checked before accepting a browser batch. */
export function newResearchProjections(base: RootContent[], next: RootContent[]): string[] {
export function newResearchProjections(
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 [...proposed].flatMap(([id, value]) =>
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] : []
);
}

Expand All @@ -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) {
Expand All @@ -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.";
}
}
Expand Down
Loading
Loading