diff --git a/apps/server/src/agent/card-tool-context.ts b/apps/server/src/agent/card-tool-context.ts new file mode 100644 index 00000000..2027b9db --- /dev/null +++ b/apps/server/src/agent/card-tool-context.ts @@ -0,0 +1,10 @@ +import { jobToolContext } from "./job-tool-context"; +import type { Context as JobContext } from "./job-tool-context"; +import type { DocumentRoom } from "./tools"; + +export type Context = JobContext & { currentMemberRequest?: () => { text: string } | undefined }; +export type CardToolRoom = DocumentRoom & Pick; + +export function cardToolContext(room: CardToolRoom): Context { + return { ...jobToolContext(room), currentMemberRequest: room.currentMemberRequest }; +} diff --git a/apps/server/src/agent/decision-prose-1.test.ts b/apps/server/src/agent/decision-prose-1.test.ts new file mode 100644 index 00000000..fd2afca7 --- /dev/null +++ b/apps/server/src/agent/decision-prose-1.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, WIDGET, opened, job, proseTool, input } = fixture; + +test("write_decision_prose commits one staged insert and publishes anchors and metadata afterward", async () => { + let context = await opened(); + job(context); + let tool = proseTool(context); + let before = room.project(context.plan.document); + let response = await tool.handler(input(context), {} as never); + expect(JSON.parse(String(response))).toEqual({ + ok: true, + title: "Which authentication approach?", + mode: "insert", + revision: 1, + }); + expect(room.project(context.plan.document)).toBe( + before.replace("Context.\n\n", "Context.\n\nWe chose GitHub Apps.\n\n"), + ); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:anchors")).toHaveLength(1); + expect(context.broadcasts.filter(frame => frame.kind === "question:meta")).toHaveLength(1); + expect(tool.anchors()).toBe(1); + expect(tool.changes()).toBe(1); + expect(Questions.prose(context.plan)[0]).toMatchObject({ widget: CARD, orphaned: false }); + expect(room.matchesAnchor(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, 1)) + .toBe(true); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(room.project(context.plan.document)); +}); + +test("write_decision_prose uses one durable delta for a first-card two-step insertion", async () => { + let context = await opened(WIDGET); + job(context); + let before = room.project(context.plan.document); + let result = await proseTool(context).handler(input(context), {} as never); + expect(JSON.parse(String(result)).ok).toBe(true); + expect(room.project(context.plan.document)).toBe(`We chose GitHub Apps.\n\n${before}`); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + expect(room.matchesAnchor(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, 0)) + .toBe(true); +}); diff --git a/apps/server/src/agent/decision-prose-2.test.ts b/apps/server/src/agent/decision-prose-2.test.ts new file mode 100644 index 00000000..84d7a895 --- /dev/null +++ b/apps/server/src/agent/decision-prose-2.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "bun:test"; +import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical"; +import * as Y from "yjs"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, WIDGET, OTHER_WIDGET, opened, job, proseTool, input } = fixture; + +test("write_decision_prose addresses a first card past live leading and inter-card carets", async () => { + let context = await opened(`${WIDGET}\n${OTHER_WIDGET}`); + context.plan.document.editor.update(() => { + let children = $getRoot().getChildren(); + children[0]!.insertBefore($createParagraphNode()); + children[1]!.insertBefore($createParagraphNode()); + }, { discrete: true }); + await room.settle(); + let caretKeys: string[] = []; + context.plan.document.editor.getEditorState().read(() => { + caretKeys = $getRoot().getChildren() + .filter(node => $isParagraphNode(node) && node.getChildrenSize() === 0) + .map(node => node.getKey()); + }); + expect(caretKeys).toHaveLength(2); + let relatives = caretKeys.map(key => { + let type = context.plan.document.binding.collabNodeMap.get(key)?.getSharedType(); + if (!type) throw new Error("caret has no shared identity"); + return Y.createRelativePositionFromTypeIndex(type, 0, -1); + }); + let peer = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + room.project(context.plan.document), + [], + ); + expect(room.digests(context.plan.document)).toHaveLength(2); + expect(room.questionnaireIndex(context.plan.document, CARD)).toBe(0); + job(context); + let response = await proseTool(context).handler(input(context), {} as never); + expect(JSON.parse(String(response)).ok).toBe(true); + expect(room.project(context.plan.document)).toStartWith( + "We chose GitHub Apps.\n\n { + let current = new Set($getRoot().getChildren().map(node => node.getKey())); + for (let key of caretKeys) expect(current.has(key)).toBe(true); + }); + let update = context.broadcasts.find(frame => frame.kind === "plan:update")?.update; + if (typeof update !== "string") throw new Error("missing persisted document delta"); + Y.applyUpdate(peer.doc, Buffer.from(update, "base64"), "remote"); + await room.settle(); + let absolutes = relatives.map(relative => + Y.createAbsolutePositionFromRelativePosition(relative, peer.doc, false) + ); + for (let absolute of absolutes) expect(absolute).not.toBeNull(); + peer.editor.getEditorState().read(() => { + let empty = $getRoot().getChildren().filter(node => + $isParagraphNode(node) && node.getChildrenSize() === 0 + ); + expect(empty).toHaveLength(2); + for (let absolute of absolutes) { + expect( + empty.some(node => + peer.binding.collabNodeMap.get(node.getKey())?.getSharedType() === absolute?.type + ), + ).toBe(true); + } + }); + peer.doc.destroy(); + let expected = room.project(context.plan.document); + await Service.close(context.plan); + contexts = contexts.filter(item => item !== context); + let reopened = await Service.open(context.channel.id, context.backend, context.server); + contexts.push({ ...context, plan: reopened }); + expect(room.project(reopened.document)).toBe(expected); + expect(room.matchesAnchor(reopened.document, reopened.records.get(CARD)!.prose![0]!, 0)) + .toBe(true); +}); + +test("write_decision_prose replaces the anchored duplicate, leaving its twin alone", async () => { + let context = await opened(`Same.\n\nSame.\n\n${WIDGET}`); + context.plan.records.get(CARD)!.prose = [ + room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!), + ]; + job(context); + let result = await proseTool(context).handler(input(context, "New prose."), {} as never); + expect(JSON.parse(String(result)).mode).toBe("replace"); + expect(room.project(context.plan.document)).toStartWith("Same.\n\nNew prose.\n\n"); + expect(context.plan.records.get(CARD)!.prose).toHaveLength(1); +}); diff --git a/apps/server/src/agent/decision-prose-3.test.ts b/apps/server/src/agent/decision-prose-3.test.ts new file mode 100644 index 00000000..9ab2bc45 --- /dev/null +++ b/apps/server/src/agent/decision-prose-3.test.ts @@ -0,0 +1,48 @@ +import { expect, spyOn, test } from "bun:test"; +import { $getRoot } from "lexical"; +import * as room from "../plan/room"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, WIDGET, opened, job, proseTool, input } = fixture; + +test("write_decision_prose replaces uniquely moved prose at its new position", async () => { + let context = await opened(`Other context.\n\nOld decision prose.\n\n${WIDGET}`); + context.plan.records.get(CARD)!.prose = [ + room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!), + ]; + context.plan.document.editor.update(() => { + let children = $getRoot().getChildren(); + children[0]!.insertBefore(children[1]!); + }, { discrete: true }); + await room.settle(); + job(context); + let result = await proseTool(context).handler(input(context, "New prose."), {} as never); + expect(JSON.parse(String(result)).mode).toBe("replace"); + expect(room.project(context.plan.document)).toStartWith("New prose.\n\nOther context.\n\n"); +}); + +test("write_decision_prose rejects missing or stale job generation and invalid text", async () => { + let context = await opened(); + let tool = proseTool(context); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(String(await tool.handler(input(context), {} as never))) + .toContain("only available to a background Planner job"); + job(context, 2); + expect(String(await tool.handler(input(context), {} as never))).toContain("generation"); + job(context); + for (let text of ["# Heading", "One.\n\nTwo.", "Text "]) { + expect(String(await tool.handler(input(context, text), {} as never))).toStartWith("Error:"); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/decision-prose-4.test.ts b/apps/server/src/agent/decision-prose-4.test.ts new file mode 100644 index 00000000..d1a55392 --- /dev/null +++ b/apps/server/src/agent/decision-prose-4.test.ts @@ -0,0 +1,70 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import type { ConversationPlan } from "@chopin/protocol"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, OPTION, opened, job, proseTool, input } = fixture; + +test("write_decision_prose fences a replaced job after an awaited staging read", async () => { + let context = await opened(); + job(context); + let before = room.project(context.plan.document); + let original = room.restore; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let action = proseTool(context).handler(input(context), {} as never); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: "newer" } as ConversationPlan.Job; + release.resolve(); + expect(String(await action)).toContain("background Planner job changed"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + held.mockRestore(); + } +}); + +test("write_decision_prose fences a newer saved decision after an awaited staging read", async () => { + let context = await opened(); + job(context); + let before = room.project(context.plan.document); + let original = room.restore; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let action = proseTool(context).handler(input(context), {} as never); + await entered.promise; + let record = context.plan.records.get(CARD)!; + context.plan.records.set(CARD, { + ...record, + history: [{ choices: [OPTION], owner: "mina", at: 1_758_645_000 }], + }); + release.resolve(); + expect(String(await action)).toContain("background Planner job changed"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + held.mockRestore(); + } +}); diff --git a/apps/server/src/agent/decision-prose-5.test.ts b/apps/server/src/agent/decision-prose-5.test.ts new file mode 100644 index 00000000..dc049f07 --- /dev/null +++ b/apps/server/src/agent/decision-prose-5.test.ts @@ -0,0 +1,56 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { createDecisionProseFixture } from "./decision-prose.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { CARD, opened, job, proseTool, input } = fixture; + +test("failed prose persistence leaves live source, record, and publication untouched", async () => { + let context = await opened(); + job(context); + let before = room.project(context.plan.document); + let record = context.plan.records.get(CARD); + let records = context.plan.records; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + context.backend.fatal = () => {}; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(String(await proseTool(context).handler(input(context), {} as never))) + .toBe("Error: storage unavailable"); + } finally { + errors.mockRestore(); + context.storage.collaboration.commit = original; + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records).toBe(records); + expect(context.plan.records.get(CARD)).toBe(record); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.chat.jobOutput).toBeUndefined(); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(0); + expect(context.broadcasts.filter(frame => frame.kind === "plan:anchors")).toHaveLength(0); +}); + +test("a post-commit anchor relay error does not turn the durable write into a failed tool call", async () => { + let context = await opened(); + job(context); + context.breakRelay("plan:anchors"); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let result = await proseTool(context).handler(input(context), {} as never); + expect(JSON.parse(String(result)).ok).toBe(true); + expect(context.plan.chat.jobOutput).toBeDefined(); + expect(room.project(context.plan.document)).toContain("We chose GitHub Apps."); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(room.project(context.plan.document)); + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/decision-prose-fields.ts b/apps/server/src/agent/decision-prose-fields.ts new file mode 100644 index 00000000..976c91b2 --- /dev/null +++ b/apps/server/src/agent/decision-prose-fields.ts @@ -0,0 +1,16 @@ +import { validateProse } from "../questions/write-prose"; + +export function input(raw: unknown): { revision: number; id: string; text: string } { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("write_decision_prose arguments must be an object"); + } + let value = raw as Record; + if ( + Object.keys(value).sort().join(",") !== "id,revision,text" + || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 + || typeof value.id !== "string" || !value.id || value.id.length > 200 + ) throw new Error("write_decision_prose needs revision, id, and text"); + let valid = validateProse(value.text); + if (!valid.ok) throw new Error(valid.message); + return { revision: value.revision as number, id: value.id, text: valid.text }; +} diff --git a/apps/server/src/agent/decision-prose.test-fixtures.ts b/apps/server/src/agent/decision-prose.test-fixtures.ts new file mode 100644 index 00000000..94699653 --- /dev/null +++ b/apps/server/src/agent/decision-prose.test-fixtures.ts @@ -0,0 +1,112 @@ +import { afterEach } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; + +export type Opened = Awaited>; + +export function createDecisionProseFixture( + readContexts: () => Awaited>[], + writeContexts: (value: Awaited>[]) => void, +) { + const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + + const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; + + const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + const WIDGET = ` + + + +`; + + const OTHER_WIDGET = WIDGET + .replaceAll(CARD, "01K0N4X2M5R8T3VQ7YB6ZC4DEF") + .replaceAll(QUESTION, "01K0N4Y2M5R8T3VQ7YB6ZC4DEF") + .replaceAll(OPTION, "01K0N4Z2M5R8T3VQ7YB6ZC4DEF"); + + let contexts: Opened[] = []; + + async function opened(source = `Context.\n\n${WIDGET}`) { + let context = await openPlan(source); + contexts.push(context); + context.plan.records.set( + CARD, + Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Auth", + question: "Which authentication approach?", + multiple: false, + options: [{ id: OPTION, label: "GitHub Apps", description: "" }], + }], + }, + status: "answered", + origin: "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + }), + ); + return context; + } + + function job(context: Opened, generation = 1) { + context.plan.chat.job = { + id: `prose:${CARD}:decided:${CARD}:${generation}`, + kind: "prose", + target: CARD, + trigger: `decided:${CARD}:${generation}`, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + } + + function proseTool(context: Opened, exclusive = Service.exclusive) { + let anchors = 0; + let changes = 0; + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => exclusive(context.plan, action), + async publish() {}, + anchors() { + anchors++; + Service.anchors(context.plan, context.server, "test"); + }, + changes() { + changes++; + }, + }).find(item => item.name === "write_decision_prose"); + if (!found?.handler) throw new Error("write_decision_prose is missing"); + return { handler: found.handler, anchors: () => anchors, changes: () => changes }; + } + + function input(context: Opened, text = "We chose GitHub Apps.") { + return { revision: context.plan.revision, id: CARD, text }; + } + + let cleanup = async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; + }; + afterEach(async () => { + contexts = readContexts(); + try { + await cleanup(); + } finally { + writeContexts(contexts); + } + }); + return { CARD, QUESTION, OPTION, WIDGET, OTHER_WIDGET, contexts, opened, job, proseTool, input }; +} diff --git a/apps/server/src/agent/decision-prose.ts b/apps/server/src/agent/decision-prose.ts new file mode 100644 index 00000000..d7944238 --- /dev/null +++ b/apps/server/src/agent/decision-prose.ts @@ -0,0 +1,104 @@ +import * as Y from "yjs"; +import * as Comments from "../comments/service"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { decisionGeneration, proseJobTrigger } from "../questions/card-actions"; +import * as Questions from "../questions/service"; +import { proseOperation } from "../questions/write-prose"; +import { input } from "./decision-prose-fields"; +import type { Context } from "./card-tool-context"; +import type { ConversationPlan } from "@chopin/protocol"; + +export async function write( + context: Context, + job: ConversationPlan.Job, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ output: { title: string; mode: "insert" | "replace" }; revision: number }> { + return context.exclusive(async () => { + let { plan } = context; + let args = input(raw); + let active = () => { + let record = plan.records.get(args.id); + return plan.chat.job === job && plan.chat.turn === turn + && job.status === "running" && job.kind === "prose" && job.target === args.id + && record?.origin === "conversation" && record.status === "answered" + && !!record.owner && !!record.threadId + && job.trigger === proseJobTrigger(args.id, decisionGeneration(record)); + }; + if (job.target !== args.id) throw new Error("This job writes up a different decision."); + if (!active()) throw new Error("background Planner job generation is no longer current"); + if (args.revision !== plan.revision) throw new Error("stale; read_plan again"); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + + let source = room.project(plan.document); + let vector = Y.encodeStateVector(plan.document.doc); + let candidate: Service.Plan = { + ...plan, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }; + // Resolve old relationships before server-authored source edits can move their blocks. + Questions.rebase(candidate); + Comments.rebase(candidate); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + source, + [], + ); + stagedDocument.seq = plan.document.seq; + candidate.document = stagedDocument; + try { + let planned = proseOperation(candidate, args.id, args.text); + if (!planned.ok) throw new Error(planned.message); + for (let operations of planned.steps) { + let outcome = edit.apply(candidate, args.revision, operations); + if (!outcome.ok) { + throw new Error(outcome.reason === "stale" ? "stale; read_plan again" : outcome.message); + } + if (!outcome.mutation) throw new Error("decision prose did not change the document"); + } + Questions.rebase(candidate, source); + Comments.rebase(candidate); + let hash = room.digests(candidate.document)[planned.index]; + if (!hash) throw new Error("decision prose did not land in the document"); + Questions.setProse(candidate, args.id, [ + room.anchorAt(candidate.document, planned.index, hash), + ]); + if (!active() || args.revision !== plan.revision) { + throw new Error("background Planner job changed before its tool completed"); + } + let mutation: room.Mutation = { + update: Y.encodeStateAsUpdate(candidate.document.doc, vector), + source: room.project(candidate.document), + }; + let block = edit.outline(candidate)[planned.index]; + if (!block || block.type !== "paragraph") { + throw new Error("decision prose did not land as a paragraph"); + } + await Service.publishStaged(plan, context.server, context.room, candidate, mutation); + try { + context.changes([{ + kind: "added", + index: planned.index, + type: block.type, + preview: block.preview, + }]); + } catch (error) { + console.error("[agent/write_decision_prose] could not announce committed change:", error); + } + try { + context.anchors(); + } catch (error) { + console.error("[agent/write_decision_prose] could not announce committed anchors:", error); + } + let title = plan.records.get(args.id)?.definition.questions[0]?.question ?? "a decision"; + return { output: { title, mode: planned.mode }, revision: plan.revision }; + } finally { + stagedDocument.doc.destroy(); + } + }); +} diff --git a/apps/server/src/agent/heading-harness.test-fixtures.ts b/apps/server/src/agent/heading-harness.test-fixtures.ts new file mode 100644 index 00000000..cca5eb15 --- /dev/null +++ b/apps/server/src/agent/heading-harness.test-fixtures.ts @@ -0,0 +1,133 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import * as Service from "../plan/service"; + +import type { HarnessV1 } from "@ai-sdk/harness"; +import type { ActiveOwnerBinding } from "./active-owner"; +import type { DocumentRoom } from "./tools"; +import type { openPlan } from "../testing/plan"; + +export const INPUT = { revision: 0, title: "Sign in", goal: "Choose how people sign in." }; + +export function documentRoom(context: Awaited>): DocumentRoom { + return { + id: context.channel.id, + plan: context.plan, + server: context.server, + exclusive: action => Service.exclusive(context.plan, action), + persist: () => Service.persist(context.plan), + async publish() {}, + anchors() {}, + changes() {}, + }; +} + +export async function headingSession( + room: DocumentRoom, + submit: (result: unknown) => Promise, +) { + let names: string[] = []; + let destroyed = { session: 0, sandbox: 0 }; + let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "heading-test", + builtinTools: {}, + async doStart(start) { + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(options) { + names = options.tools.map(spec => spec.name); + let done = Promise.withResolvers(); + queueMicrotask(() => + options.emit({ + type: "tool-call", + toolCallId: "heading-1", + toolName: "draft_heading", + input: JSON.stringify(INPUT), + }) + ); + return { + done: done.promise, + async submitToolResult(value) { + await submit(value); + options.emit({ + type: "tool-result", + toolCallId: "heading-1", + toolName: "draft_heading", + result: value.output as string, + }); + options.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }); + done.resolve(); + }, + }; + }, + async doDestroy() { + destroyed.session++; + }, + async doCompact() {}, + async doContinueTurn() { + throw new Error("unused"); + }, + async doSuspendTurn() { + throw new Error("unused"); + }, + async doDetach() { + throw new Error("unused"); + }, + async doStop() { + throw new Error("unused"); + }, + }; + }, + }; + let owner: ActiveOwnerBinding = { + channelId: room.id, + token: "test-token", + repository: { id: "R_test", owner: "owner", name: "repository", defaultBranch: "main" }, + ownerSessionId: "owner-session", + ownerGeneration: 1, + credentialRevision: 1, + expiresAt: new Date(Date.now() + 60_000), + signal: new AbortController().signal, + currentToken: () => "test-token", + revalidate: async () => true, + release() {}, + }; + let opened = await openPlannerSession(owner, { + room, + repository: owner.repository, + instructions: "Title the document only.", + }, { + headingAgent: createPlannerAgent(fake, "heading"), + githubTools: async () => ({ + ok: true, + value: { + list_pull_requests: tool({ inputSchema: z.object({}), execute: async () => "[]" }), + pull_request_read: tool({ inputSchema: z.object({}), execute: async () => "unused" }), + }, + }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() { + destroyed.sandbox++; + }, + }) as never, + registerCredential: () => () => {}, + }); + if (!opened.ok) throw new Error(`Could not open test Planner: ${opened.error.kind}`); + return { session: opened.value, owner, names: () => names, destroyed }; +} diff --git a/apps/server/src/agent/heading-harness.test.ts b/apps/server/src/agent/heading-harness.test.ts new file mode 100644 index 00000000..fcd86809 --- /dev/null +++ b/apps/server/src/agent/heading-harness.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "bun:test"; +import { tool } from "ai"; +import { z } from "zod"; +import { openPlan } from "../testing/plan"; +import * as Service from "../plan/service"; +import * as Room from "../plan/room"; +import { openPlannerSession } from "../harness/session"; +import { scopedJobTools } from "./job-tools"; +import { documentRoom, headingSession, INPUT } from "./heading-harness.test-fixtures"; + +function running(plan: Service.Plan) { + plan.chat.job = { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +test("current Harness executes heading against durable memory before returning its result", async () => { + let context = await openPlan(); + running(context.plan); + let observed = false; + let hosted = await headingSession(documentRoom(context), async result => { + expect(result).toMatchObject({ output: expect.stringContaining('"ok": true') }); + let source = `# ${INPUT.title}\n\n${INPUT.goal}\n`; + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + expect((await Service.readStored(loaded!)).source).toBe(source); + expect(Room.project(context.plan.document)).toBe(source); + expect(result).toMatchObject({ + output: JSON.stringify( + { ok: true, title: INPUT.title, goal: INPUT.goal, revision: 1 }, + null, + 2, + ), + }); + observed = true; + }); + try { + let stream = await hosted.session.stream( + "Give the empty document a heading", + hosted.owner.signal, + ); + for await (let _part of stream.fullStream) {} + expect(observed).toBe(true); + expect(context.plan.chat.jobOutput).toBe( + JSON.stringify({ title: INPUT.title, goal: INPUT.goal }), + ); + expect(hosted.names()).toContain("draft_heading"); + expect(hosted.names()).toContain("read_plan"); + for ( + let name of [ + "edit_plan", + "ask", + "anchor_plan", + "edit_implementation_graph", + "create_research_workspace", + "bash", + "read_file", + "web_search", + "refine_decision", + "write_decision_prose", + ] + ) expect(hosted.names()).not.toContain(name); + } finally { + await hosted.session.destroy(); + await Service.close(context.plan); + } + expect(hosted.destroyed).toEqual({ session: 1, sandbox: 1 }); +}); + +test("actual execution boundary refuses foreign writes even when a harness keeps old tools", async () => { + let context = await openPlan(); + let writes = 0; + let scoped = scopedJobTools({ + edit_plan: tool({ + inputSchema: z.object({}), + execute: async () => { + writes++; + return "written"; + }, + }), + }, documentRoom(context)); + try { + let options = { toolCallId: "old-tool", messages: [], context: undefined }; + expect(await scoped.edit_plan!.execute!({} as never, options)).toBe("written"); + running(context.plan); + expect(await scoped.edit_plan!.execute!({} as never, options)).toBe( + "Error: This is a background heading job; use only draft_heading. edit_plan is not available.", + ); + expect(writes).toBe(1); + expect(Room.project(context.plan.document)).toBe(""); + } finally { + await Service.close(context.plan); + } +}); + +test("unsupported background jobs cannot open an ordinary writable Planner session", async () => { + let context = await openPlan(); + running(context.plan); + context.plan.chat.job = { ...context.plan.chat.job!, kind: "unknown" as never, target: "thread" }; + let called = false; + try { + let opened = await openPlannerSession({} as never, { + room: documentRoom(context), + repository: {} as never, + instructions: "Unsupported background kind", + }, { + githubTools: async () => { + called = true; + throw new Error("must not fetch tools"); + }, + }); + expect(opened.ok).toBe(false); + if (opened.ok) throw new Error("unsupported job opened a session"); + expect(opened.error).toMatchObject({ kind: "Unavailable" }); + if (!("cause" in opened.error)) throw new Error("missing failure cause"); + expect((opened.error.cause as Error).message).toBe( + "Background unknown tools are not available", + ); + expect(called).toBe(false); + expect(Room.project(context.plan.document)).toBe(""); + } finally { + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/agent/heading-tool.test-bridge.ts b/apps/server/src/agent/heading-tool.test-bridge.ts new file mode 100644 index 00000000..d966e127 --- /dev/null +++ b/apps/server/src/agent/heading-tool.test-bridge.ts @@ -0,0 +1,18 @@ +import { jobTools } from "./job-tools"; +import type { DocumentRoom } from "./tools"; + +type ArchivedContext = Omit & { room: string }; + +/** The archived callbacks use the old handler shape; execute the current tool unchanged. */ +export function toolbox(context: ArchivedContext) { + let room = { ...context, id: context.room }; + return [{ + name: "draft_heading", + handler: (raw: unknown, _options: never) => + jobTools.draft_heading.execute!(raw as never, { + context: { room }, + toolCallId: "heading-test", + messages: [], + }), + }]; +} diff --git a/apps/server/src/agent/heading-tool.test.ts b/apps/server/src/agent/heading-tool.test.ts new file mode 100644 index 00000000..5143381a --- /dev/null +++ b/apps/server/src/agent/heading-tool.test.ts @@ -0,0 +1,226 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { QuestionnaireNode } from "@chopin/dialect"; +import { $nodesOfType } from "lexical"; + +import { toolbox } from "./heading-tool.test-bridge"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { ConversationPlan } from "@chopin/protocol"; + +const CARD = ` + + + +`; + +let contexts: Awaited>[] = []; + +afterEach(async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; +}); + +async function opened(source: string) { + let context = await openPlan(source); + contexts.push(context); + return context; +} + +function headingJob(plan: Awaited>) { + plan.chat.job = { + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +function headingTool(context: Awaited>, exclusive = Service.exclusive) { + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => exclusive(context.plan, action), + async publish() {}, + anchors() {}, + changes() {}, + }).find(item => item.name === "draft_heading"); + if (!found?.handler) throw new Error("draft_heading is missing"); + return { handler: found.handler }; +} + +function cardKey(context: Awaited>): string { + let key = ""; + context.plan.document.editor.getEditorState().read(() => { + key = $nodesOfType(QuestionnaireNode)[0]?.getKey() ?? ""; + }); + return key; +} + +const INPUT = { + revision: 0, + title: "Authentication Strategy", + goal: "Goal: Decide how people sign in.", +}; + +test("draft_heading titles an empty document in one durable update", async () => { + let context = await opened(""); + headingJob(context.plan); + let result = await headingTool(context).handler(INPUT, {} as never); + expect(JSON.parse(String(result))).toEqual({ + ok: true, + revision: 1, + title: INPUT.title, + goal: INPUT.goal, + }); + expect(room.project(context.plan.document)).toBe(`# ${INPUT.title}\n\n${INPUT.goal}\n`); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); +}); + +test("draft_heading commits one heading and paragraph before the existing card", async () => { + let context = await opened(CARD); + headingJob(context.plan); + let before = room.project(context.plan.document); + let key = cardKey(context); + let threads = context.plan.threads; + let result = await headingTool(context).handler(INPUT, {} as never); + expect(JSON.parse(String(result))).toEqual({ + ok: true, + revision: 1, + title: INPUT.title, + goal: INPUT.goal, + }); + let expected = `# ${INPUT.title}\n\n${INPUT.goal}\n\n${before}`; + expect(room.project(context.plan.document)).toBe(expected); + expect(cardKey(context)).toBe(key); + expect(context.plan.threads).not.toBe(threads); + expect(context.plan.document.seq).toBe(1); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + expect(JSON.parse(context.plan.chat.jobOutput!)).toEqual({ + title: INPUT.title, + goal: INPUT.goal, + }); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(expected); +}); + +test("draft_heading replaces only the empty H1 template before cards", async () => { + let context = await opened(`# \n\n${CARD}`); + headingJob(context.plan); + let before = room.project(context.plan.document); + expect(before.startsWith("#\n\n")).toBe(true); + let key = cardKey(context); + let result = await headingTool(context).handler(INPUT, {} as never); + expect(JSON.parse(String(result)).ok).toBe(true); + expect(room.project(context.plan.document)).toBe( + `# ${INPUT.title}\n\n${INPUT.goal}\n\n${before.slice("#\n\n".length)}`, + ); + expect(cardKey(context)).toBe(key); +}); + +test("draft_heading refuses human prose and leaves the document untouched", async () => { + for (let source of ["Somebody wrote this.\n", "# Human heading\n\nA human goal.\n"]) { + let context = await opened(source); + headingJob(context.plan); + let before = room.project(context.plan.document); + let result = await headingTool(context).handler(INPUT, {} as never); + expect(String(result)).toBe( + "Error: The document already has prose. A background job only titles an empty document.", + ); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } +}); + +test("draft_heading rejects missing, wrong, stale, and replaced job scope", async () => { + let context = await opened(""); + let missing = await headingTool(context).handler(INPUT, {} as never); + expect(String(missing)).toContain("only available to a background Planner job"); + headingJob(context.plan); + context.plan.chat.job = { ...context.plan.chat.job!, target: "other" } as ConversationPlan.Job; + expect(String(await headingTool(context).handler(INPUT, {} as never))).toContain( + "heading job target is not the document", + ); + headingJob(context.plan); + expect(String(await headingTool(context).handler({ ...INPUT, revision: 99 }, {} as never))) + .toContain("stale; read_plan again"); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let delayed = headingTool(context, (_plan, action) => { + entered.resolve(); + return release.promise.then(action); + }); + let result = delayed.handler(INPUT, {} as never); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: "heading:document:m1" }; + release.resolve(); + expect(String(await result)).toContain("background Planner job changed"); + expect(room.project(context.plan.document)).toBe(""); + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); + +test("draft_heading rejects lengths, CR titles, blank lines, and non-paragraph goals", async () => { + let context = await opened(""); + headingJob(context.plan); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let input of [ + { ...INPUT, title: "x".repeat(81) }, + { ...INPUT, title: "First\rSecond" }, + { ...INPUT, title: "# Already a heading" }, + { ...INPUT, extra: "surprise" }, + { ...INPUT, goal: "x".repeat(401) }, + { ...INPUT, goal: "First\r\n\r\nSecond" }, + { ...INPUT, goal: "- a list item" }, + { ...INPUT, goal: "```ts\ncode\n```" }, + { ...INPUT, goal: "" }, + ] + ) { + expect(String(await headingTool(context).handler(input, {} as never))).toStartWith("Error:"); + expect(room.project(context.plan.document)).toBe(""); + } + } finally { + errors.mockRestore(); + } + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); + +test("failed heading persistence leaves no live edit, publication, or job output", async () => { + let context = await opened(CARD); + headingJob(context.plan); + let before = room.project(context.plan.document); + let outlines = new Map(context.plan.outlines); + let threads = context.plan.threads; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + context.backend.fatal = () => {}; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(String(await headingTool(context).handler(INPUT, {} as never))) + .toBe("Error: storage unavailable"); + } finally { + errors.mockRestore(); + context.storage.collaboration.commit = original; + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.revision).toBe(0); + expect(context.plan.document.seq).toBe(0); + expect(context.plan.outlines).toEqual(outlines); + expect(context.plan.threads).toBe(threads); + expect(context.plan.chat.jobOutput).toBeUndefined(); + expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(0); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(loaded)).source).toBe(before); +}); diff --git a/apps/server/src/agent/heading-tool.ts b/apps/server/src/agent/heading-tool.ts new file mode 100644 index 00000000..2dbc2d8d --- /dev/null +++ b/apps/server/src/agent/heading-tool.ts @@ -0,0 +1,124 @@ +import * as Y from "yjs"; +import { parse } from "@chopin/dialect/parse"; +import { serialize } from "@chopin/dialect/serialize"; + +import * as Comments from "../comments/service"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Context } from "./job-tool-context"; + +type Input = { revision: number; title: string; goal: string }; + +function input(raw: unknown): Input { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("draft_heading arguments must be an object"); + } + let value = raw as Record; + if ( + Object.keys(value).length !== 3 + || !Object.hasOwn(value, "revision") || !Object.hasOwn(value, "title") + || !Object.hasOwn(value, "goal") + ) { + throw new Error("draft_heading accepts only revision, title, and goal"); + } + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) { + throw new Error("revision must be a nonnegative integer"); + } + let title = typeof value.title === "string" ? value.title.trim() : ""; + if ( + !title || title.length > 80 || /[\r\n\u2028\u2029]/.test(value.title as string) + || title.startsWith("#") + ) { + throw new Error("title must be 1–80 characters on one line."); + } + let goal = typeof value.goal === "string" ? value.goal.trim() : ""; + if ( + !goal || goal.length > 400 + || /(?:\r\n|\r|\n)[ \t]*(?:\r\n|\r|\n)/.test(value.goal as string) + ) { + throw new Error("goal must be 1–400 characters in one paragraph."); + } + let blocks; + try { + blocks = parse(`# ${title}\n\n${goal}\n`).children; + } catch { + throw new Error("goal must be 1–400 characters in one paragraph."); + } + if ( + blocks.length !== 2 || blocks[0]?.type !== "heading" || blocks[0].depth !== 1 + || blocks[1]?.type !== "paragraph" + ) { + throw new Error("goal must be 1–400 characters in one paragraph."); + } + return { revision: value.revision as number, title, goal }; +} + +/** Commit a heading and goal together, preserving every existing decision card. */ +export async function draftHeading( + context: Context, + job: ConversationPlan.Job, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ output: { title: string; goal: string }; revision: number }> { + return context.exclusive(async () => { + let { plan } = context; + let active = () => + plan.chat.job === job && plan.chat.turn === turn + && job.status === "running" && job.kind === "heading"; + if (!active()) throw new Error("background Planner job changed before its tool completed"); + if (job.target !== "document") throw new Error("heading job target is not the document"); + let { revision, title, goal } = input(raw); + if (revision !== plan.revision) throw new Error("stale; read_plan again"); + if (!room.headingAllowed(plan.document)) { + throw new Error( + "The document already has prose. A background job only titles an empty document.", + ); + } + + let source = room.project(plan.document); + let original = parse(source); + let placeholder = original.children[0]?.type === "heading" + && original.children[0].depth === 1 && original.children[0].children.length === 0; + let heading = parse(`# ${title}\n\n${goal}\n`); + let next = serialize({ + type: "root", + children: [...heading.children, ...original.children.slice(placeholder ? 1 : 0)], + }); + + // Resolve old anchors before the server edit moves any card in the live tree. + let candidate: Service.Plan = { + ...plan, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }; + Questions.rebase(candidate); + Comments.rebase(candidate); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + source, + [], + ); + stagedDocument.seq = plan.document.seq; + candidate.document = stagedDocument; + try { + let result = edit.replace(candidate, revision, next); + if (!result.ok) { + throw new Error(result.reason === "stale" ? "stale; read_plan again" : result.message); + } + if (!result.mutation) throw new Error("heading did not change the document"); + if (!active()) throw new Error("background Planner job changed before its tool completed"); + await Service.publishStaged(plan, context.server, context.room, candidate, result.mutation); + context.changes(result.changes); + context.anchors(); + return { output: { title, goal }, revision: plan.revision }; + } finally { + stagedDocument.doc.destroy(); + } + }); +} diff --git a/apps/server/src/agent/job-scope.test.ts b/apps/server/src/agent/job-scope.test.ts new file mode 100644 index 00000000..a72011f1 --- /dev/null +++ b/apps/server/src/agent/job-scope.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; + +import { create } from "../chat/service"; +import { JOB_TOOLS, refusal, runJobTool, WRITE_TOOLS } from "./job-scope"; + +import type { ConversationPlan } from "@chopin/protocol"; + +const JOB: ConversationPlan.Job = { + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", +}; + +describe("background Planner job scope", () => { + test("ordinary turns may write normally but cannot call a job tool", () => { + expect(JOB_TOOLS).toEqual({ + heading: "draft_heading", + refine: "refine_decision", + suggest: "refine_decision", + prose: "write_decision_prose", + }); + expect(WRITE_TOOLS.has("create_research_workspace")).toBe(true); + expect(WRITE_TOOLS.has("revise_open_decision")).toBe(true); + expect(refusal(undefined, "read_plan")).toBeUndefined(); + expect(refusal(undefined, "edit_plan")).toBeUndefined(); + expect(refusal(undefined, "refine_decision")).toBe( + "refine_decision is only available to a background Planner job.", + ); + }); + + test("a job can read and use exactly its own writing tool", () => { + expect(refusal(JOB, "read_plan")).toBeUndefined(); + expect(refusal(JOB, "refine_decision")).toBeUndefined(); + for ( + let name of [ + "edit_plan", + "ask", + "anchor_plan", + "edit_implementation_graph", + "create_research_workspace", + "revise_open_decision", + "draft_heading", + "write_decision_prose", + ] + ) { + expect(refusal(JOB, name)).toBe( + `This is a background refine job; use only refine_decision. ${name} is not available.`, + ); + } + }); +}); + +describe("runJobTool", () => { + test("records one bounded JSON output for the current running job", async () => { + let chat = create(); + chat.job = { ...JOB }; + let current = chat.job; + let result = await runJobTool(chat, "refine_decision", async job => { + expect(job).toBe(current); + return { output: { added: 2 }, skipped: ["duplicate"] }; + }); + expect(result).toEqual({ ok: true, added: 2, skipped: ["duplicate"] }); + expect(chat.jobOutput).toBe(JSON.stringify({ added: 2 })); + }); + + test("refuses absent, inactive, foreign, and read-only tool calls before producing", async () => { + let chat = create(); + let calls = 0; + let produce = async () => { + calls++; + return { output: {} }; + }; + await expect(runJobTool(chat, "refine_decision", produce)).rejects.toThrow( + "only available to a background Planner job", + ); + chat.job = { ...JOB, status: "failed" }; + await expect(runJobTool(chat, "refine_decision", produce)).rejects.toThrow( + "running background Planner job", + ); + chat.job = { ...JOB }; + await expect(runJobTool(chat, "draft_heading", produce)).rejects.toThrow( + "use only refine_decision", + ); + await expect(runJobTool(chat, "read_plan", produce)).rejects.toThrow( + "only its own tool", + ); + expect(calls).toBe(0); + expect(chat.jobOutput).toBeUndefined(); + }); + + test("an awaited tool cannot record success for a replacement job", async () => { + let chat = create(); + chat.job = { ...JOB }; + let gate = Promise.withResolvers(); + let operation = runJobTool(chat, "refine_decision", async () => { + await gate.promise; + return { output: { added: 1 } }; + }); + chat.job = { ...JOB, id: "refine:W2:m2", target: "W2", trigger: "m2" }; + gate.resolve(); + await expect(operation).rejects.toThrow("background Planner job changed"); + expect(chat.jobOutput).toBeUndefined(); + }); + + test("an awaited tool cannot record success for a replacement turn", async () => { + let chat = create(); + chat.job = { ...JOB }; + chat.turn = { id: "turn-1", handle: "chopin", started: 1, responded: false }; + let gate = Promise.withResolvers(); + let operation = runJobTool(chat, "refine_decision", async () => { + await gate.promise; + return { output: { added: 1 } }; + }); + chat.turn = { id: "turn-2", handle: "chopin", started: 2, responded: false }; + gate.resolve(); + await expect(operation).rejects.toThrow("background Planner job changed"); + expect(chat.jobOutput).toBeUndefined(); + }); + + test("rejects output that cannot be stored as a bounded JSON summary", async () => { + let chat = create(); + chat.job = { ...JOB }; + await expect(runJobTool(chat, "refine_decision", async () => ({ output: undefined }))) + .rejects.toThrow("valid JSON"); + await expect(runJobTool(chat, "refine_decision", async () => ({ + output: { detail: "x".repeat(4_096) }, + }))).rejects.toThrow("4096"); + expect(chat.jobOutput).toBeUndefined(); + }); +}); diff --git a/apps/server/src/agent/job-scope.ts b/apps/server/src/agent/job-scope.ts new file mode 100644 index 00000000..3e2bad56 --- /dev/null +++ b/apps/server/src/agent/job-scope.ts @@ -0,0 +1,71 @@ +/** A background Planner job may read freely but may write only through its own tool. */ + +import type { ConversationPlan } from "@chopin/protocol"; +import type { Chat } from "../chat/service"; + +type Job = ConversationPlan.Job; + +export const JOB_TOOLS: Record = { + heading: "draft_heading", + refine: "refine_decision", + suggest: "refine_decision", + prose: "write_decision_prose", +}; + +export const WRITE_TOOLS: ReadonlySet = new Set([ + "edit_plan", + "ask", + "anchor_plan", + "edit_implementation_graph", + "create_research_workspace", + "revise_open_decision", + ...Object.values(JOB_TOOLS), +]); + +export function refusal(job: Job | undefined, tool: string): string | undefined { + let own = job ? JOB_TOOLS[job.kind] : undefined; + if (!job) { + return Object.values(JOB_TOOLS).includes(tool) + ? `${tool} is only available to a background Planner job.` + : undefined; + } + if (!WRITE_TOOLS.has(tool) || tool === own) return; + return `This is a background ${job.kind} job; use only ${own}. ${tool} is not available.`; +} + +export async function runJobTool( + chat: Chat, + tool: string, + produce: (job: Job) => Promise<{ output: unknown } & Record>, +): Promise { + let job = chat.job; + let refused = refusal(job, tool); + if (refused) throw new Error(refused); + if (!job || job.status !== "running") { + throw new Error("a running background Planner job is required"); + } + if (tool !== JOB_TOOLS[job.kind]) { + throw new Error("a background Planner job may run only its own tool"); + } + + let turn = chat.turn; + let id = job.id; + let { output, ...reply } = await produce(job); + if (chat.job !== job || job.id !== id || job.status !== "running" || chat.turn !== turn) { + throw new Error("background Planner job changed before its tool completed"); + } + let serialized: string | undefined; + try { + serialized = JSON.stringify(output); + } catch { + throw new Error("background Planner job output must be valid JSON"); + } + if (typeof serialized !== "string") { + throw new Error("background Planner job output must be valid JSON"); + } + if (serialized.length > 4_096) { + throw new Error("background Planner job output exceeds 4096 characters"); + } + chat.jobOutput = serialized; + return { ok: true, ...(output as object), ...reply }; +} diff --git a/apps/server/src/agent/job-tool-context.ts b/apps/server/src/agent/job-tool-context.ts new file mode 100644 index 00000000..c8fc46e7 --- /dev/null +++ b/apps/server/src/agent/job-tool-context.ts @@ -0,0 +1,18 @@ +import type { DocumentRoom } from "./tools"; + +export type Context = + & Pick + & { + room: string; + }; + +export function jobToolContext(room: DocumentRoom): Context { + return { + plan: room.plan, + server: room.server, + room: room.id, + exclusive: room.exclusive, + anchors: room.anchors, + changes: room.changes, + }; +} diff --git a/apps/server/src/agent/job-tools.ts b/apps/server/src/agent/job-tools.ts new file mode 100644 index 00000000..ff4ee653 --- /dev/null +++ b/apps/server/src/agent/job-tools.ts @@ -0,0 +1,66 @@ +import { jsonSchema, tool } from "ai"; +import { z } from "zod"; +import { draftHeading } from "./heading-tool"; +import { jobToolContext } from "./job-tool-context"; +import { refusal, runJobTool } from "./job-scope"; + +import type { ToolSet } from "ai"; +import type { DocumentRoom } from "./tools"; + +let contextSchema = z.object({ + room: z.custom(value => + !!value && typeof value === "object" + && typeof (value as DocumentRoom).id === "string" && !!(value as DocumentRoom).plan + ), +}); + +export let jobTools = { + draft_heading: tool({ + description: "Background job only. Give an empty document its title and one-paragraph " + + "goal, drawn from what people have said. Refused once the document has authored prose.", + inputSchema: jsonSchema<{ revision: number; title: string; goal: string }>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + title: { type: "string", minLength: 1, maxLength: 80 }, + goal: { type: "string", minLength: 1, maxLength: 400 }, + }, + required: ["revision", "title", "goal"], + additionalProperties: false, + }), + contextSchema, + execute: async (raw, { context: { room } }): Promise => { + try { + let turn = room.plan.chat.turn; + let result = await runJobTool( + room.plan.chat, + "draft_heading", + job => draftHeading(jobToolContext(room), job, turn, raw), + ); + return JSON.stringify(result, null, 2) ?? "null"; + } catch (err) { + let message = err instanceof Error ? err.message : String(err); + console.error("[agent/draft_heading]", err); + return `Error: ${message}`; + } + }, + }), +}; + +/** Check the live job at execution, even if a harness ignores its active tool list. */ +export function scopedJobTools(tools: ToolSet, room: DocumentRoom): ToolSet { + return Object.fromEntries( + Object.entries(tools).map(([name, original]) => { + let execute = original.execute; + if (!execute) return [name, original]; + return [name, { + ...original, + execute: (input, options) => { + let denied = refusal(room.plan.chat?.job, name); + if (denied) return Promise.resolve(`Error: ${denied}`); + return execute(input, options); + }, + }]; + }), + ); +} diff --git a/apps/server/src/agent/refine-fields.ts b/apps/server/src/agent/refine-fields.ts new file mode 100644 index 00000000..19b8896e --- /dev/null +++ b/apps/server/src/agent/refine-fields.ts @@ -0,0 +1,96 @@ +import { assertSourceShape } from "../conversation-plan/sources"; +import type { ConversationPlan } from "@chopin/protocol"; + +export type Address = { index: number; digest: string }; + +export type Option = { label: string; rationale: string; source?: ConversationPlan.SourceRef }; + +export type Input = { + revision: number; + id: string; + title?: string; + add_options: Option[]; + place_after?: Address; +}; + +export function input(raw: unknown): Input { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("refine_decision arguments must be an object"); + } + let value = raw as Record; + if ( + !Object.hasOwn(value, "revision") || !Object.hasOwn(value, "id") + || Object.keys(value).some(key => + !["revision", "id", "title", "add_options", "place_after"].includes(key) + ) + ) throw new Error("refine_decision arguments are invalid"); + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) { + throw new Error("revision must be a nonnegative integer"); + } + if (typeof value.id !== "string" || !value.id) throw new Error("decision id is required"); + let title: string | undefined; + if (Object.hasOwn(value, "title")) { + title = typeof value.title === "string" ? value.title.trim() : ""; + if (!title || title.length > 80 || /[\r\n\u2028\u2029]/.test(value.title as string)) { + throw new Error("title must be 1–80 characters on one line."); + } + } + let add_options: Option[] = []; + if (Object.hasOwn(value, "add_options")) { + if (!Array.isArray(value.add_options) || value.add_options.length > 10) { + throw new Error("add_options must contain at most ten options"); + } + for (let rawOption of value.add_options) { + if ( + !rawOption || typeof rawOption !== "object" || Array.isArray(rawOption) + || Object.keys(rawOption).some(key => + key !== "label" && key !== "rationale" && key !== "source" + ) + || !Object.hasOwn(rawOption, "label") || !Object.hasOwn(rawOption, "rationale") + ) throw new Error("each option needs a label and rationale"); + let option = rawOption as Record; + let label = typeof option.label === "string" ? option.label.trim() : ""; + let rationale = typeof option.rationale === "string" ? option.rationale.trim() : ""; + if (!label || label.length > 200) { + throw new Error("option label must be 1–200 characters"); + } + if (!rationale || rationale.length > 300) { + throw new Error("option rationale must be 1–300 characters"); + } + let source: ConversationPlan.SourceRef | undefined; + if (Object.hasOwn(option, "source")) { + try { + assertSourceShape(option.source); + } catch { + throw new Error("source-shape"); + } + source = option.source; + if (source.role !== "option" && source.role !== "question") { + throw new Error("option source must have option or question role"); + } + } + add_options.push({ label, rationale, ...(source ? { source } : {}) }); + } + } + let place_after: Address | undefined; + if (Object.hasOwn(value, "place_after")) { + let rawAddress = value.place_after; + if ( + !rawAddress || typeof rawAddress !== "object" || Array.isArray(rawAddress) + || Object.keys(rawAddress).sort().join(",") !== "digest,index" + ) throw new Error("place_after must identify one block"); + let address = rawAddress as Record; + if ( + !Number.isSafeInteger(address.index) || (address.index as number) < 0 + || typeof address.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(address.digest) + ) throw new Error("place_after must identify one block"); + place_after = { index: address.index as number, digest: address.digest }; + } + return { + revision: value.revision as number, + id: value.id, + ...(title ? { title } : {}), + add_options, + ...(place_after ? { place_after } : {}), + }; +} diff --git a/apps/server/src/agent/refine-placement.ts b/apps/server/src/agent/refine-placement.ts new file mode 100644 index 00000000..592a1b24 --- /dev/null +++ b/apps/server/src/agent/refine-placement.ts @@ -0,0 +1,43 @@ +import * as Y from "yjs"; +import * as Comments from "../comments/service"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import type { Context } from "./card-tool-context"; +import type { Address } from "./refine-fields"; + +export async function placeCard( + context: Context, + id: string, + at: Address, + beforePublish: () => void, +): Promise { + let { plan } = context; + let source = room.project(plan.document); + let candidate: Service.Plan = { + ...plan, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }; + Questions.rebase(candidate); + Comments.rebase(candidate); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + source, + [], + ); + stagedDocument.seq = plan.document.seq; + candidate.document = stagedDocument; + try { + let mutation = Questions.place(candidate, [{ widget: id, blocks: [at] }]); + if (!mutation) return false; + beforePublish(); + await Service.publishStaged(plan, context.server, context.room, candidate, mutation); + context.anchors(); + return true; + } finally { + stagedDocument.doc.destroy(); + } +} diff --git a/apps/server/src/agent/refine-tool-1.test.ts b/apps/server/src/agent/refine-tool-1.test.ts new file mode 100644 index 00000000..5b3f917c --- /dev/null +++ b/apps/server/src/agent/refine-tool-1.test.ts @@ -0,0 +1,129 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import type { Chat } from "@chopin/protocol"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call, source, linkQuestion } = fixture; + +test("refine_decision persists one question mention for two named alternatives", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-question", + author: { kind: "member", handle: "jev" }, + text: "Could we use Lexical or ProseMirror?", + ts: 1, + }; + context.plan.chat.entries.push(entry); + let ref = { ...source(entry, "Lexical or ProseMirror"), role: "question" as const }; + linkQuestion(context, ref); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Lexical", rationale: "Named in Jev's question.", source: ref }, + { label: "ProseMirror", rationale: "Named in Jev's question.", source: ref }, + ], + }), + ); + expect(answer.ok).toBe(true); + expect(answer.added).toHaveLength(2); + for (let id of answer.added) { + expect(context.plan.records.get(context.id)?.optionOrigins[id]?.source).toEqual(ref); + } + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + await expect(Service.readStored(loaded)).resolves.toBeDefined(); + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as { + questions: Questions.Record[]; + }; + sidecar.questions[0]!.optionOrigins[answer.added[0]]!.source = { + ...ref, + quote: "Lexical", + end: ref.start + 7, + }; + await expect(Service.readStored({ ...loaded, sidecar: sidecar as never })) + .rejects.toThrow(/option question source outside its card thread/); +}); + +test("refine_decision rejects question refs outside the card thread and other roles", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-question", + author: { kind: "member", handle: "jev" }, + text: "Could we use Lexical or ProseMirror?", + ts: 1, + }; + context.plan.chat.entries.push(entry); + let unrelated: Chat.Entry = { ...entry, id: "other-question", ts: 2 }; + context.plan.chat.entries.push(unrelated); + let ref = { ...source(entry, "Lexical or ProseMirror"), role: "question" as const }; + linkQuestion(context, ref); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let citation of [ + { ...ref, quote: "Lexical", end: ref.start + 7 }, + { ...ref, messageId: unrelated.id }, + { ...ref, author: { kind: "member", handle: "other" } }, + { ...ref, role: "reason" }, + { ...ref, role: "constraint" }, + ] + ) { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which editor should we use?", + add_options: [{ label: "Lexical", rationale: "Named in question.", source: citation }], + }); + expect(answer).toContain("Error"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toEqual([]); + } + } finally { + errors.mockRestore(); + } +}); + +test("refine_decision persists a verified exact chat source and restores it", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-a", + author: { kind: "member", handle: "jev" }, + text: "We could use Lexical for selection.", + ts: 1, + }; + context.plan.chat.entries.push(entry); + job(context.plan, context.id); + let citation = source(entry, "Lexical"); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "Lexical", rationale: "Jev proposed it.", source: citation }], + }), + ); + expect(answer.ok).toBe(true); + let origin = context.plan.records.get(context.id)?.optionOrigins[answer.added[0]]; + expect(origin).toEqual({ origin: "planner", rationale: "Jev proposed it.", source: citation }); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = loaded.sidecar ?? loaded.snapshot!.sidecar; + expect( + (sidecar as { questions: Questions.Record[] }).questions[0]?.optionOrigins[answer.added[0]], + ) + .toEqual(origin!); + await expect(Service.readStored(loaded)).resolves.toBeDefined(); + let damaged = structuredClone(sidecar) as { questions: Questions.Record[] }; + damaged.questions[0]!.optionOrigins[answer.added[0]]!.source!.quote = "TipTaps"; + await expect(Service.readStored({ ...loaded, sidecar: damaged as never })) + .rejects.toThrow(/invalid option source/); +}); diff --git a/apps/server/src/agent/refine-tool-2.test.ts b/apps/server/src/agent/refine-tool-2.test.ts new file mode 100644 index 00000000..32762597 --- /dev/null +++ b/apps/server/src/agent/refine-tool-2.test.ts @@ -0,0 +1,36 @@ +import { test } from "bun:test"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { expectExplicitSourceNotAuthoritative } = fixture; + +test("refine_decision rejects an explicit unrelated member quote as an option source", async () => { + await expectExplicitSourceNotAuthoritative({ + id: "unrelated-option-source", + author: { kind: "member", handle: "jev" }, + text: "We should use server-side caching for this endpoint.", + ts: 1, + }, "server-side caching"); +}); + +test("refine_decision rejects an explicit agent-authored option source", async () => { + await expectExplicitSourceNotAuthoritative({ + id: "agent-option-source", + author: { kind: "agent" }, + text: "Tiptap is a good fit for the editor.", + ts: 1, + }, "Tiptap"); +}); + +test("refine_decision rejects an explicit quote that bundles same-approach choices", async () => { + await expectExplicitSourceNotAuthoritative({ + id: "paired-option-source", + author: { kind: "member", handle: "jev" }, + text: "Tiptap or Lexical would work for the editor.", + ts: 1, + }, "Tiptap or Lexical"); +}); diff --git a/apps/server/src/agent/refine-tool-3.test.ts b/apps/server/src/agent/refine-tool-3.test.ts new file mode 100644 index 00000000..d29e0c35 --- /dev/null +++ b/apps/server/src/agent/refine-tool-3.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; +import * as Service from "../plan/service"; +import { effectsFor } from "../conversation-plan/effects"; +import type { ConversationPlan } from "@chopin/protocol"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call, d01Messages } = fixture; + +test("refine_decision sources unique D01 options from the saved member list", async () => { + let context = await opened(); + let { m2 } = d01Messages(context); + let candidates = [ + { label: "Tiptap", quote: "Tiptap", start: 0, end: 6 }, + { label: "bare ProseMirror", quote: "bare ProseMirror", start: 8, end: 24 }, + { label: "Lexical", quote: "Lexical", start: 26, end: 33 }, + { + label: "just native Selection and Range with our own document model", + quote: "just native Selection and Range with our own document model", + start: 38, + end: 97, + }, + ] as const; + job(context.plan, context.id); + let add_options = candidates.map(({ label }) => ({ + label, + rationale: "Rob listed this editor approach in m2.", + })); + let response = await call(context, { + revision: context.plan.revision, + id: context.id, + add_options, + }); + expect(response).not.toContain("Error:"); + let answer = JSON.parse(response); + let ids: string[] = answer.added; + let expectedSources: ConversationPlan.SourceRef[] = candidates.map(candidate => ({ + messageId: m2.id, + author: m2.author as ConversationPlan.SourceAuthor, + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + role: "option" as const, + })); + expect(ids).toHaveLength(candidates.length); + let optionEvents = context.plan.conversationPlan.events.filter(event => + event.type === "option.added" && ids.includes(event.contribution.id) + ); + expect(optionEvents.map(event => event.type === "option.added" && event.contribution.id)) + .toEqual(ids); + expect(optionEvents.map(event => event.type === "option.added" ? event.source : undefined)) + .toEqual(expectedSources); + + let retry = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options, + }), + ); + expect(retry.added).toEqual([]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => option.id), + ).toEqual(ids); + optionEvents = context.plan.conversationPlan.events.filter(event => + event.type === "option.added" && ids.includes(event.contribution.id) + ); + expect(optionEvents.map(event => event.type === "option.added" && event.contribution.id)) + .toEqual(ids); + + await Service.close(context.plan); + contexts = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + contexts.push({ ...context, plan: restored }); + let restoredRecord = restored.records.get(context.id)!; + expect(restoredRecord.definition.questions[0]?.options.map(option => option.id)).toEqual(ids); + let restoredOptionEvents = restored.conversationPlan.events.filter(event => + event.type === "option.added" && ids.includes(event.contribution.id) + ); + expect( + restoredOptionEvents.map(event => + event.type === "option.added" ? event.contribution.id : undefined + ), + ).toEqual(ids); + expect( + restoredOptionEvents.map(event => event.type === "option.added" ? event.source : undefined), + ).toEqual(expectedSources); + expect( + effectsFor(restoredOptionEvents, restored.conversationPlan, undefined, restored.records), + ).toEqual([]); + expect(ids.map(id => restoredRecord.optionOrigins[id]?.source)).toEqual(expectedSources); +}); + +test("refine_decision matches the D01 native option without its leading chat filler", async () => { + let context = await opened(); + let { m2 } = d01Messages(context); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [{ + label: "Native Selection and Range with our own document model", + rationale: "Rob named this browser-native editor approach in m2.", + }], + }), + ); + let expected: ConversationPlan.SourceRef = { + messageId: m2.id, + author: m2.author as ConversationPlan.SourceAuthor, + quote: "just native Selection and Range with our own document model", + start: 38, + end: 97, + role: "option", + }; + expect(answer.added).toHaveLength(1); + let optionId = answer.added[0] as string; + let optionEvent = context.plan.conversationPlan.events.find(event => + event.type === "option.added" && event.contribution.id === optionId + ); + expect(optionEvent?.type === "option.added" ? optionEvent.source : undefined).toEqual(expected); + expect(context.plan.records.get(context.id)?.optionOrigins[optionId]?.source).toEqual(expected); +}); + +test("refine_decision keeps Just Eat, unrelated, bundled, and ambiguous evidence distinct", async () => { + let context = await opened(); + let { m2 } = d01Messages(context); + context.plan.chat.entries.push({ + id: "just-eat", + author: { kind: "member", handle: "Rob" }, + text: "Just Eat", + ts: 3, + }); + context.plan.chat.entries.push({ ...m2, id: "d01-m2-copy", ts: m2.ts + 1 }); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Eat", rationale: "A distinct product name." }, + { label: "Trix", rationale: "An unrelated product." }, + { label: "Tiptap or Lexical", rationale: "Two bundled editor choices." }, + { + label: "Native Selection and Range with our own document model", + rationale: "The duplicate evidence is ambiguous.", + }, + ], + }), + ); + let record = context.plan.records.get(context.id)!; + expect(answer.added).toHaveLength(3); + expect(answer.skipped).toEqual(["Tiptap or Lexical"]); + for (let id of answer.added as string[]) { + expect(record.optionOrigins[id]?.source).toBeUndefined(); + } +}); diff --git a/apps/server/src/agent/refine-tool-4.test.ts b/apps/server/src/agent/refine-tool-4.test.ts new file mode 100644 index 00000000..55743eb8 --- /dev/null +++ b/apps/server/src/agent/refine-tool-4.test.ts @@ -0,0 +1,109 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import type { Chat } from "@chopin/protocol"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call, source, d01Messages } = fixture; + +test("refine_decision leaves ambiguous or non-member option evidence unattributed", async () => { + for (let evidence of ["ambiguous", "foreign"] as const) { + let context = await opened(); + let { m2 } = d01Messages(context); + if (evidence === "ambiguous") { + context.plan.chat.entries.push({ ...m2, id: "d01-m2-copy", ts: m2.ts + 1 }); + } else { + m2.author = { kind: "agent" }; + } + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Tiptap", rationale: "A possible editor approach." }, + { label: "bare ProseMirror", rationale: "A possible editor approach." }, + ], + }), + ); + let record = context.plan.records.get(context.id)!; + expect(answer.added).toHaveLength(2); + for (let id of answer.added) { + expect(record.optionOrigins[id]?.source).toBeUndefined(); + } + } +}); + +test("refine_decision rejects mismatched, foreign, and missing chat sources before edits", async () => { + let context = await opened(); + let entry: Chat.Entry = { + id: "message-a", + author: { kind: "member", handle: "jev" }, + text: "We could use Lexical for selection.", + ts: 1, + }; + context.plan.chat.entries.push(entry); + job(context.plan, context.id); + let good = source(entry, "Lexical"); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let citation of [ + { ...good, quote: "TipTaps" }, + { ...good, author: { kind: "member", handle: "other" } }, + { ...good, messageId: "other-room-message" }, + ] + ) { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which editor should we use?", + add_options: [{ label: "Lexical", rationale: "Jev proposed it.", source: citation }], + }); + expect(answer).toContain("source"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toEqual([]); + } + } finally { + errors.mockRestore(); + } +}); + +test("malformed question-source bounds reject before title, placement, or option edits", async () => { + let context = await opened(); + let { ref } = d01Messages(context); + job(context.plan, context.id); + let digest = room.digests(context.plan.document)[0]!; + let beforeDocument = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeSequence = context.plan.document.seq; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which rich-text editor should we use?", + add_options: [{ + label: "Tiptap", + rationale: "Rob listed this library in m2.", + source: { ...ref, end: ref.end + 1 }, + }], + place_after: { index: 0, digest }, + }); + + expect(answer).toBe("Error: source-shape"); + expect(answer).not.toContain("invalid conversation source"); + expect(room.project(context.plan.document)).toBe(beforeDocument); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(context.plan.document.seq).toBe(beforeSequence); + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/refine-tool-5.test.ts b/apps/server/src/agent/refine-tool-5.test.ts new file mode 100644 index 00000000..0a0417b5 --- /dev/null +++ b/apps/server/src/agent/refine-tool-5.test.ts @@ -0,0 +1,107 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Store from "../questions/store"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { OPTION, opened, job, call, d01Messages } = fixture; + +test("a valid D01 m1 question source cannot source Rob's m2 editor options", async () => { + let context = await opened(); + let { m2, ref } = d01Messages(context); + job(context.plan, context.id); + let beforeDocument = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let answer = await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which rich-text editor should we use?", + add_options: [{ + label: "Tiptap", + rationale: `Rob proposed it in m2: ${m2.text}`, + source: ref, + }], + }); + + expect(answer).toContain("option question source does not name this card option"); + expect(answer).not.toContain("invalid conversation source"); + expect(answer).not.toContain("source-shape"); + expect(room.project(context.plan.document)).toBe(beforeDocument); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + } finally { + errors.mockRestore(); + } +}); + +test("refine_decision retitles, adds a reasoned option, and durably places the card", async () => { + let context = await opened([{ id: OPTION, label: "Anchors" }]); + job(context.plan, context.id); + let digest = room.digests(context.plan.document)[0]!; + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Which auth system should ship first?", + add_options: [{ label: "GitHub Apps", rationale: "Fits repository permissions." }], + place_after: { index: 0, digest }, + }), + ); + expect(answer).toMatchObject({ + ok: true, + title: "Which auth system should ship first?", + placed: true, + skipped: [], + full: [], + }); + expect(answer.added).toHaveLength(1); + let record = context.plan.records.get(context.id)!; + expect(record.definition.questions[0]?.question).toBe("Which auth system should ship first?"); + expect(record.optionOrigins[answer.added[0]]).toEqual({ + origin: "planner", + rationale: "Fits repository permissions.", + }); + let source = room.project(context.plan.document); + expect(source.indexOf("Opening prose.")).toBeLessThan(source.indexOf(" { + let options = Array.from({ length: 9 }, (_, index) => ({ + id: `01K0N4W3B7P27CBAEC7A8C8W${String(index).padStart(2, "0")}`, + label: index === 0 ? "Anchors" : `Option ${index}`, + })); + let context = await opened(options); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: " anchors ", rationale: "Already discussed." }, + { label: "Export", rationale: "Frequently requested." }, + { label: "Import", rationale: "Could follow export." }, + ], + }), + ); + expect(answer.added).toHaveLength(1); + expect(answer.skipped).toEqual(["anchors"]); + expect(answer.full).toEqual(["Import"]); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toHaveLength(10); +}); diff --git a/apps/server/src/agent/refine-tool-6.test.ts b/apps/server/src/agent/refine-tool-6.test.ts new file mode 100644 index 00000000..1d5a16d1 --- /dev/null +++ b/apps/server/src/agent/refine-tool-6.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { OPTION, opened, job, call } = fixture; + +test("refine_decision keeps library alternatives atomic and skips reworded repeats", async () => { + let context = await opened([{ id: OPTION, label: "Tiptap" }]); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Use Tiptap for string highlighting", rationale: "The team discussed it." }, + { label: "ProseMirror", rationale: "It is a distinct editor approach." }, + { label: "Lexical", rationale: "It is another editor approach." }, + { label: "Tiptap or ProseMirror or Lexical", rationale: "The team mentioned all three." }, + { + label: "Browser-native Selection and Range APIs", + rationale: "The native platform approach is available.", + }, + { label: "Selection/Range APIs", rationale: "Restates the native approach." }, + ], + }), + ); + expect(answer.added).toHaveLength(3); + expect(answer.skipped).toEqual([ + "Use Tiptap for string highlighting", + "Tiptap or ProseMirror or Lexical", + "Selection/Range APIs", + ]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["Tiptap", "ProseMirror", "Lexical", "Browser-native Selection and Range APIs"]); +}); + +test("refine_decision rejects a bundled sibling choice without losing distinct options", async () => { + let context = await opened([{ id: OPTION, label: "SQLite" }]); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "PostgreSQL", rationale: "The existing deployment uses it." }, + { label: "Use PostgreSQL for storage", rationale: "Restates PostgreSQL." }, + { label: "SQLite, PostgreSQL, and MySQL", rationale: "A grouped list." }, + { label: "MySQL", rationale: "It is another database approach." }, + ], + }), + ); + expect(answer.added).toHaveLength(2); + expect(answer.skipped).toEqual(["Use PostgreSQL for storage", "SQLite, PostgreSQL, and MySQL"]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["SQLite", "PostgreSQL", "MySQL"]); +}); + +test("refine_decision skips slash lists on an empty card but keeps a paired API approach", async () => { + let context = await opened(); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "Tiptap / ProseMirror / Lexical", rationale: "A bundled library list." }, + { label: "SQLite/PostgreSQL/MySQL", rationale: "A bundled database list." }, + { label: "Tiptap / ProseMirror", rationale: "Two separate libraries." }, + { label: "ProseMirror API / Lexical API", rationale: "Two separate API options." }, + { label: "Selection/Range APIs", rationale: "One browser-native approach." }, + ], + }), + ); + expect(answer.added).toHaveLength(1); + expect(answer.skipped).toEqual([ + "Tiptap / ProseMirror / Lexical", + "SQLite/PostgreSQL/MySQL", + "Tiptap / ProseMirror", + "ProseMirror API / Lexical API", + ]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["Selection/Range APIs"]); +}); diff --git a/apps/server/src/agent/refine-tool-7.test.ts b/apps/server/src/agent/refine-tool-7.test.ts new file mode 100644 index 00000000..8a28f967 --- /dev/null +++ b/apps/server/src/agent/refine-tool-7.test.ts @@ -0,0 +1,80 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { OPTION, opened, job, call } = fixture; + +test("refine_decision preserves a distinct extended product name", async () => { + let context = await opened([{ id: OPTION, label: "React" }]); + job(context.plan, context.id); + let answer = JSON.parse( + await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [ + { label: "React Native", rationale: "A mobile framework is a separate approach." }, + { label: "Use React for the browser", rationale: "Restates the existing option." }, + ], + }), + ); + expect(answer.added).toHaveLength(1); + expect(answer.skipped).toEqual(["Use React for the browser"]); + expect( + context.plan.records.get(context.id)?.definition.questions[0]?.options.map(option => + option.label + ), + ).toEqual(["React", "React Native"]); +}); + +test("refine_decision refuses another target, stale revision, and closed records", async () => { + let context = await opened(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + job(context.plan, context.id); + let before = room.project(context.plan.document); + expect(await call(context, { revision: context.plan.revision, id: OPTION })) + .toContain("different decision"); + expect(await call(context, { revision: context.plan.revision + 1, id: context.id })) + .toContain("read_plan again"); + context.plan.records.delete(context.id); + expect(await call(context, { revision: context.plan.revision, id: context.id })) + .toContain("no longer open"); + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } finally { + errors.mockRestore(); + } +}); + +test("a queued refine tool cannot write after its background job is replaced", async () => { + let context = await opened(); + job(context.plan, context.id); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let delayed = call(context, { + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }, (_plan, action) => { + entered.resolve(); + return release.promise.then(action); + }); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: `refine:${context.id}:m2` }; + release.resolve(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await delayed).toContain("background Planner job changed"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); diff --git a/apps/server/src/agent/refine-tool-8.test.ts b/apps/server/src/agent/refine-tool-8.test.ts new file mode 100644 index 00000000..c5364fbb --- /dev/null +++ b/apps/server/src/agent/refine-tool-8.test.ts @@ -0,0 +1,116 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Store from "../questions/store"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call } = fixture; + +test("a job replaced during staged restoration cannot publish placement, title, or options", async () => { + for ( + let change of [ + { place_after: true }, + { title: "Why now?" }, + { add_options: [{ label: "GitHub Apps", rationale: "Uses repository access." }] }, + ] + ) { + let context = await opened(); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let original = room.restore; + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let pending = call(context, { + revision: context.plan.revision, + id: context.id, + ...(change.place_after + ? { place_after: { index: 0, digest: room.digests(context.plan.document)[0] } } + : change), + }); + await entered.promise; + context.plan.chat.job = { ...context.plan.chat.job!, id: `refine:${context.id}:m2` }; + release.resolve(); + expect(await pending).toContain("background Planner job changed"); + } finally { + errors.mockRestore(); + held.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.options).toEqual([]); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(context.plan.chat.jobOutput).toBeUndefined(); + } +}); + +test("suggest jobs reject both title and placement before changing the card", async () => { + let context = await opened(); + job(context.plan, context.id, "suggest"); + let before = room.project(context.plan.document); + let digest = room.digests(context.plan.document)[0]!; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + title: "Why?", + }), + ).toContain("refine job"); + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + place_after: { index: 0, digest }, + }), + ).toContain("refine job"); + expect(room.project(context.plan.document)).toBe(before); + } finally { + errors.mockRestore(); + } +}); + +test("invalid title, options, and placement are refused before any retitle commits", async () => { + let context = await opened(); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let revision = Store.get(context.plan.questions, context.id)?.revision; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let invalid of [ + { title: "First\rSecond" }, + { title: "x".repeat(81) }, + { title: "Why?", add_options: [{ label: " ", rationale: "Valid" }] }, + { title: "Why?", add_options: [{ label: "Valid", rationale: " " }] }, + { title: "Why?", place_after: { index: 0, digest: room.digest("changed") } }, + ] + ) { + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + ...invalid, + }), + ).toStartWith("Error:"); + expect(room.project(context.plan.document)).toBe(before); + expect(Store.get(context.plan.questions, context.id)?.revision).toBe(revision); + } + } finally { + errors.mockRestore(); + } +}); diff --git a/apps/server/src/agent/refine-tool-9.test.ts b/apps/server/src/agent/refine-tool-9.test.ts new file mode 100644 index 00000000..5b8195d5 --- /dev/null +++ b/apps/server/src/agent/refine-tool-9.test.ts @@ -0,0 +1,38 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import { createRefineToolFixture } from "./refine-tool.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createRefineToolFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened, job, call } = fixture; + +test("failed placement persistence leaves no live move or publication", async () => { + let context = await opened(); + job(context.plan, context.id); + let before = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + context.backend.fatal = () => {}; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await call(context, { + revision: context.plan.revision, + id: context.id, + place_after: { index: 0, digest: room.digests(context.plan.document)[0] }, + }), + ).toBe("Error: storage unavailable"); + } finally { + errors.mockRestore(); + context.storage.collaboration.commit = original; + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(context.plan.chat.jobOutput).toBeUndefined(); +}); diff --git a/apps/server/src/agent/refine-tool.test-fixtures.ts b/apps/server/src/agent/refine-tool.test-fixtures.ts new file mode 100644 index 00000000..7555bcaf --- /dev/null +++ b/apps/server/src/agent/refine-tool.test-fixtures.ts @@ -0,0 +1,188 @@ +import { afterEach, expect } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import { applyEvent } from "../conversation-plan/events"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +function job( + plan: Awaited>, + id: string, + kind: "refine" | "suggest" = "refine", +) { + plan.chat.job = { + id: `${kind}:${id}:m1`, + kind, + target: id, + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; +} + +export function createRefineToolFixture( + readContexts: () => Awaited>[], + writeContexts: (value: Awaited>[]) => void, +) { + const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + let contexts: Awaited>[] = []; + + async function opened(options: Array<{ id: string; label: string }> = []) { + let context = await openPlan("Opening prose.\n\nLater prose.\n"); + contexts.push(context); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options, + }); + return { ...context, id }; + } + + function tool( + context: Awaited>, + exclusive = Service.exclusive, + ) { + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => exclusive(context.plan, action), + async publish() {}, + anchors() {}, + changes() {}, + }).find(item => item.name === "refine_decision"); + if (!found?.handler) throw new Error("refine_decision is missing"); + return found.handler; + } + + async function call( + context: Awaited>, + input: Record, + exclusive = Service.exclusive, + ) { + return String(await tool(context, exclusive)(input, {} as never)); + } + + function source(entry: Chat.Entry, quote: string): ConversationPlan.SourceRef { + let start = entry.text.indexOf(quote); + if (start < 0) throw new Error("quote missing from test message"); + return { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote, + start, + end: start + quote.length, + role: "option", + }; + } + + function linkQuestion( + context: Awaited> & { id: string }, + ref: ConversationPlan.SourceRef, + ) { + let opened = applyEvent(context.plan.conversationPlan, { + id: "open-question", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: ref, + question: "Lexical or ProseMirror?", + }); + context.plan.conversationPlan = applyEvent(opened, { + id: "link-question", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + questionnaireId: context.id, + }); + } + + function d01Messages(context: Awaited> & { id: string }) { + let m1: Chat.Entry = { + id: "d01-m1", + author: { kind: "member", handle: "Nia" }, + text: "need to pick an editor before comments get any deeper. what are we comparing?", + ts: 1, + }; + let m2: Chat.Entry = { + id: "d01-m2", + author: { kind: "member", handle: "Rob" }, + text: + "Tiptap, bare ProseMirror, Lexical, or just native Selection and Range with our own document model.", + ts: 2, + }; + context.plan.chat.entries.push(m1, m2); + let ref = { ...source(m1, m1.text), role: "question" as const }; + linkQuestion(context, ref); + return { m1, m2, ref }; + } + + async function expectExplicitSourceNotAuthoritative(entry: Chat.Entry, quote: string) { + let context = await opened(); + context.plan.chat.entries.push(entry); + job(context.plan, context.id); + let citation = source(entry, quote); + let before = room.project(context.plan.document); + let response = await call(context, { + revision: context.plan.revision, + id: context.id, + add_options: [{ + label: "Tiptap", + rationale: "Candidate source for the Tiptap option.", + source: citation, + }], + }); + let record = context.plan.records.get(context.id)!; + if (response.includes("Error")) { + expect(room.project(context.plan.document)).toBe(before); + expect(record.definition.questions[0]?.options).toEqual([]); + return; + } + + let answer = JSON.parse(response) as { added: string[] }; + expect(answer.added).toHaveLength(1); + let id = answer.added[0]!; + expect(record.optionOrigins[id]?.source).toBeUndefined(); + let event = context.plan.conversationPlan.events.find(event => + event.type === "option.added" && event.contribution.id === id + ); + expect(event?.type === "option.added" ? event.source : undefined).toBeUndefined(); + } + + let cleanup = async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; + }; + afterEach(async () => { + contexts = readContexts(); + try { + await cleanup(); + } finally { + writeContexts(contexts); + } + }); + return { + OPTION, + contexts, + opened, + job, + tool, + call, + source, + linkQuestion, + d01Messages, + expectExplicitSourceNotAuthoritative, + }; +} diff --git a/apps/server/src/agent/refine-tool.ts b/apps/server/src/agent/refine-tool.ts new file mode 100644 index 00000000..4150c73e --- /dev/null +++ b/apps/server/src/agent/refine-tool.ts @@ -0,0 +1,181 @@ +import { parse } from "@chopin/dialect/parse"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { matchesQuestionSource, questionMentionsOption } from "../questions/records"; +import { atomicOptionQuote, grouped, sameApproach } from "../questions/option-match"; +import { validateSource } from "../conversation-plan/sources"; +import { extractQuotes } from "../conversation-plan/quotes"; +import { input, type Option } from "./refine-fields"; +import { placeCard } from "./refine-placement"; +import type { Context } from "./card-tool-context"; +import type { ConversationPlan } from "@chopin/protocol"; + +export async function refineDecision( + context: Context, + job: ConversationPlan.Job, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ + output: { title: string; added: number }; + title: string; + added: string[]; + skipped: string[]; + full: string[]; + placed: boolean; +}> { + return context.exclusive(async () => { + let { plan } = context; + let active = () => + plan.chat.job === job && plan.chat.turn === turn + && job.status === "running" && (job.kind === "refine" || job.kind === "suggest"); + let ensureActive = () => { + if (!active()) throw new Error("background Planner job changed before its tool completed"); + }; + ensureActive(); + let args = input(raw); + if (args.id !== job.target) throw new Error("This job refines a different decision."); + if (job.kind === "suggest" && (args.title !== undefined || args.place_after !== undefined)) { + throw new Error("title and place_after are only changed by a refine job."); + } + if (args.revision !== plan.revision) throw new Error("stale; read_plan again"); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(args.id); + let live = Store.get(plan.questions, args.id); + if ( + !record || !Questions.isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + ) throw new Error("That decision is no longer open."); + let question = record.definition.questions[0]!; + if (live.definition.questions[0]?.id !== question.id) { + throw new Error("That decision is no longer open."); + } + if (!room.hasQuestionnaire(plan.document, args.id, question.id)) { + throw new Error("Card is not available"); + } + if (args.place_after) { + let at = args.place_after; + let hashes = room.digests(plan.document); + if (hashes[at.index] !== at.digest) { + throw new Error(`block ${at.index} has changed; read_plan again.`); + } + let block = parse(room.project(plan.document)).children[at.index]; + if (!block || block.type === "mdxJsxFlowElement") { + throw new Error("place_after must address a prose block"); + } + } + + // Classify every option before the first durable edit, so invalid later input + // cannot leave an earlier title or placement committed by itself. + for (let option of args.add_options) { + if (!option.source) continue; + if (option.source.author.kind !== "member") { + throw new Error("option source must come from a member"); + } + if ( + option.source.role === "option" + && !atomicOptionQuote(option.source.quote, option.label) + ) { + throw new Error("option source does not name one card option"); + } + if (option.source.role === "question") { + let thread = plan.conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === args.id + ); + if ( + !matchesQuestionSource(option.source, thread) + || !questionMentionsOption(option.source.quote, option.label) + ) { + throw new Error("option question source does not name this card option"); + } + } + let message = plan.chat.entries.find(entry => entry.id === option.source?.messageId); + if (!message) throw new Error("option source message missing from room transcript"); + validateSource(option.source, message); + } + let seen = question.options.map(option => option.label); + let count = question.options.length; + let additions: Option[] = []; + let skipped: string[] = []; + let full: string[] = []; + for (let option of args.add_options) { + if (grouped(option.label) || seen.some(label => sameApproach(label, option.label))) { + skipped.push(option.label); + } else if (count >= 10) full.push(option.label); + else { + seen.push(option.label); + count++; + additions.push(option); + } + } + for (let option of additions) { + if (option.source) continue; + let matches = plan.chat.entries.flatMap(message => { + if (message.author.kind !== "member" || message.streaming) return []; + let handle = message.author.handle; + let candidates; + try { + candidates = extractQuotes(message.text); + } catch { + return []; + } + return candidates.filter(candidate => atomicOptionQuote(candidate.quote, option.label)).map( + candidate => ({ message, candidate, handle }), + ); + }); + if (matches.length !== 1) continue; + let { message, candidate, handle } = matches[0]!; + option.source = { + messageId: message.id, + author: { kind: "member", handle }, + ...candidate, + role: "option", + }; + } + + // Placement runs first, against the exact prose digest supplied by the job. + // A later title or option changes only the card, never this prose address. + let placed = false; + if (args.place_after) { + ensureActive(); + placed = await placeCard(context, args.id, args.place_after, ensureActive); + } + let title = question.question; + if (args.title !== undefined) { + ensureActive(); + let done = await Questions.retitle( + plan, + context.server, + context.room, + args.id, + args.title, + true, + ensureActive, + ); + if (!done.ok) throw new Error(done.message); + title = args.title; + } + let added: string[] = []; + for (let option of additions) { + ensureActive(); + let result = await Questions.addServerOption( + plan, + context.server, + context.room, + args.id, + { + label: option.label, + rationale: option.rationale, + origin: "planner", + ...(option.source ? { source: option.source } : {}), + }, + true, + ensureActive, + ); + if (!result.ok) throw new Error(`option addition was refused: ${result.reason}`); + added.push(result.optionId); + } + return { output: { title, added: added.length }, title, added, skipped, full, placed }; + }); +} diff --git a/apps/server/src/agent/revise-fields.ts b/apps/server/src/agent/revise-fields.ts new file mode 100644 index 00000000..dd927b26 --- /dev/null +++ b/apps/server/src/agent/revise-fields.ts @@ -0,0 +1,57 @@ +export type Option = { label: string; rationale: string }; + +export type Input = { revision: number; id: string; title?: string; add_options: Option[] }; + +export function input(raw: unknown): Input { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("revise_open_decision arguments must be an object"); + } + let value = raw as Record; + if ( + !Object.hasOwn(value, "revision") || !Object.hasOwn(value, "id") + || Object.keys(value).some(key => !["revision", "id", "title", "add_options"].includes(key)) + ) throw new Error("revise_open_decision arguments are invalid"); + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) { + throw new Error("revision must be a nonnegative integer"); + } + if (typeof value.id !== "string" || !value.id) throw new Error("decision id is required"); + let title: string | undefined; + if (Object.hasOwn(value, "title")) { + title = typeof value.title === "string" ? value.title.trim() : ""; + if (!title || title.length > 80 || /[\r\n\u2028\u2029]/.test(value.title as string)) { + throw new Error("title must be 1–80 characters on one line"); + } + } + let add_options: Option[] = []; + if (Object.hasOwn(value, "add_options")) { + if (!Array.isArray(value.add_options) || value.add_options.length > 10) { + throw new Error("add_options must contain at most ten options"); + } + for (let rawOption of value.add_options) { + if ( + !rawOption || typeof rawOption !== "object" || Array.isArray(rawOption) + || Object.keys(rawOption).sort().join(",") !== "label,rationale" + ) throw new Error("each option needs a label and rationale"); + let option = rawOption as Record; + let label = typeof option.label === "string" ? option.label.trim() : ""; + let rationale = typeof option.rationale === "string" ? option.rationale.trim() : ""; + if (!label || label.length > 200 || /[\r\n\u2028\u2029]/.test(label)) { + throw new Error("option label must be 1–200 characters on one line"); + } + if (!rationale || rationale.length > 300) { + throw new Error("option rationale must be 1–300 characters"); + } + add_options.push({ label, rationale }); + } + } + if (!title && add_options.length === 0) throw new Error("title or add_options is required"); + if (title && Object.hasOwn(value, "add_options")) { + throw new Error("revise_open_decision accepts one action per call; use separate calls"); + } + return { + revision: value.revision as number, + id: value.id, + ...(title ? { title } : {}), + add_options, + }; +} diff --git a/apps/server/src/agent/revise-open-decision-1.test.ts b/apps/server/src/agent/revise-open-decision-1.test.ts new file mode 100644 index 00000000..45f3dd41 --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-1.test.ts @@ -0,0 +1,167 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("separate direct calls retitle and add sourced options without deciding", async () => { + let context = await opened(); + let beforeHistory = context.plan.records.get(context.id)!.history; + let beforeRevision = context.plan.revision; + let beforeBroadcasts = context.broadcasts.length; + let retitled = JSON.parse( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Which auth system should ship first?", + }), + ); + expect(retitled).toMatchObject({ + ok: true, + title: "Which auth system should ship first?", + added: [], + }); + let answer = JSON.parse( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Uses repository-scoped permissions." }], + }), + ); + expect(answer).toMatchObject({ + ok: true, + title: "Which auth system should ship first?", + }); + expect(answer.added).toHaveLength(1); + let record = context.plan.records.get(context.id)!; + expect(record.definition.questions[0]?.question).toBe(answer.title); + expect(record.optionOrigins[answer.added[0]]).toEqual({ + origin: "planner", + rationale: "Uses repository-scoped permissions.", + }); + expect(record.status).toBe("open"); + expect(record.resolver).toBeUndefined(); + expect(record.choices).toBeUndefined(); + expect(record.history).toEqual(beforeHistory); + expect(Store.get(context.plan.questions, context.id)?.definition).toEqual(record.definition); + expect(room.project(context.plan.document)).toContain(`prompt="${answer.title}"`); + expect(room.project(context.plan.document)).toContain('label="GitHub Apps"'); + expect(context.plan.pendingCardActions).toMatchObject([{ + kind: "option-added", + origin: "planner", + optionId: answer.added[0], + }]); + let stored = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect((await Service.readStored(stored)).source).toBe(room.project(context.plan.document)); + expect(context.plan.chat.jobOutput).toBeUndefined(); + expect(context.plan.revision).toBe(beforeRevision + 2); + expect(context.broadcasts.slice(beforeBroadcasts).filter(frame => frame.kind === "plan:update")) + .toHaveLength(2); + expect( + context.broadcasts.slice(beforeBroadcasts).filter(frame => frame.kind === "question:changed"), + ) + .toHaveLength(2); +}); + +test("a failed combined commit leaves no partial title, option, revision, or broadcast", async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeBroadcasts = context.broadcasts.length; + let original = context.storage.collaboration.commit; + let commits = 0; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + commits++; + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.revisePlannerCard(context.plan, context.server, "test", context.id, { + title: "Which auth system should ship first?", + addOptions: [{ label: "GitHub Apps", rationale: "Repository-scoped permissions." }], + })).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(commits).toBe(1); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.question).toBe( + "What auth system should we use?", + ); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); + expect(context.plan.pendingCardActions).toEqual([]); +}); + +test("two-card requests cannot carry an action across card targets", async () => { + let context = await opened(); + let billing = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-b", + header: "Billing", + question: "Which billing provider should we use?", + options: [], + }); + context.request({ + text: "@chopin revise the question for Billing and add an option to Authentication", + }); + let beforeSource = room.project(context.plan.document); + let beforeRecords = structuredClone([...context.plan.records]); + let beforeDrafts = [context.id, billing].map(id => + Store.get(context.plan.questions, id)!.revision + ); + let beforeRevision = context.plan.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: beforeRevision, + id: context.id, + title: "Which auth system should ship first?", + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("one action per call"); + expect( + await context.call({ + revision: beforeRevision, + id: context.id, + title: "Which auth system should ship first?", + add_options: [], + }), + ).toContain("one action per call"); + expect( + await context.call({ + revision: beforeRevision, + id: context.id, + title: "Which auth system should ship first?", + }), + ).toContain("must identify this decision in that action"); + expect( + await context.call({ + revision: beforeRevision, + id: billing, + add_options: [{ label: "Stripe", rationale: "Known billing APIs." }], + }), + ).toContain("must identify this decision in that action"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect([...context.plan.records]).toEqual(beforeRecords); + expect([context.id, billing].map(id => Store.get(context.plan.questions, id)!.revision)) + .toEqual(beforeDrafts); + expect(context.plan.revision).toBe(beforeRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); +}); diff --git a/apps/server/src/agent/revise-open-decision-2.test.ts b/apps/server/src/agent/revise-open-decision-2.test.ts new file mode 100644 index 00000000..4b845fbc --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-2.test.ts @@ -0,0 +1,129 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("contrast and multiple card names cannot authorize the excluded card", async () => { + let context = await opened(); + let billing = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-b", + header: "Billing", + question: "Which billing provider should we use?", + options: [], + }); + let beforeSource = room.project(context.plan.document); + let beforeRecords = structuredClone([...context.plan.records]); + let beforeDrafts = [context.id, billing].map(id => + Store.get(context.plan.questions, id)!.revision + ); + let beforeRevision = context.plan.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let [text, input] of [ + [ + "@chopin add an option to Authentication instead of Billing", + { id: billing, add_options: [{ label: "Stripe", rationale: "Known billing APIs." }] }, + ], + [ + "@chopin revise the question for Authentication rather than Billing", + { id: billing, title: "Which billing system should ship first?" }, + ], + [ + "@chopin add an option to Authentication and Billing", + { id: billing, add_options: [{ label: "Stripe", rationale: "Known billing APIs." }] }, + ], + [ + "@chopin add an option to Authentication except Billing", + { id: billing, add_options: [{ label: "Stripe", rationale: "Known billing APIs." }] }, + ], + ] as const + ) { + context.request({ text }); + expect(await context.call({ revision: beforeRevision, ...input })) + .toContain("must identify this decision in that action"); + } + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect([...context.plan.records]).toEqual(beforeRecords); + expect([context.id, billing].map(id => Store.get(context.plan.questions, id)!.revision)) + .toEqual(beforeDrafts); + expect(context.plan.revision).toBe(beforeRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); +}); + +test("reference or backscroll instructions cannot authorize an unrequested card edit", async () => { + let context = await opened(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + context.request({ text: "@chopin summarize the Authentication decision" }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }), + ).toContain("did not request a question edit"); + context.request({ text: "@chopin revise the Authentication decision question" }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions" }], + }), + ).toContain("did not request new options"); + context.request({ text: "@chopin revise the question for Search" }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }), + ).toContain("must identify this decision"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("negated or withdrawn edit language never authorizes an option", async () => { + let context = await opened(); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let text of [ + "@chopin Don't add an option to the Authentication decision.", + "@chopin Please cancel my request to add an option to the Authentication decision.", + ] + ) { + context.request({ text }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("did not request new options"); + } + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); +}); diff --git a/apps/server/src/agent/revise-open-decision-3.test.ts b/apps/server/src/agent/revise-open-decision-3.test.ts new file mode 100644 index 00000000..ee0299db --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-3.test.ts @@ -0,0 +1,87 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("editing an option rationale does not authorize a question retitle", async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + context.request({ + text: "@chopin, please add an option to the Authentication question and edit the rationale.", + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Which auth system should ship first?", + }), + ).toContain("did not request a question edit"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); +}); + +test("generic question words and ambiguous references do not identify a card", async () => { + let context = await opened(); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + for ( + let [text, refusal] of [ + ["@chopin What options should we add?", "did not request new options"], + ["@chopin Add an option to this decision.", "must identify this decision"], + ] as const + ) { + context.request({ text }); + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain(refusal); + } + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); +}); + +test("a named card in a deliberative question still does not authorize an edit", async () => { + let context = await opened(); + context.request({ text: "@chopin What options should we add to the Authentication decision?" }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("did not request new options"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options).toEqual([]); +}); diff --git a/apps/server/src/agent/revise-open-decision-4.test.ts b/apps/server/src/agent/revise-open-decision-4.test.ts new file mode 100644 index 00000000..1fe75bca --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-4.test.ts @@ -0,0 +1,87 @@ +import { expect, spyOn, test } from "bun:test"; +import { plannerConfiguration } from "./scoped-tools.test-configuration"; +import * as room from "../plan/room"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("the direct card tool is built but withheld from ordinary hosted turns until card sync", async () => { + let context = await opened([{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Anchors" }]); + let config = plannerConfiguration({ model: "model" }, { tools: context.tools }, { + token: "ghu_owner", + repository: { id: "R_repo", owner: "octo-org", name: "score", defaultBranch: "main" }, + }); + expect(config.availableTools).toContain("custom:*"); + expect(config.tools?.map(tool => tool.name)).not.toContain("revise_open_decision"); + expect(context.tools.map(tool => tool.name)).toContain("revise_open_decision"); + expect(context.tools.find(tool => tool.name === "revise_open_decision")?.skipPermission) + .toBe(false); + let read = context.tools.find(tool => tool.name === "read_plan")!; + let snapshot = JSON.parse(String(await read.handler!({}, {} as never))); + expect(snapshot.questions.find((item: { id: string }) => item.id === context.id).options) + .toEqual([["Anchors"]]); +}); + +test("direct card edit requires a current member request and is fenced from jobs", async () => { + let context = await opened(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + context.request(); + expect(await context.call({ revision: context.plan.revision, id: context.id, title: "Why?" })) + .toContain("current member request"); + context.request({ text: "@chopin revise the Authentication decision question" }); + context.plan.chat.job = { + id: `refine:${context.id}:m1`, + kind: "refine", + target: context.id, + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + expect(await context.call({ revision: context.plan.revision, id: context.id, title: "Why?" })) + .toContain("current member request"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("stale revisions, closed cards, duplicate labels and option limits refuse before mutation", async () => { + let context = await opened([{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Anchors" }]); + let before = room.project(context.plan.document); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let base = { revision: context.plan.revision, id: context.id }; + expect(await context.call({ ...base, revision: base.revision + 1, title: "Why now?" })) + .toContain("read_plan again"); + expect( + await context.call({ + ...base, + add_options: [{ label: " anchors ", rationale: "Duplicate" }], + }), + ) + .toContain("duplicate option"); + expect( + await context.call({ + ...base, + add_options: Array.from({ length: 10 }, (_, i) => ({ + label: `Option ${i}`, + rationale: "Evidence", + })), + }), + ).toContain("too many options"); + context.plan.records.delete(context.id); + expect(await context.call({ ...base, title: "Why now?" })).toContain("no longer open"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); +}); diff --git a/apps/server/src/agent/revise-open-decision-5.test.ts b/apps/server/src/agent/revise-open-decision-5.test.ts new file mode 100644 index 00000000..ab3cd18d --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-5.test.ts @@ -0,0 +1,113 @@ +import { expect, spyOn, test } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +test("an active implementation refuses direct card edits", async () => { + let context = await opened(); + let active = spyOn(Service, "implementationActive").mockReturnValue(true); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }), + ).toContain("implementation is active"); + } finally { + active.mockRestore(); + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("a queued direct tool cannot write after the member turn changes", async () => { + let context = await opened(); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let found = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => { + entered.resolve(); + return release.promise.then(() => Service.exclusive(context.plan, action)); + }, + async publish() {}, + anchors() {}, + changes() {}, + currentMemberRequest: () => context.plan.chat.activeRequest, + }).find(item => item.name === "revise_open_decision")!; + context.plan.chat.activeRequest = { + entryId: "m1", + userId: "u1", + handle: "ana", + text: "Revise the Authentication decision question", + claimantSessionId: "s1", + turnId: "turn-1", + lifecycle: 1, + }; + let pending = Promise.resolve(found.handler!({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }, {} as never)).then(String); + await entered.promise; + context.plan.chat.turn = { id: "turn-2", handle: "ana", started: 2, responded: false }; + release.resolve(); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await pending).toContain("Planner turn changed"); + } finally { + errors.mockRestore(); + } + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); + +test("a replaced member request cannot publish a staged card edit", async () => { + let context = await opened(); + let before = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let original = room.restore; + let held = spyOn(room, "restore").mockImplementation(async (...args) => { + entered.resolve(); + await release.promise; + return original(...args); + }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let pending = context.call({ + revision: context.plan.revision, + id: context.id, + title: "Why now?", + }); + await entered.promise; + context.request({ text: "@chopin revise another decision question" }); + release.resolve(); + expect(await pending).toContain("Planner turn changed"); + } finally { + held.mockRestore(); + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(before); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(context.plan.records.get(context.id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); +}); diff --git a/apps/server/src/agent/revise-open-decision-requests.test.ts b/apps/server/src/agent/revise-open-decision-requests.test.ts new file mode 100644 index 00000000..4c83729d --- /dev/null +++ b/apps/server/src/agent/revise-open-decision-requests.test.ts @@ -0,0 +1,124 @@ +import { expect, spyOn, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Store from "../questions/store"; +import { createReviseOpenDecisionFixture } from "./revise-open-decision.test-fixtures"; + +let contexts: ReturnType["contexts"]; +let fixture = createReviseOpenDecisionFixture(() => contexts, value => { + contexts = value; +}); +contexts = fixture.contexts; +let { opened } = fixture; + +for ( + let [name, text] of [ + ["curly-apostrophe negation", "@chopin Don’t add an option to the Authentication decision."], + [ + "deliberative question", + "@chopin Would it be useful to add an option to the Authentication decision?", + ], + [ + "later withdrawal", + "@chopin, please add an option to the Authentication decision — actually, don't.", + ], + ] as const +) { + test(`${name} cannot change any card state`, async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + context.request({ text }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ).toContain("did not request new options"); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); + }); +} + +for ( + let [name, text, title, refusal] of [ + [ + "a note about options", + "@chopin please add a note to the Authentication decision about options", + undefined, + "did not request new options", + ], + [ + "a rationale edit with a distant question noun", + "@chopin please edit the rationale for the Authentication decision question", + "Which auth system should ship first?", + "did not request a question edit", + ], + ] as const +) { + test(`${name} cannot change any card state`, async () => { + let context = await opened(); + let beforeSource = room.project(context.plan.document); + let beforeRecord = structuredClone(context.plan.records.get(context.id)!); + let beforeRevision = context.plan.revision; + let beforeDraftRevision = Store.get(context.plan.questions, context.id)!.revision; + let beforeOutbox = [...context.plan.pendingCardActions]; + let beforeBroadcasts = context.broadcasts.length; + context.request({ text }); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect( + await context.call({ + revision: context.plan.revision, + id: context.id, + ...(title ? { title } : { + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + }), + ).toContain(refusal); + } finally { + errors.mockRestore(); + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(context.id)).toEqual(beforeRecord); + expect(context.plan.revision).toBe(beforeRevision); + expect(Store.get(context.plan.questions, context.id)!.revision).toBe(beforeDraftRevision); + expect(context.plan.pendingCardActions).toEqual(beforeOutbox); + expect(context.broadcasts).toHaveLength(beforeBroadcasts); + }); +} + +for ( + let text of [ + "@chopin, please add an option called GitHub Apps to the Authentication decision.", + "@chopin Can you add an option called GitHub Apps to the Authentication decision?", + ] +) { + test(`affirmative direct request authorizes an option: ${text}`, async () => { + let context = await opened(); + context.request({ text }); + let answer = JSON.parse( + await context.call({ + revision: context.plan.revision, + id: context.id, + add_options: [{ label: "GitHub Apps", rationale: "Repository permissions." }], + }), + ); + expect(answer.ok).toBe(true); + expect(context.plan.records.get(context.id)!.definition.questions[0]!.options) + .toHaveLength(1); + }); +} diff --git a/apps/server/src/agent/revise-open-decision.test-fixtures.ts b/apps/server/src/agent/revise-open-decision.test-fixtures.ts new file mode 100644 index 00000000..3e078f28 --- /dev/null +++ b/apps/server/src/agent/revise-open-decision.test-fixtures.ts @@ -0,0 +1,58 @@ +import { afterEach } from "bun:test"; +import { toolbox } from "./scoped-tools.test-bridge"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; + +export function createReviseOpenDecisionFixture( + readContexts: () => Awaited>[], + writeContexts: (value: Awaited>[]) => void, +) { + let contexts: Awaited>[] = []; + + async function opened(options: Array<{ id: string; label: string }> = []) { + let context = await openPlan("Opening prose.\n"); + contexts.push(context); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options, + }); + context.plan.chat.busy = true; + context.plan.chat.turn = { id: "turn-1", handle: "ana", started: 1, responded: false }; + let request: { text: string } | undefined = { + text: + "@chopin revise the question for the Authentication decision and add an option to the Authentication decision", + }; + let tools = toolbox({ + plan: context.plan, + server: context.server, + room: "test", + persist: () => Service.persist(context.plan), + exclusive: action => Service.exclusive(context.plan, action), + async publish() {}, + anchors() {}, + changes() {}, + currentMemberRequest: () => request, + }); + let found = tools.find(item => item.name === "revise_open_decision"); + if (!found?.handler) throw new Error("revise_open_decision is missing"); + let call = (input: unknown) => Promise.resolve(found.handler!(input, {} as never)).then(String); + return { ...context, id, tools, call, request: (value?: { text: string }) => request = value }; + } + + let cleanup = async () => { + for (let context of contexts) await Service.close(context.plan); + contexts = []; + }; + afterEach(async () => { + contexts = readContexts(); + try { + await cleanup(); + } finally { + writeContexts(contexts); + } + }); + return { contexts, opened }; +} diff --git a/apps/server/src/agent/revise-open-decision.ts b/apps/server/src/agent/revise-open-decision.ts new file mode 100644 index 00000000..0e2d1fa9 --- /dev/null +++ b/apps/server/src/agent/revise-open-decision.ts @@ -0,0 +1,69 @@ +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { input } from "./revise-fields"; +import { explicitRequest } from "./revise-requests"; +import type { Context } from "./card-tool-context"; + +export async function reviseOpenDecision( + context: Context, + turn: Context["plan"]["chat"]["turn"], + raw: unknown, +): Promise<{ ok: true; title: string; added: string[] }> { + let request = context.currentMemberRequest?.(); + if (!request || !turn || context.plan.chat.job) { + throw new Error("a direct current member request is required"); + } + return context.exclusive(async () => { + let { plan } = context; + let ensureActive = () => { + if ( + plan.chat.turn !== turn || plan.chat.job + || context.currentMemberRequest?.() !== request + ) throw new Error("the member's Planner turn changed before its tool completed"); + }; + ensureActive(); + let args = input(raw); + if (args.revision !== plan.revision) throw new Error("stale; read_plan again"); + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(args.id); + let live = Store.get(plan.questions, args.id); + if ( + !record || !Questions.isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !room.hasQuestionnaire(plan.document, args.id, record.definition.questions[0]!.id) + ) throw new Error("that decision is no longer open in this document"); + let question = record.definition.questions[0]!; + explicitRequest( + request.text, + { id: args.id, header: question.header, question: question.question }, + [...plan.records.values()].map(item => ({ + id: item.id, + header: item.definition.questions[0]?.header ?? "", + question: item.definition.questions[0]?.question ?? "", + })), + args, + ); + let seen = new Set(question.options.map(option => option.label.trim().toLocaleLowerCase())); + if (question.options.length + args.add_options.length > 10) { + throw new Error("decision has too many options"); + } + for (let option of args.add_options) { + let folded = option.label.toLocaleLowerCase(); + if (seen.has(folded)) throw new Error(`duplicate option: ${option.label}`); + seen.add(folded); + } + let result = await Questions.revisePlannerCard( + plan, + context.server, + context.room, + args.id, + { title: args.title, addOptions: args.add_options }, + true, + ensureActive, + ); + return { ok: true, ...result }; + }); +} diff --git a/apps/server/src/agent/revise-requests.ts b/apps/server/src/agent/revise-requests.ts new file mode 100644 index 00000000..a350d27c --- /dev/null +++ b/apps/server/src/agent/revise-requests.ts @@ -0,0 +1,90 @@ +import type { Input } from "./revise-fields"; + +export type Target = { id: string; header: string; question: string }; + +export type RequestedAction = { kind: "title" | "option"; clause: string }; + +export function words(value: string): string[] { + return value.toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; +} + +export function names(target: Target): string[] { + return [target.id, target.header, target.question] + .map(value => words(value).join(" ")) + .filter(Boolean); +} + +export function requestedActions(text: string): RequestedAction[] { + let message = text.toLocaleLowerCase().replace(/[\u2018\u2019]/g, "'") + .replace(/^\s*@chopin\b[\s,:-]*/, "").trim(); + let verbs = "change|edit|rename|retitle|reword|revise|update|add|include|propose|suggest"; + let start = new RegExp( + `^(?:(?:please|kindly)\\s+)?(?:(?:(?:can|could|would) you\\s+(?:please\\s+)?` + + `|i (?:want|would like) you to\\s+|i'd like you to\\s+))?(${verbs})\\b`, + ); + let first = start.exec(message); + let actions: RequestedAction[] = []; + if (!first) return actions; + // A withdrawal anywhere in the same message cancels the whole proposed batch. + if (/\b(?:don't|do not|not|never|no|cancel|withdraw|retract|stop)\b/.test(message)) { + return actions; + } + let commands: Array<{ verb: string; objectStart: number; boundary: number }> = [{ + verb: first[1]!, + objectStart: first[0].length, + boundary: 0, + }]; + let continuation = new RegExp(`\\b(?:and|then)\\s+(?:please\\s+)?(${verbs})\\b`, "g"); + for (let match of message.slice(first[0].length).matchAll(continuation)) { + let boundary = first[0].length + match.index!; + commands.push({ verb: match[1]!, objectStart: boundary + match[0].length, boundary }); + } + for (let [index, command] of commands.entries()) { + let clause = message.slice(command.objectStart, commands[index + 1]?.boundary); + if (/^(?:add|include|propose|suggest)$/.test(command.verb)) { + if ( + /^\s*(?:(?:an?|some|new|the)\s+)?(?:option|options|choice|choices|alternative|alternatives)\b/ + .test(clause) + ) { + actions.push({ kind: "option", clause }); + } + } else if (/^\s*(?:the\s+)?(?:question|title|wording)\b/.test(clause)) { + actions.push({ kind: "title", clause }); + } + } + return actions; +} + +export function explicitRequest( + text: string, + target: Target, + allTargets: Target[], + args: Input, +): void { + let actions = requestedActions(text); + let supports = (kind: RequestedAction["kind"], refusal: string) => { + let matches = actions.filter(action => action.kind === kind); + if (matches.length === 0) throw new Error(refusal); + let named = matches.some(action => { + let clause = ` ${words(action.clause).join(" ")} `; + if (/\b(?:instead of|rather than|except)\b/.test(clause)) return false; + let mentioned = allTargets.filter(candidate => + names(candidate).some(name => clause.includes(` ${name} `)) + ); + if (mentioned.length !== 1 || mentioned[0]?.id !== target.id) return false; + return [...clause.matchAll(/\b(?:to|for)\s+(?:the\s+)?/g)].some(match => { + let after = clause.slice(match.index! + match[0].length); + return names(target).some(name => after.startsWith(`${name} `)); + }); + }); + if (!named) { + throw new Error("the current member request must identify this decision in that action"); + } + }; + if (args.title !== undefined) { + supports("title", "the current member did not request a question edit"); + } + if (args.add_options.length > 0) { + supports("option", "the current member did not request new options"); + } +} diff --git a/apps/server/src/agent/scoped-prose-tool.ts b/apps/server/src/agent/scoped-prose-tool.ts new file mode 100644 index 00000000..001d8d1a --- /dev/null +++ b/apps/server/src/agent/scoped-prose-tool.ts @@ -0,0 +1,37 @@ +import { jsonSchema, tool } from "ai"; +import { cardToolContext } from "./card-tool-context"; +import { answer, cardToolSchema } from "./scoped-tool-result"; +import { write } from "./decision-prose"; +import { runJobTool } from "./job-scope"; + +export function createProseTools() { + return { + write_decision_prose: tool({ + description: "Background job only. Write one grounded paragraph for a saved conversation " + + "decision. At most 600 characters; inline Markdown is allowed. Re-decisions replace the " + + "existing paragraph.", + inputSchema: jsonSchema>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + id: { type: "string" }, + text: { type: "string", minLength: 1, maxLength: 600 }, + }, + required: ["revision", "id", "text"], + additionalProperties: false, + }), + contextSchema: cardToolSchema, + execute: (raw, { context: { room } }) => { + let context = cardToolContext(room); + return answer("write_decision_prose", () => { + let turn = context.plan.chat.turn; + return runJobTool( + context.plan.chat, + "write_decision_prose", + job => write(context, job, turn, raw), + ); + }); + }, + }), + }; +} diff --git a/apps/server/src/agent/scoped-refine-tool.ts b/apps/server/src/agent/scoped-refine-tool.ts new file mode 100644 index 00000000..496dd7fc --- /dev/null +++ b/apps/server/src/agent/scoped-refine-tool.ts @@ -0,0 +1,80 @@ +import { jsonSchema, tool } from "ai"; +import { cardToolContext } from "./card-tool-context"; +import { answer, cardToolSchema } from "./scoped-tool-result"; +import { refineDecision } from "./refine-tool"; +import { runJobTool } from "./job-scope"; + +export function createRefineTools() { + return { + refine_decision: tool({ + description: "Background job only. Improve one open decision: a refine job may reword its " + + "question and place it after related prose; a suggest job may only add reasoned " + + "options. Adding no option is valid when the evidence supports none.", + inputSchema: jsonSchema>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + id: { type: "string" }, + title: { type: "string", minLength: 1, maxLength: 80 }, + add_options: { + type: "array", + maxItems: 10, + items: { + type: "object", + properties: { + label: { type: "string", minLength: 1, maxLength: 200 }, + rationale: { type: "string", minLength: 1, maxLength: 300 }, + source: { + type: "object", + properties: { + messageId: { type: "string" }, + author: { + type: "object", + properties: { + kind: { type: "string", enum: ["member", "agent"] }, + handle: { type: "string" }, + }, + required: ["kind"], + additionalProperties: false, + }, + quote: { type: "string" }, + start: { type: "integer", minimum: 0 }, + end: { type: "integer", minimum: 1 }, + role: { type: "string", enum: ["option", "question"] }, + }, + required: ["messageId", "author", "quote", "start", "end", "role"], + additionalProperties: false, + }, + }, + required: ["label", "rationale"], + additionalProperties: false, + }, + }, + place_after: { + type: "object", + properties: { + index: { type: "integer", minimum: 0 }, + digest: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" }, + }, + required: ["index", "digest"], + additionalProperties: false, + }, + }, + required: ["revision", "id"], + additionalProperties: false, + }), + contextSchema: cardToolSchema, + execute: (raw, { context: { room } }) => { + let context = cardToolContext(room); + return answer("refine_decision", () => { + let turn = context.plan.chat.turn; + return runJobTool( + context.plan.chat, + "refine_decision", + job => refineDecision(context, job, turn, raw), + ); + }); + }, + }), + }; +} diff --git a/apps/server/src/agent/scoped-request-boundary.test.ts b/apps/server/src/agent/scoped-request-boundary.test.ts new file mode 100644 index 00000000..46928013 --- /dev/null +++ b/apps/server/src/agent/scoped-request-boundary.test.ts @@ -0,0 +1,47 @@ +import { expect, spyOn, test } from "bun:test"; +import { createScopedTools } from "./scoped-tools"; +import { documentRoom } from "./heading-harness.test-fixtures"; +import { openPlan } from "../testing/plan"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Room from "../plan/room"; + +test("raw transcript request state cannot authorize a direct tool without its verified provider", async () => { + let context = await openPlan("Opening prose.\n"); + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId: "thread-a", + header: "Authentication", + question: "Which authentication system?", + options: [], + }); + context.plan.chat.turn = { id: "current-turn", handle: "ana", started: 1, responded: false }; + context.plan.chat.activeRequest = { + entryId: "old-entry", + userId: "U_test", + handle: "ana", + claimantSessionId: "old-session", + turnId: "old-turn", + lifecycle: context.plan.chat.lifecycle - 1, + text: "@chopin revise the question for the Authentication decision", + }; + let room = documentRoom(context); + expect(room.currentMemberRequest).toBeUndefined(); + let before = Room.project(context.plan.document); + let record = structuredClone(context.plan.records.get(id)); + let revision = context.plan.revision; + let result = await createScopedTools().revise_open_decision.execute!({ + revision, + id, + title: "Authentication choice", + }, { context: { room }, toolCallId: "raw-request", messages: [] }); + expect(result).toBe("Error: a direct current member request is required"); + expect(Room.project(context.plan.document)).toBe(before); + expect(context.plan.records.get(id)).toEqual(record); + expect(context.plan.revision).toBe(revision); + } finally { + errors.mockRestore(); + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/agent/scoped-revise-tool.ts b/apps/server/src/agent/scoped-revise-tool.ts new file mode 100644 index 00000000..be0cc76f --- /dev/null +++ b/apps/server/src/agent/scoped-revise-tool.ts @@ -0,0 +1,53 @@ +import { jsonSchema, tool } from "ai"; +import { cardToolContext } from "./card-tool-context"; +import { answer, cardToolSchema } from "./scoped-tool-result"; +import { reviseOpenDecision } from "./revise-open-decision"; + +export function createDirectTools() { + return { + revise_open_decision: tool({ + description: "In a direct member-requested Chat turn, perform exactly one action " + + "on one existing open decision explicitly named in that action's clause: " + + "either reword its question or add evidence-backed options with rationales. " + + "Use separate calls for separate actions, reading the revision again between calls. " + + "Never create a card, rename an existing option, or choose an answer.", + inputSchema: jsonSchema>({ + type: "object", + properties: { + revision: { type: "integer", minimum: 0 }, + id: { type: "string" }, + title: { + type: "string", + minLength: 1, + maxLength: 80, + description: "Only for a question retitle call; omit add_options.", + }, + add_options: { + type: "array", + description: "Only for an option addition call; omit title.", + minItems: 1, + maxItems: 10, + items: { + type: "object", + properties: { + label: { type: "string", minLength: 1, maxLength: 200 }, + rationale: { type: "string", minLength: 1, maxLength: 300 }, + }, + required: ["label", "rationale"], + additionalProperties: false, + }, + }, + }, + required: ["revision", "id"], + additionalProperties: false, + }), + contextSchema: cardToolSchema, + execute: (raw, { context: { room } }) => { + let context = cardToolContext(room); + + let turn = context.plan.chat.turn; + return answer("revise_open_decision", () => reviseOpenDecision(context, turn, raw)); + }, + }), + }; +} diff --git a/apps/server/src/agent/scoped-tool-result.ts b/apps/server/src/agent/scoped-tool-result.ts new file mode 100644 index 00000000..c07dca36 --- /dev/null +++ b/apps/server/src/agent/scoped-tool-result.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import type { CardToolRoom } from "./card-tool-context"; + +export let cardToolSchema = z.object({ + room: z.custom(value => + !!value && typeof value === "object" + && typeof (value as CardToolRoom).id === "string" && !!(value as CardToolRoom).plan + ), +}); + +export async function answer(name: string, produce: () => unknown): Promise { + try { + return JSON.stringify(await produce(), null, 2) ?? "null"; + } catch (err) { + let message = err instanceof Error ? err.message : String(err); + console.error(`[agent/${name}]`, err); + return `Error: ${message}`; + } +} diff --git a/apps/server/src/agent/scoped-tools.test-bridge.ts b/apps/server/src/agent/scoped-tools.test-bridge.ts new file mode 100644 index 00000000..902d7f8e --- /dev/null +++ b/apps/server/src/agent/scoped-tools.test-bridge.ts @@ -0,0 +1,25 @@ +import { createScopedTools } from "./scoped-tools"; +import { documentTools } from "./tools"; +import type { CardToolRoom, Context } from "./card-tool-context"; +import type { ToolSet } from "ai"; + +type ArchivedContext = Omit & { + room: string; + currentMemberRequest?: Context["currentMemberRequest"]; +}; + +/** Executes current AI SDK tools for the unchanged archived test callbacks. */ +export function toolbox(context: ArchivedContext) { + let room = { ...context, id: context.room }; + let tools: ToolSet = { ...documentTools, ...createScopedTools() }; + return Object.entries(tools).map(([name, value]) => ({ + name, + skipPermission: value.metadata?.skipPermission === true, + handler: (raw: unknown, _options: never) => + value.execute!(raw as never, { + context: { room: room as CardToolRoom }, + toolCallId: "scoped-test", + messages: [], + }), + })); +} diff --git a/apps/server/src/agent/scoped-tools.test-configuration.ts b/apps/server/src/agent/scoped-tools.test-configuration.ts new file mode 100644 index 00000000..b5f4e994 --- /dev/null +++ b/apps/server/src/agent/scoped-tools.test-configuration.ts @@ -0,0 +1,86 @@ +import { createPlannerAgent } from "../harness/agents"; +import type { HarnessV1 } from "@ai-sdk/harness"; +import type { toolbox } from "./scoped-tools.test-bridge"; + +let offered: string[] = []; +let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "scoped-metadata", + builtinTools: {}, + async doStart(start) { + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(turn) { + offered = turn.tools.map(spec => spec.name); + queueMicrotask(() => + turn.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }) + ); + return { done: Promise.resolve(), async submitToolResult() {} }; + }, + async doDestroy() {}, + async doCompact() {}, + async doContinueTurn() { + throw new Error("unused"); + }, + async doSuspendTurn() { + throw new Error("unused"); + }, + async doDetach() { + throw new Error("unused"); + }, + async doStop() { + throw new Error("unused"); + }, + }; + }, +}; +let agent = createPlannerAgent(fake); +let session = await agent.createSession({ + sessionId: "scoped-metadata", + sandboxSession: { + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() {}, + } as never, +}); +try { + let result = await agent.stream({ + session, + prompt: "Observe tool registration", + options: { + room: { id: "metadata", plan: { chat: {} } } as never, + repository: {} as never, + owner: {} as never, + githubTools: {}, + instructions: "Observe only", + }, + }); + for await (let _part of result.fullStream) {} +} finally { + await session.destroy(); +} + +/** Legacy assertion shape, derived only from a real current Harness turn's offered tools. */ +export function plannerConfiguration( + _config: { model: string }, + options: { tools: ReturnType }, + _owner: unknown, +) { + return { + availableTools: offered.length ? ["custom:*"] : [], + tools: options.tools.filter(value => offered.includes(value.name)).map(value => ({ + ...value, + skipPermission: agent.tools[value.name]?.metadata?.skipPermission === true, + })), + }; +} diff --git a/apps/server/src/agent/scoped-tools.ts b/apps/server/src/agent/scoped-tools.ts new file mode 100644 index 00000000..634bbf35 --- /dev/null +++ b/apps/server/src/agent/scoped-tools.ts @@ -0,0 +1,7 @@ +import { createRefineTools } from "./scoped-refine-tool"; +import { createDirectTools } from "./scoped-revise-tool"; +import { createProseTools } from "./scoped-prose-tool"; + +export function createScopedTools() { + return { ...createRefineTools(), ...createDirectTools(), ...createProseTools() }; +} diff --git a/apps/server/src/agent/tools.ts b/apps/server/src/agent/tools.ts index d933222a..a3609451 100644 --- a/apps/server/src/agent/tools.ts +++ b/apps/server/src/agent/tools.ts @@ -1,3 +1,4 @@ +import type { ActiveMemberRequest } from "../chat/service"; /** * What the planner can do. * @@ -83,6 +84,8 @@ export type DocumentRoom = { /** Tells the room where this batch wrote, moved and removed. */ changes: (found: edit.Change[]) => void; jobs?: JobService; + /** The same verified member request that owns the current live turn. */ + currentMemberRequest?: () => ActiveMemberRequest | undefined; /** Starts the exact research request represented by the current member turn. */ createResearch?: (question: string) => Promise; /** Reads one reference retained by this room's active Planner session. */ @@ -135,6 +138,9 @@ export const documentTools = { id: record.id, status: record.status, questions: record.definition.questions.map(question => question.question), + options: record.definition.questions.map(question => + question.options.map(option => option.label) + ), ...(record.answers ? { answers: record.answers } : {}), ...(record.resolver ? { answered_by: record.resolver } : {}), })), diff --git a/apps/server/src/chat/job-boundary.test.ts b/apps/server/src/chat/job-boundary.test.ts new file mode 100644 index 00000000..43602041 --- /dev/null +++ b/apps/server/src/chat/job-boundary.test.ts @@ -0,0 +1,71 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import { runJobTool } from "../agent/job-scope"; +import * as Chat from "./service"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("a job refuses an inactive write even after its own tool recorded output", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + await runJobTool(h.chat, "refine_decision", async () => ({ output: {} })); + h.emitPart({ type: "tool-call", toolCallId: "bad", toolName: "edit_plan", input: {} }); + expect(await running).toEqual({ + status: "failed", + reason: "Planner tool boundary failure: edit_plan", + }); + expect(h.chat.entries).toEqual([]); +}); + +test("a job does not trust a result without its live call identity", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + h.emitPart({ + type: "tool-result", + toolCallId: "unknown", + toolName: "refine_decision", + input: {}, + output: '{"added":1}', + }); + expect(await running).toEqual({ + status: "failed", + reason: "Planner tool boundary failure: unrecognized job tool result", + }); + expect(h.chat.jobOutput).toBeUndefined(); +}); + +test("a tool result cannot replace output produced by the captured own-tool wrapper", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + h.emitPart({ type: "tool-call", toolCallId: "own", toolName: "refine_decision", input: {} }); + h.emitPart({ + type: "tool-result", + toolCallId: "own", + toolName: "refine_decision", + input: {}, + output: '{"added":1}', + }); + h.emit("session.idle"); + expect(await running).toEqual({ + status: "failed", + reason: "The Planner ended without calling refine_decision.", + }); +}); + +test("a job aborts a permission refusal without publishing tool details", async () => { + let h = harness(); + let running = Chat.job(h.context, JOB, "prompt", "session"); + await until(() => h.prompts.length === 1); + h.emitPart({ type: "tool-call", toolCallId: "own", toolName: "refine_decision", input: {} }); + h.emitPart({ type: "tool-output-denied", toolCallId: "own", toolName: "refine_decision" }); + expect(await running).toEqual({ + status: "failed", + reason: "Planner tool permission was denied.", + }); + expect(h.chat.entries).toEqual([]); + expect(h.frames.some(frame => frame.kind === "chat:tool")).toBe(false); +}); diff --git a/apps/server/src/chat/job-fence.ts b/apps/server/src/chat/job-fence.ts new file mode 100644 index 00000000..67600f34 --- /dev/null +++ b/apps/server/src/chat/job-fence.ts @@ -0,0 +1,20 @@ +import { jobReason, type JobTurn } from "./job-queue"; +import type { Chat } from "./service"; + +/** A cancelled current turn must lose its tool's live job identity before any late write. */ +export function watchJobAbort(chat: Chat, turn: JobTurn, signal: AbortSignal): () => void { + let currentTurn = chat.turn; + function abort() { + if (chat.job !== turn.job || chat.turn !== currentTurn) return; + chat.job = undefined; + chat.jobOutput = undefined; + chat.interruption ??= chat.closed + ? "The document closed." + : signal.reason instanceof Error && signal.reason.name !== "AbortError" + ? jobReason(signal.reason) + : "The Planner turn was stopped."; + } + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + return () => signal.removeEventListener("abort", abort); +} diff --git a/apps/server/src/chat/job-handoff.test.ts b/apps/server/src/chat/job-handoff.test.ts new file mode 100644 index 00000000..8a192664 --- /dev/null +++ b/apps/server/src/chat/job-handoff.test.ts @@ -0,0 +1,47 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { harness, JOB, member, socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("a queue handoff persist failure settles the shifted job", async () => { + let state = harness(); + let commits = 0; + state.context.persist = async () => { + commits++; + if (commits === 3) throw new Error("handoff unavailable"); + }; + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "later", "session"); + state.emit("session.idle"); + expect(await queued).toEqual({ status: "failed", reason: "handoff unavailable" }); + expect(state.prompts).toHaveLength(1); + expect(state.chat.busy).toBe(false); + expect(state.chat.waiting).toEqual([]); +}); + +test("closing during queue handoff settles a job already removed from waiting", async () => { + let state = harness(); + let commits = 0; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + state.context.persist = async () => { + commits++; + if (commits === 3) { + entered.resolve(); + await release.promise; + } + }; + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "later", "session"); + state.emit("session.idle"); + await entered.promise; + expect(state.chat.waiting).toEqual([]); + let closing = Chat.close(state.chat); + release.resolve(); + await closing; + expect(await queued).toEqual({ status: "failed", reason: "The document closed." }); +}); diff --git a/apps/server/src/chat/job-harness.test-fixtures.ts b/apps/server/src/chat/job-harness.test-fixtures.ts new file mode 100644 index 00000000..28f43c84 --- /dev/null +++ b/apps/server/src/chat/job-harness.test-fixtures.ts @@ -0,0 +1,68 @@ +import * as Chat from "./service"; +import { jobStream } from "./job-stream.test-fixtures"; + +export function harness(agent = true, chats: Chat.Chat[]) { + let chat = Chat.create(); + chats.push(chat); + let stream = jobStream(); + let frames: Array> = []; + let brokenFrame: string | undefined; + let ownerAvailable = true; + let expiresAt = new Date(Date.now() + 60 * 60 * 1000); + let owner = { access: { token: "gho_test", revision: 1, expiresAt }, session: { expiresAt } }; + let repository = { id: "repo", owner: "owner", name: "repo", defaultBranch: "main" }; + let context = { + chat, + plan: { chat }, + server: { + publish(_topic: string, body: string) { + let frame = JSON.parse(body) as Record; + if (frame.kind === brokenFrame) throw new Error("socket broadcast failed"); + frames.push(frame); + }, + }, + room: "room", + config: { agent }, + claimantSessionId: "session", + repository, + persist: async () => {}, + activeOwner: async () => ({ + ownerSessionId: "session", + ownerGeneration: 1, + credentialRevision: 1, + repository, + signal: new AbortController().signal, + revalidate: async () => true, + release() {}, + }), + openPlannerSession: async () => ({ ok: true, value: stream.session() }), + auth: { + storage: { + channels: { + claimAgentOwner: async () => ({ + ownerSessionId: ownerAvailable ? "session" : undefined, + generation: 1, + summary: "", + transcriptCursor: 0, + }), + updateAgentContext: async () => {}, + }, + }, + sessions: { + resolve: async () => owner, + use: async () => ({ + authenticated: owner, + value: { ...repository, permissions: { push: true, admin: false } }, + }), + }, + }, + } as unknown as Chat.Room; + return { + chat, + context, + frames, + ...stream, + breakFrame: (kind: string) => brokenFrame = kind, + setOwnerAvailable: (value: boolean) => ownerAvailable = value, + }; +} diff --git a/apps/server/src/chat/job-heading-harness.test-fixtures.ts b/apps/server/src/chat/job-heading-harness.test-fixtures.ts new file mode 100644 index 00000000..77c31615 --- /dev/null +++ b/apps/server/src/chat/job-heading-harness.test-fixtures.ts @@ -0,0 +1,100 @@ +import type { HarnessV1 } from "@ai-sdk/harness"; + +/** Drives the current HarnessAgent tool dispatcher, so the real bound heading tool executes. */ +export function headingHarness( + onStart: () => Promise, + onResult: (output: unknown) => Promise, + call = { + name: "draft_heading", + input: { + revision: 0, + title: "Authentication", + goal: "Choose our authentication approach.", + } as Record, + }, +) { + let finishes: Array<() => void> = []; + let starts = 0; + let destroyed = 0; + let tools: string[][] = []; + let fake: HarnessV1 = { + specificationVersion: "harness-v1", + harnessId: "chat-heading-memory", + builtinTools: {}, + async doStart(start) { + return { + sessionId: start.sessionId, + isResume: false, + async doPromptTurn(options) { + starts++; + tools.push(options.tools.map(spec => spec.name)); + await onStart(); + let done = Promise.withResolvers(); + let finish = () => { + options.emit({ + type: "finish-step", + finishReason: { unified: "stop", raw: undefined }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }, + }); + done.resolve(); + }; + finishes.push(finish); + options.abortSignal?.addEventListener("abort", finish, { once: true }); + void done.promise.then(() => options.abortSignal?.removeEventListener("abort", finish)); + queueMicrotask(() => + options.emit({ + type: "tool-call", + toolCallId: "heading", + toolName: call.name, + input: JSON.stringify(call.input), + }) + ); + return { + done: done.promise, + async submitToolResult(value) { + await onResult(value.output); + options.emit({ + type: "tool-result", + toolCallId: "heading", + toolName: call.name, + result: value.output as string, + }); + }, + }; + }, + async doDestroy() { + destroyed++; + }, + async doCompact() {}, + async doContinueTurn() { + throw new Error("unused"); + }, + async doSuspendTurn() { + throw new Error("unused"); + }, + async doDetach() { + throw new Error("unused"); + }, + async doStop() { + for (let finish of finishes) finish(); + return { + type: "resume-session", + harnessId: "chat-heading-memory", + specificationVersion: "harness-v1", + data: null, + }; + }, + }; + }, + }; + return { + fake, + tools, + starts: () => starts, + destroyed: () => destroyed, + finish: () => finishes.at(-1)?.(), + }; +} diff --git a/apps/server/src/chat/job-lifecycle.test.ts b/apps/server/src/chat/job-lifecycle.test.ts new file mode 100644 index 00000000..46dbf450 --- /dev/null +++ b/apps/server/src/chat/job-lifecycle.test.ts @@ -0,0 +1,77 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("closing resolves queued and active jobs, and the queue has a bound", async () => { + let queued = harness(); + queued.chat.busy = true; + let waiting = Chat.job(queued.context, JOB, "prompt", "session"); + expect(queued.chat.waiting).toHaveLength(1); + await Chat.close(queued.chat); + expect(await waiting).toEqual({ status: "failed", reason: "The document closed." }); + expect(queued.chat.waiting).toEqual([]); + + let active = harness(); + let running = Chat.job(active.context, JOB, "prompt", "session"); + await until(() => active.prompts.length === 1); + await Chat.close(active.chat); + expect(await running).toEqual({ status: "failed", reason: "The document closed." }); + + let full = harness(); + full.chat.busy = true; + for (let index = 0; index < 20; index++) { + full.chat.waiting.push({ id: `w${index}`, handle: "ana", text: "human" }); + } + expect(await Chat.job(full.context, JOB, "prompt", "session")).toEqual({ + status: "skipped", + reason: "The Planner queue is full.", + }); + expect(full.chat.waiting).toHaveLength(20); +}); + +test("ephemeral state and queue broadcast failures cannot strand a job", async () => { + let direct = harness(); + direct.breakFrame("chat:state"); + let running = Chat.job(direct.context, JOB, "prompt", "session"); + await until(() => direct.prompts.length === 1); + await runJobTool(direct.chat, "refine_decision", async () => ({ output: {} })); + direct.emit("session.idle"); + expect(await running).toEqual({ status: "done", output: "{}" }); + + let queued = harness(); + queued.chat.busy = true; + queued.breakFrame("chat:queue"); + let waiting = Chat.job(queued.context, JOB, "prompt", "session"); + expect(queued.chat.waiting).toHaveLength(1); + await Chat.close(queued.chat); + expect(await waiting).toEqual({ status: "failed", reason: "The document closed." }); +}); + +test("a final persist failure settles the job and releases the queue", async () => { + let state = harness(); + state.context.persist = async () => { + throw new Error("storage unavailable"); + }; + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, { ...JOB, id: "refine:W2:m2" }, "later", "session"); + await runJobTool(state.chat, "refine_decision", async () => ({ output: { added: 1 } })); + state.emit("session.idle"); + let outcome = await Promise.race([ + running, + Bun.sleep(300).then(() => { + throw new Error("job promise stranded by persist failure"); + }), + ]); + expect(outcome.status).toBe("failed"); + expect(await queued).toEqual({ + status: "failed", + reason: "The Planner turn could not be saved.", + }); + expect(state.chat.busy).toBe(false); + expect(state.chat.waiting).toEqual([]); +}); diff --git a/apps/server/src/chat/job-notice.test.ts b/apps/server/src/chat/job-notice.test.ts new file mode 100644 index 00000000..4dacd135 --- /dev/null +++ b/apps/server/src/chat/job-notice.test.ts @@ -0,0 +1,26 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { Chat as Wire } from "@chopin/protocol"; + +installJobCleanup(); + +test("a committed notice survives a failed post-commit broadcast", async () => { + let opened = await openPlan(); + opened.breakRelay("chat:message"); + try { + let entry = await Chat.notice({ + chat: opened.plan.chat, + plan: opened.plan, + server: opened.server, + room: opened.channel.id, + }, "The Planner refined this decision."); + let stored = await opened.storage.collaboration.load(opened.channel.id, opened.now); + let sidecar = stored?.sidecar ?? stored?.snapshot?.sidecar; + expect((sidecar as { transcript: Wire.Entry[] }).transcript).toContainEqual(entry); + } finally { + await Service.close(opened.plan); + } +}); diff --git a/apps/server/src/chat/job-profiles.test.ts b/apps/server/src/chat/job-profiles.test.ts new file mode 100644 index 00000000..b8d6cc21 --- /dev/null +++ b/apps/server/src/chat/job-profiles.test.ts @@ -0,0 +1,110 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import * as Plan from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { createPlannerAgent } from "../harness/agents"; +import { openPlannerSession } from "../harness/session"; +import { BACKGROUND_TOOL_NAMES } from "../harness/tool-names"; +import { JOB_TOOLS, WRITE_TOOLS } from "../agent/job-scope"; +import { headingHarness } from "./job-heading-harness.test-fixtures"; +import { harness, JOB } from "./job.test-fixtures"; +import type { ActiveOwnerBinding } from "../agent/active-owner"; +import type { ConversationPlan } from "@chopin/protocol"; + +installJobCleanup(); + +test("each complete background profile opens the current session with only reads and its own writer", async () => { + for (let kind of ["heading", "refine", "suggest", "prose"] as ConversationPlan.JobKind[]) { + let opened = await openPlan(); + opened.plan.chat.job = { ...JOB, kind }; + let driver = headingHarness(async () => {}, async output => { + expect(String(output)).toContain('"revision": 0'); + driver.finish(); + }, { name: "read_plan", input: {} }); + let agent = createPlannerAgent(driver.fake, kind); + let owner: ActiveOwnerBinding = { + channelId: opened.channel.id, + token: "token", + repository: { id: "R_test", owner: "owner", name: "repository", defaultBranch: "main" }, + ownerSessionId: "owner", + ownerGeneration: 1, + credentialRevision: 1, + expiresAt: new Date(Date.now() + 60_000), + signal: new AbortController().signal, + currentToken: () => "token", + revalidate: async () => true, + release() {}, + }; + let result = await openPlannerSession(owner, { + room: Chat.documentRoom( + { + plan: opened.plan, + chat: opened.plan.chat, + room: opened.channel.id, + server: opened.server, + persist: () => Plan.persist(opened.plan), + } as Chat.Room, + ), + repository: owner.repository, + instructions: "Read the document.", + }, { + headingAgent: agent, + refineAgent: agent, + proseAgent: agent, + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() {}, + }) as never, + registerCredential: () => () => {}, + }); + try { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("complete profile did not open"); + expect(result.value.activeTools).toEqual(BACKGROUND_TOOL_NAMES[kind]); + expect(Object.isFrozen(result.value.activeTools)).toBe(true); + let stream = await result.value.stream("Read", owner.signal); + for await (let _part of stream.fullStream) {} + expect(driver.tools).toEqual([[...BACKGROUND_TOOL_NAMES[kind]]]); + expect(driver.tools[0]!.filter(name => WRITE_TOOLS.has(name))).toEqual([JOB_TOOLS[kind]]); + await result.value.destroy(); + } finally { + opened.plan.chat.job = undefined; + await Plan.close(opened.plan); + } + } +}); + +test("the direct writer receives the same live verified member request", () => { + let h = harness(); + h.chat.busy = true; + h.chat.turn = { id: "turn", handle: "ana", started: 1, responded: false }; + let active: Chat.ActiveMemberRequest = { + entryId: "entry", + userId: "U_ana", + handle: "ana", + text: "Rename the auth decision.", + claimantSessionId: "session", + turnId: "turn", + lifecycle: h.chat.lifecycle, + }; + h.chat.entries.push({ + id: "entry", + author: { kind: "member", handle: "ana" }, + text: active.text, + ts: 1, + }); + h.chat.activeRequest = active; + let room = Chat.documentRoom(h.context); + expect(room.currentMemberRequest?.()).toBe(active); + h.chat.entries[0]!.text = "Changed source"; + expect(room.currentMemberRequest?.()).toBeUndefined(); + h.chat.entries[0]!.text = active.text; + h.chat.lifecycle++; + expect(room.currentMemberRequest?.()).toBeUndefined(); +}); diff --git a/apps/server/src/chat/job-queue.test.ts b/apps/server/src/chat/job-queue.test.ts new file mode 100644 index 00000000..5712f52e --- /dev/null +++ b/apps/server/src/chat/job-queue.test.ts @@ -0,0 +1,83 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, member, socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("jobs and human turns drain in arrival order without leaking job scope", async () => { + let first = harness(); + await Chat.send(first.context, socket(), member("Human first")); + await until(() => first.prompts.length === 1); + let behind = Chat.job(first.context, JOB, "job second", "session"); + expect(first.chat.waiting).toHaveLength(1); + first.emit("session.idle"); + await until(() => first.prompts.length === 2); + expect(first.prompts[1]).toBe("job second"); + await runJobTool(first.chat, "refine_decision", async () => ({ output: { added: 0 } })); + first.emit("session.idle"); + expect(await behind).toEqual({ status: "done", output: '{"added":0}' }); + + let second = harness(); + let ahead = Chat.job(second.context, JOB, "job first", "session"); + await until(() => second.prompts.length === 1); + await Chat.send(second.context, socket(), member("Human second")); + expect(second.chat.waiting).toHaveLength(1); + await runJobTool(second.chat, "refine_decision", async () => ({ output: { added: 0 } })); + second.emit("session.idle"); + expect(await ahead).toEqual({ status: "done", output: '{"added":0}' }); + await until(() => second.prompts.length === 2); + expect(second.chat.job).toBeUndefined(); + expect(second.prompts[1]).toContain("Human second"); + second.emit("session.idle"); + await until(() => !second.chat.busy); +}); + +test("archiving cancels a job queued behind a human turn without dropping human work", async () => { + let state = harness(); + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "job later", "session"); + state.chat.waiting.push({ id: "human-later", handle: "ana", text: "Human later" }); + expect(state.chat.waiting).toHaveLength(2); + Chat.cancelQueuedJobs(state.context, "This document was archived."); + expect(await queued).toEqual({ + status: "failed", + reason: "This document was archived.", + }); + expect(state.chat.waiting.map(item => item.id)).toEqual(["human-later"]); + expect(state.frames.at(-1)).toMatchObject({ kind: "chat:queue" }); + state.emit("session.idle"); + await until(() => state.prompts.length === 2); + expect(state.prompts).not.toContain("job later"); + state.emit("session.idle"); + await until(() => !state.chat.busy); +}); + +test("archive reset fences a job shifted out of the queue during persistence", async () => { + let state = harness(); + await Chat.send(state.context, socket(), member("Human first")); + await until(() => state.prompts.length === 1); + let queued = Chat.job(state.context, JOB, "job later", "session"); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let saves = 0; + state.context.persist = async () => { + if (++saves === 2) { + entered.resolve(); + await release.promise; + } + }; + state.emit("session.idle"); + await entered.promise; + expect(state.chat.waiting).toEqual([]); + Chat.cancelQueuedJobs(state.context, "This document was archived."); + await Chat.resetAgent(state.chat, undefined, undefined, "This document was archived."); + release.resolve(); + expect(await queued).toEqual({ + status: "failed", + reason: "This document was archived.", + }); + expect(state.prompts).toHaveLength(1); +}); diff --git a/apps/server/src/chat/job-queue.ts b/apps/server/src/chat/job-queue.ts new file mode 100644 index 00000000..d7bd689c --- /dev/null +++ b/apps/server/src/chat/job-queue.ts @@ -0,0 +1,152 @@ +import { ulid } from "@chopin/dialect"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import type { JobOutcome } from "../conversation-plan/jobs"; +import type { Announcer } from "./notices"; +import type { Chat, Room, Waiting } from "./service"; + +export type JobTurn = { + job: ConversationPlan.Job; + prompt: string; + done: (outcome: JobOutcome) => void; + settled?: boolean; +}; + +export function jobReason(error: unknown): string { + let message = error instanceof Error ? error.message : String(error); + return message.trim().slice(0, 500) || "The Planner could not be reached."; +} + +export function finishJob(turn: JobTurn, outcome: JobOutcome): void { + if (turn.settled) return; + turn.settled = true; + turn.done(outcome); +} + +export function safeProjection(project: (...args: Args) => void) { + return (...args: Args): void => { + try { + project(...args); + } catch (error) { + console.error("[chat] could not broadcast background job state:", error); + } + }; +} + +export function createJobQueue({ queued, state, startRun, MAX_QUEUE }: { + MAX_QUEUE: number; + queued: (chat: Chat, server: Server, room: string) => void; + state: (chat: Chat, server: Server, room: string) => void; + startRun: ( + context: Room, + handle: string, + text: string, + thread: string | undefined, + claimantSessionId: string, + reserved?: boolean, + member?: never, + references?: [], + jobTurn?: JobTurn, + ) => void; +}) { + function cancelQueuedJobs(context: Announcer, reason: string): number { + let { chat, server, room } = context; + let retained: Waiting[] = []; + let cancelled = 0; + for (let waiting of chat.waiting) { + if (!waiting.job) { + retained.push(waiting); + continue; + } + cancelled++; + finishJob(waiting.job, { status: "failed", reason: jobReason(reason) }); + } + if (chat.handoffJob && !chat.handoffJob.settled) { + cancelled++; + finishJob(chat.handoffJob, { status: "failed", reason: jobReason(reason) }); + } + if (cancelled > 0) { + chat.waiting = retained; + queued(chat, server, room); + } + return cancelled; + } + + function stopAfterPersistenceFailure(chat: Chat, server: Server, room: string): void { + chat.closed = true; + chat.busy = false; + chat.turn = undefined; + chat.acting = undefined; + let waiting = chat.waiting; + chat.waiting = []; + for (let item of waiting) { + if (item.job) { + finishJob(item.job, { + status: "failed", + reason: "The Planner turn could not be saved.", + }); + } + } + queued(chat, server, room); + state(chat, server, room); + } + + function job( + context: Room, + value: ConversationPlan.Job, + prompt: string, + claimantSessionId: string, + signal?: AbortSignal, + ): Promise { + let { chat, config, room, server } = context; + if (!config.agent) { + return Promise.resolve({ status: "skipped", reason: "The Planner is off (AGENT=off)." }); + } + if (chat.closed) return Promise.resolve({ status: "failed", reason: "The document closed." }); + let cancelled: JobOutcome = { status: "failed", reason: "The Planner job was cancelled." }; + if (signal?.aborted) return Promise.resolve(cancelled); + let { promise, resolve } = Promise.withResolvers(); + let turn: JobTurn = { + job: value, + prompt, + done: outcome => { + signal?.removeEventListener("abort", cancel); + resolve(outcome); + }, + }; + function cancel() { + if (turn.settled) return; + if (chat.job === value) { + chat.interruption = "The Planner job was cancelled."; + chat.job = undefined; + chat.jobOutput = undefined; + chat.turnController?.abort(); + return; + } + chat.waiting = chat.waiting.filter(item => item.job !== turn); + finishJob(turn, cancelled); + queued(chat, server, room); + } + signal?.addEventListener("abort", cancel, { once: true }); + if (chat.busy) { + if (chat.waiting.length >= MAX_QUEUE) { + finishJob(turn, { status: "skipped", reason: "The Planner queue is full." }); + return promise; + } + chat.waiting.push({ + id: ulid(), + handle: "chopin", + text: "(background job)", + sessionId: claimantSessionId, + job: turn, + }); + queued(chat, server, room); + } else { + startRun(context, "chopin", prompt, undefined, claimantSessionId, false, undefined, [], turn); + } + return promise; + } + + return { job, cancelQueuedJobs, stopAfterPersistenceFailure }; +} diff --git a/apps/server/src/chat/job-signal.test.ts b/apps/server/src/chat/job-signal.test.ts new file mode 100644 index 00000000..defb9e08 --- /dev/null +++ b/apps/server/src/chat/job-signal.test.ts @@ -0,0 +1,56 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { harness, JOB, member, socket, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("the coordinator signal cancels only its queued job and retains human work", async () => { + let h = harness(); + h.chat.busy = true; + let signal = new AbortController(); + let queued = Chat.job(h.context, JOB, "prompt", "session", signal.signal); + h.chat.waiting.push({ id: "human", handle: "ana", text: "Human later" }); + signal.abort(); + expect(await queued).toEqual({ status: "failed", reason: "The Planner job was cancelled." }); + expect(h.chat.waiting.map(item => item.id)).toEqual(["human"]); + expect(h.prompts).toEqual([]); +}); + +test("the coordinator signal fences active own-tool scope before settling the turn", async () => { + let h = harness(); + let signal = new AbortController(); + let running = Chat.job(h.context, JOB, "prompt", "session", signal.signal); + await until(() => h.prompts.length === 1); + signal.abort(); + expect(h.chat.job).toBeUndefined(); + expect(await running).toEqual({ status: "failed", reason: "The Planner job was cancelled." }); + expect(h.chat.entries).toEqual([]); + expect(h.chat.busy).toBe(false); +}); + +test("the coordinator signal settles a shifted job before a held handoff commit finishes", async () => { + let h = harness(); + await Chat.send(h.context, socket(), member("Human first")); + await until(() => h.prompts.length === 1); + let signal = new AbortController(); + let running = Chat.job(h.context, JOB, "later", "session", signal.signal); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let saves = 0; + h.context.persist = async () => { + if (++saves === 2) { + entered.resolve(); + await release.promise; + } + }; + h.emit("session.idle"); + await entered.promise; + expect(h.chat.waiting).toEqual([]); + signal.abort(); + expect(await running).toEqual({ status: "failed", reason: "The Planner job was cancelled." }); + release.resolve(); + await until(() => !h.chat.busy); + expect(h.prompts).toHaveLength(1); + expect(h.chat.handoffJob).toBeUndefined(); +}); diff --git a/apps/server/src/chat/job-stream.test-fixtures.ts b/apps/server/src/chat/job-stream.test-fixtures.ts new file mode 100644 index 00000000..4f42e934 --- /dev/null +++ b/apps/server/src/chat/job-stream.test-fixtures.ts @@ -0,0 +1,109 @@ +import type { TextStreamPart, ToolSet } from "ai"; +import type { PlannerSession } from "../harness/session"; + +type Part = TextStreamPart; + +/** Legacy test commands feed the current typed stream, without a Copilot SDK session. */ +export function jobStream() { + let prompts: string[] = []; + let parts: Part[] = []; + let wake = Promise.withResolvers(); + let ended = false; + let error: Error | undefined; + let aborts = 0; + function push(part: Part) { + parts.push(part); + wake.resolve(); + } + function end() { + ended = true; + wake.resolve(); + } + function emit(type: string, data: Record = {}) { + switch (type) { + case "assistant.message_delta": + push({ type: "text-delta", id: String(data.messageId), text: String(data.deltaContent) }); + return; + case "assistant.message": + push({ type: "text-end", id: String(data.messageId) }); + return; + case "tool.execution_start": + push({ + type: "tool-call", + toolCallId: String(data.toolCallId), + toolName: String(data.toolName), + input: {}, + }); + return; + case "tool.execution_complete": { + let content = (data.result as { content?: unknown } | undefined)?.content; + push( + data.success + ? { + type: "tool-result", + toolCallId: String(data.toolCallId), + toolName: "refine_decision", + input: {}, + output: content, + } + : { + type: "tool-error", + toolCallId: String(data.toolCallId), + toolName: "refine_decision", + input: {}, + error: content, + }, + ); + return; + } + case "session.error": + push({ type: "error", error: new Error(String(data.message)) }); + end(); + return; + case "session.idle": + end(); + } + } + function session(): PlannerSession { + return { + activeTools: ["read_plan", "refine_decision"], + async stream(prompt, signal) { + prompts.push(prompt); + if (error) throw error; + parts = []; + ended = false; + wake = Promise.withResolvers(); + let stop = () => { + aborts++; + end(); + }; + signal.addEventListener("abort", stop, { once: true }); + if (signal.aborted) stop(); + let fullStream = (async function*() { + try { + while (true) { + while (parts.length) yield parts.shift()!; + if (ended) return; + await wake.promise; + wake = Promise.withResolvers(); + } + } finally { + signal.removeEventListener("abort", stop); + } + })(); + return { fullStream } as never; + }, + async destroy() { + end(); + }, + }; + } + return { + prompts, + emit, + emitPart: push, + session, + aborts: () => aborts, + setSendError: (value: Error) => error = value, + }; +} diff --git a/apps/server/src/chat/job-tools.test.ts b/apps/server/src/chat/job-tools.test.ts new file mode 100644 index 00000000..afd99897 --- /dev/null +++ b/apps/server/src/chat/job-tools.test.ts @@ -0,0 +1,96 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("resetting an interrupted session fails its active job without a transcript line", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + await Chat.resetAgent(state.chat, "session", 1, "Planner credentials were revoked."); + expect(await running).toEqual({ + status: "failed", + reason: "Planner credentials were revoked.", + }); + expect(state.chat.entries).toEqual([]); +}); + +test("two failed own-tool calls end the turn after one possible re-read", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + for (let index = 0; index < 2; index++) { + state.emit("tool.execution_start", { + toolCallId: `tool-${index}`, + toolName: "refine_decision", + }); + state.emit("tool.execution_complete", { + toolCallId: `tool-${index}`, + success: true, + result: { content: "Error: stale revision; read_plan again" }, + }); + if (index === 0) expect(state.chat.job).toBe(JOB); + } + let outcome = await running; + expect(outcome.status).toBe("failed"); + if (outcome.status === "done") throw new Error("two tool failures completed the job"); + expect(outcome.reason).toContain("failed twice"); + expect(state.aborts()).toBe(1); + expect(state.chat.job).toBeUndefined(); +}); + +test("a bounded source-shape tool failure is reported without echoing its arguments", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + state.emit("tool.execution_start", { + toolCallId: "malformed-source", + toolName: "refine_decision", + }); + state.emit("tool.execution_complete", { + toolCallId: "malformed-source", + success: true, + result: { + content: "Error: source-shape", + arguments: { source: { quote: "private malformed quote" } }, + }, + }); + state.emit("session.idle"); + + let outcome = await running; + expect(outcome).toEqual({ status: "failed", reason: "source-shape" }); + if (outcome.status === "done") throw new Error("a malformed source completed the job"); + expect(outcome.reason).not.toContain("private malformed quote"); + expect(outcome.reason).not.toContain("arguments"); +}); + +test("a successful retry clears a prior source-shape tool failure", async () => { + let state = harness(); + let running = Chat.job(state.context, JOB, "prompt", "session"); + await until(() => state.prompts.length === 1); + state.emit("tool.execution_start", { + toolCallId: "malformed-source", + toolName: "refine_decision", + }); + state.emit("tool.execution_complete", { + toolCallId: "malformed-source", + success: true, + result: { content: "Error: source-shape" }, + }); + state.emit("tool.execution_start", { + toolCallId: "valid-source", + toolName: "refine_decision", + }); + await runJobTool(state.chat, "refine_decision", async () => ({ output: { added: 1 } })); + state.emit("tool.execution_complete", { + toolCallId: "valid-source", + success: true, + result: { content: '{"ok":true}' }, + }); + state.emit("session.idle"); + + expect(await running).toEqual({ status: "done", output: '{"added":1}' }); +}); diff --git a/apps/server/src/chat/job-translate.ts b/apps/server/src/chat/job-translate.ts new file mode 100644 index 00000000..8f1a5799 --- /dev/null +++ b/apps/server/src/chat/job-translate.ts @@ -0,0 +1,67 @@ +import { JOB_TOOLS, WRITE_TOOLS } from "../agent/job-scope"; +import type { TextStreamPart, ToolSet } from "ai"; +import type { Room } from "./service"; + +/** Background stream content stays private; only the live own-tool wrapper records output. */ +export function translateJob(context: Room, part: TextStreamPart): void { + let { chat } = context; + let job = chat.job; + if (!job) return; + let own = JOB_TOOLS[job.kind]; + function interrupt(reason: string) { + chat.interruption ??= reason; + chat.turnController?.abort(); + } + switch (part.type) { + case "tool-call": { + if ( + !chat.jobToolNames?.has(part.toolName) + || WRITE_TOOLS.has(part.toolName) && part.toolName !== own + || chat.jobCalls?.has(part.toolCallId) + ) { + console.error(`[chat] boundary failure: inactive job tool ${part.toolName}`); + interrupt(`Planner tool boundary failure: ${part.toolName}`); + return; + } + chat.jobCalls?.set(part.toolCallId, part.toolName); + return; + } + case "tool-result": + case "tool-error": + case "tool-output-denied": { + let name = chat.jobCalls?.get(part.toolCallId); + chat.jobCalls?.delete(part.toolCallId); + if (!name || "toolName" in part && part.toolName !== name) { + interrupt("Planner tool boundary failure: unrecognized job tool result"); + return; + } + if (part.type === "tool-output-denied") { + interrupt("Planner tool permission was denied."); + return; + } + if (name !== own) return; + let content = part.type === "tool-result" ? part.output : part.error; + chat.jobFailureCode = name === "refine_decision" && part.type === "tool-result" + && content === "Error: source-shape" + ? "source-shape" + : undefined; + if ( + part.type === "tool-error" || typeof content === "string" && content.startsWith("Error:") + ) { + chat.jobFailures = (chat.jobFailures ?? 0) + 1; + if (chat.jobFailures >= 2) { + interrupt(chat.jobFailureCode ?? `The Planner's ${own} failed twice.`); + } + } + return; + } + case "tool-approval-request": + interrupt("Planner tool approval was denied."); + return; + case "error": + interrupt(part.error instanceof Error ? part.error.message : String(part.error)); + return; + default: + return; + } +} diff --git a/apps/server/src/chat/job.test-fixtures.ts b/apps/server/src/chat/job.test-fixtures.ts new file mode 100644 index 00000000..55af6331 --- /dev/null +++ b/apps/server/src/chat/job.test-fixtures.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, spyOn } from "bun:test"; +import * as Chat from "./service"; +import { harness as currentHarness } from "./job-harness.test-fixtures"; +import type { Chat as Wire, ConversationPlan, Request } from "@chopin/protocol"; +import type { Socket } from "../wire"; + +export const JOB: ConversationPlan.Job = { + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", +}; + +let chats: Chat.Chat[] = []; +let errors: ReturnType | undefined; + +export function installJobCleanup(): void { + beforeEach(() => { + errors = spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + for (let chat of chats) await Chat.close(chat); + chats = []; + errors?.mockRestore(); + errors = undefined; + }); +} + +export async function until(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 300; attempt++) { + if (condition()) return; + await Bun.sleep(1); + } + throw new Error("condition did not become true"); +} + +export function member(text: string): Request { + return { + kind: "chat:send", + rid: crypto.randomUUID(), + requestId: crypto.randomUUID(), + to: "planner", + text, + ts: 0, + }; +} + +export function socket(): Socket { + return { data: { handle: "ana", principalId: "U_ana" }, send() {} } as unknown as Socket; +} + +export function harness(agent = true) { + return currentHarness(agent, chats); +} diff --git a/apps/server/src/chat/job.test.ts b/apps/server/src/chat/job.test.ts new file mode 100644 index 00000000..d5dc8131 --- /dev/null +++ b/apps/server/src/chat/job.test.ts @@ -0,0 +1,95 @@ +import { installJobCleanup } from "./job.test-fixtures"; +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import { runJobTool } from "../agent/job-scope"; +import { harness, JOB, until } from "./job.test-fixtures"; + +installJobCleanup(); + +test("job skips disabled and unavailable Planner sessions without transcript entries", async () => { + let off = harness(false); + expect(await Chat.job(off.context, JOB, "exact prompt", "session")).toEqual({ + status: "skipped", + reason: "The Planner is off (AGENT=off).", + }); + expect(off.chat.entries).toEqual([]); + let missing = harness(); + missing.setOwnerAvailable(false); + let outcome = await Chat.job(missing.context, JOB, "exact prompt", "session"); + expect(outcome.status).toBe("skipped"); + if (outcome.status === "done") throw new Error("an unavailable owner completed a job"); + expect(outcome.reason).toContain("owner is unavailable"); + expect(missing.chat.entries).toEqual([]); + expect(missing.chat.busy).toBe(false); + let credentials = harness(); + (credentials.context.auth.sessions as never as { resolve: () => Promise }).resolve = + async () => { + throw new Error("credential expired"); + }; + expect(await Chat.job(credentials.context, JOB, "prompt", "session")).toEqual({ + status: "skipped", + reason: "credential expired", + }); +}); + +test("job sends its prompt verbatim and keeps backscroll while suppressing model chatter", async () => { + let state = harness(); + state.chat.backscroll = [{ handle: "ana", text: "Human context" }]; + let running = Chat.job(state.context, JOB, "EXACT job prompt\nsecond line", "session"); + await until(() => state.prompts.length === 1); + expect(state.prompts).toEqual(["EXACT job prompt\nsecond line"]); + expect(state.chat.turn?.handle).toBe("chopin"); + expect(state.chat.job).toBe(JOB); + state.emit("assistant.message_delta", { messageId: "m", deltaContent: "private thought" }); + state.emit("assistant.message", { messageId: "m", content: "private thought" }); + state.emit("tool.execution_start", { toolCallId: "tool", toolName: "refine_decision" }); + await runJobTool(state.chat, "refine_decision", async () => ({ output: { added: 1 } })); + state.emit("tool.execution_complete", { + toolCallId: "tool", + success: true, + result: { content: '{"ok":true}' }, + }); + state.emit("session.idle"); + expect(await running).toEqual({ status: "done", output: '{"added":1}' }); + expect(state.chat.backscroll).toEqual([{ handle: "ana", text: "Human context" }]); + expect(state.chat.entries).toEqual([]); + expect(state.frames.some(frame => frame.kind === "chat:message" || frame.kind === "chat:tool")) + .toBe(false); + expect(state.chat.job).toBeUndefined(); + expect(state.chat.jobOutput).toBeUndefined(); + expect(state.chat.busy).toBe(false); +}); + +test("job fails on SDK send error, session error after tool output, and a tool-less idle", async () => { + let send = harness(); + send.setSendError(new Error("send unavailable")); + expect(await Chat.job(send.context, JOB, "prompt", "session")).toEqual({ + status: "failed", + reason: "send unavailable", + }); + expect(send.chat.entries).toEqual([]); + + let crashed = harness(); + let work = Chat.job(crashed.context, JOB, "prompt", "session"); + await until(() => crashed.prompts.length === 1); + await runJobTool(crashed.chat, "refine_decision", async () => ({ output: { added: 1 } })); + crashed.emit("session.error", { message: "model failed after the write" }); + expect(await work).toEqual({ status: "failed", reason: "model failed after the write" }); + expect(crashed.chat.entries).toEqual([]); + + let idle = harness(); + let empty = Chat.job(idle.context, JOB, "prompt", "session"); + await until(() => idle.prompts.length === 1); + idle.emit("session.idle"); + expect(await empty).toEqual({ + status: "failed", + reason: "The Planner ended without calling refine_decision.", + }); + + let long = harness(); + long.setSendError(new Error("x".repeat(600))); + let bounded = await Chat.job(long.context, JOB, "prompt", "session"); + expect(bounded.status).toBe("failed"); + if (bounded.status === "done") throw new Error("a failed send completed the job"); + expect(bounded.reason).toHaveLength(500); +}); diff --git a/apps/server/src/chat/notice-idempotency.test.ts b/apps/server/src/chat/notice-idempotency.test.ts new file mode 100644 index 00000000..6f682a49 --- /dev/null +++ b/apps/server/src/chat/notice-idempotency.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { hosted } from "./notice.test-fixtures"; + +test("a stable notice is committed and broadcast only once across concurrent retries", async () => { + let context = await hosted(); + let original = context.storage.collaboration.commit; + let commits = 0; + context.storage.collaboration.commit = async input => { + commits++; + let result = await original(input); + context.order.push("committed"); + return result; + }; + let id = "research-complete:request-1"; + let [first, second] = await Promise.all([ + Chat.noticeOnce(context.announcer, id, "Research is ready"), + Chat.noticeOnce(context.announcer, id, "Research is ready"), + ]); + expect(second).toBe(first); + expect(first.id).toBe(id); + expect(commits).toBe(1); + expect(context.order).toEqual(["committed", "broadcast:Research is ready"]); + expect(context.plan.chat.entries).toEqual([first]); + await Service.close(context.plan); +}); + +test("a stable notice retry after restart returns its durable entry without publication", async () => { + let context = await hosted(); + let id = "research-complete:request-2"; + let input = { + text: "Research is ready", + decision: { questionnaireId: "document", kind: "activity" as const, label: "Ready" }, + }; + let first = await Chat.noticeOnce(context.announcer, id, input); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + let announcer = { ...context.announcer, chat: restored.chat, plan: restored }; + context.order.length = 0; + let retry = await Chat.noticeOnce(announcer, id, input); + expect(retry).toBe(restored.chat.entries[0]); + expect(retry).toEqual(first); + expect(context.order).toEqual([]); + await Service.close(restored); +}); + +test("a stable notice rejects divergent content without changing the transcript", async () => { + let context = await hosted(); + let id = "research-complete:request-3"; + let first = await Chat.noticeOnce(context.announcer, id, "Research is ready"); + context.order.length = 0; + await expect(Chat.noticeOnce(context.announcer, id, "Research failed")) + .rejects.toThrow("conflicts with an existing entry"); + await expect(Chat.noticeOnce(context.announcer, id, { + text: "Research is ready", + decision: { questionnaireId: "document", kind: "activity" }, + })).rejects.toThrow("conflicts with an existing entry"); + expect(context.plan.chat.entries).toEqual([first]); + expect(context.order).toEqual([]); + await Service.close(context.plan); +}); + +test("a failed stable notice commit can be retried with the same ID", async () => { + let context = await hosted(); + let original = context.storage.collaboration.commit; + let attempts = 0; + context.storage.collaboration.commit = async input => { + attempts++; + if (attempts === 1) throw new Error("disk"); + return original(input); + }; + let id = "research-complete:request-4"; + await expect(Chat.noticeOnce(context.announcer, id, "Research is ready")) + .rejects.toThrow("disk"); + expect(context.plan.chat.entries).toEqual([]); + expect(context.order).toEqual([]); + let entry = await Chat.noticeOnce(context.announcer, id, "Research is ready"); + expect(entry.id).toBe(id); + expect(context.plan.chat.entries).toEqual([entry]); + expect(context.order).toEqual(["broadcast:Research is ready"]); + await Service.close(context.plan); +}); diff --git a/apps/server/src/chat/notice-persistence.test.ts b/apps/server/src/chat/notice-persistence.test.ts new file mode 100644 index 00000000..15a2ce1e --- /dev/null +++ b/apps/server/src/chat/notice-persistence.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import type { Socket } from "../wire"; +import { hosted } from "./notice.test-fixtures"; + +test("a decision notice commits before broadcasting and restores", async () => { + let context = await hosted(); + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async input => { + let result = await original(input); + context.order.push("committed"); + return result; + }; + let entry = await Chat.notice(context.announcer, { + text: "Ready to decide: What auth system should we use?", + decision: { questionnaireId: ulid(), kind: "prompt", generation: 0 }, + }); + expect(context.order).toEqual([ + "committed", + "broadcast:Ready to decide: What auth system should we use?", + ]); + expect(entry).toMatchObject({ author: { kind: "system" }, decision: { kind: "prompt" } }); + expect(context.plan.chat.entries.at(-1)).toBe(entry); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + expect(restored.chat.entries.at(-1)?.decision).toEqual(entry.decision); + await Service.close(restored); +}); + +test("a refreshed notice keeps its ID, commits before broadcast, and rolls back on failure", async () => { + let context = await hosted(); + let questionnaireId = ulid(); + let first = await Chat.notice(context.announcer, { + text: "Ready to decide", + decision: { questionnaireId, kind: "prompt", generation: 0, label: "Initial" }, + }); + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async input => { + let result = await original(input); + context.order.push("committed"); + return result; + }; + let refreshed = await Service.exclusive( + context.plan, + () => + Chat.refreshNoticeExclusive(context.announcer, first.id, { + text: "Ready to decide", + decision: { questionnaireId, kind: "prompt", generation: 0, label: "Updated" }, + }), + ); + expect(refreshed.id).toBe(first.id); + expect(context.plan.chat.entries).toHaveLength(1); + expect(context.order.slice(-2)).toEqual(["committed", "broadcast:Ready to decide"]); + expect(context.frames.at(-1)?.entry?.decision).toEqual(refreshed.decision); + + let broadcasts = context.frames.length; + context.storage.collaboration.commit = async () => { + throw new Error("disk"); + }; + await expect( + Service.exclusive(context.plan, () => + Chat.refreshNoticeExclusive(context.announcer, first.id, { + text: "Ready to decide", + decision: { questionnaireId, kind: "prompt", generation: 0, label: "Failed" }, + })), + ).rejects.toThrow("disk"); + expect(context.plan.chat.entries[0]).toBe(refreshed); + expect(context.frames).toHaveLength(broadcasts); + context.storage.collaboration.commit = original; + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + expect(restored.chat.entries).toHaveLength(1); + expect(restored.chat.entries[0]?.decision).toEqual(refreshed.decision); + await Service.close(restored); +}); + +test("a failed notice commit cannot be saved by a following transcript write", async () => { + let context = await hosted(); + let entered: (() => void) | undefined; + let started = new Promise(resolve => entered = resolve); + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + let original = context.storage.collaboration.commit; + let first = true; + context.storage.collaboration.commit = async input => { + if (first) { + first = false; + entered!(); + await blocked; + throw new Error("disk"); + } + return original(input); + }; + let failed = Chat.notice(context.announcer, "failed notice"); + await started; + let socket = { + data: { handle: "octocat", principalId: "U_octocat" }, + send() {}, + } as unknown as Socket; + let later = Chat.send( + { + ...context.announcer, + persist: () => Service.persist(context.plan), + } as Chat.Room, + socket, + { + kind: "chat:send", + ts: 0, + rid: "member-send", + requestId: crypto.randomUUID(), + text: "Keep this message", + to: "room", + }, + ); + await Bun.sleep(0); + expect(context.plan.chat.entries.map(entry => entry.text)).toEqual([ + "failed notice", + "Keep this message", + ]); + release!(); + await expect(failed).rejects.toThrow("disk"); + await later; + expect(context.plan.chat.entries.map(entry => entry.text)).toEqual(["Keep this message"]); + expect(context.order).toEqual(["broadcast:Keep this message"]); + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + let sidecar = loaded?.sidecar ?? loaded?.snapshot?.sidecar; + expect(JSON.stringify(sidecar)).not.toContain("failed notice"); + expect(JSON.stringify(sidecar)).toContain("Keep this message"); + await Service.close(context.plan); +}); diff --git a/apps/server/src/chat/notice-publication.test.ts b/apps/server/src/chat/notice-publication.test.ts new file mode 100644 index 00000000..868574f2 --- /dev/null +++ b/apps/server/src/chat/notice-publication.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { hosted } from "./notice.test-fixtures"; + +test("an activity can refer to the document heading", async () => { + let context = await hosted(); + let entry = await Chat.notice(context.announcer, { + text: "Chopin drafted the title and goal", + decision: { questionnaireId: "document", kind: "activity" }, + }); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, { + storage: context.storage, + lease: () => context.lease, + fatal: () => {}, + }, context.server); + expect(restored.chat.entries.at(-1)?.decision).toEqual(entry.decision); + await Service.close(restored); +}); + +test("a notice broadcasts the decision metadata that was committed", async () => { + let context = await hosted(); + let entered: (() => void) | undefined; + let started = new Promise(resolve => entered = resolve); + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async input => { + entered!(); + await blocked; + return original(input); + }; + let decision = { questionnaireId: ulid(), kind: "activity" as const, label: "Original" }; + let pending = Chat.notice(context.announcer, { text: "Refined Original", decision }); + await started; + decision.label = "Changed before broadcast"; + release!(); + let entry = await pending; + expect(entry.decision?.label).toBe("Original"); + expect(context.frames.findLast(frame => frame.kind === "chat:message")?.entry?.decision) + .toEqual(entry.decision); + let loaded = await context.storage.collaboration.load(context.channel.id, context.now); + let sidecar = loaded?.sidecar ?? loaded?.snapshot?.sidecar; + expect(JSON.stringify(sidecar)).toContain('"label":"Original"'); + expect(JSON.stringify(sidecar)).not.toContain("Changed before broadcast"); + await Service.close(context.plan); +}); + +test("concurrent notices broadcast in commit order", async () => { + let context = await hosted(); + let first = Chat.notice(context.announcer, "first"); + let second = Chat.notice(context.announcer, "second"); + await Promise.all([first, second]); + expect(context.order).toEqual(["broadcast:first", "broadcast:second"]); + expect(context.plan.chat.entries.map(entry => entry.text)).toEqual(["first", "second"]); + await Service.close(context.plan); +}); diff --git a/apps/server/src/chat/notice.test-fixtures.ts b/apps/server/src/chat/notice.test-fixtures.ts new file mode 100644 index 00000000..af68dcdf --- /dev/null +++ b/apps/server/src/chat/notice.test-fixtures.ts @@ -0,0 +1,39 @@ +import * as Chat from "./service"; +import * as Service from "../plan/service"; +import { MemoryStorage } from "../storage/memory/adapter"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; + +export async function hosted() { + let now = new Date("2026-08-13T12:00:00.000Z"); + let storage = new MemoryStorage(); + await storage.users.put({ id: "U_octocat", login: "octocat", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Release", + createdBy: "U_octocat", + now, + }); + let lease = await storage.leases.acquire("writer", "test", 60_000); + if (!lease) throw new Error("could not acquire test lease"); + let order: string[] = []; + let frames: Array<{ kind: string; entry?: { text: string; decision?: unknown } }> = []; + let server = { + publish(_topic: string, data: string) { + let frame = JSON.parse(data) as { kind: string; entry?: { text: string } }; + frames.push(frame); + if (frame.kind === "chat:message") order.push(`broadcast:${frame.entry?.text}`); + }, + } as unknown as Server; + let backend: Service.Backend = { + storage, + lease: () => lease, + fatal: () => {}, + }; + let plan = await Service.open(channel.id, backend, server); + let announcer: Chat.Announcer = { chat: plan.chat, plan, server, room: channel.id }; + return { storage, channel, lease, server, plan, announcer, order, frames, now }; +} diff --git a/apps/server/src/chat/notices.ts b/apps/server/src/chat/notices.ts new file mode 100644 index 00000000..a84eb8f7 --- /dev/null +++ b/apps/server/src/chat/notices.ts @@ -0,0 +1,125 @@ +import { isDeepStrictEqual } from "node:util"; +import { ulid } from "@chopin/dialect"; +import * as Service from "../plan/service"; +import type { Chat as Wire } from "@chopin/protocol"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import type { Room } from "./service"; + +export type Announcer = Pick; +export type NoticeInput = string | { text: string; decision?: NonNullable }; + +export function createNotices({ now, announce }: { + now: () => number; + announce: (server: Server, room: string, entry: Wire.Entry) => void; +}) { + function notice(context: Announcer, input: NoticeInput): Promise { + return Service.exclusive(context.plan, () => noticeExclusive(context, input)); + } + + function noticeOnce( + context: Announcer, + id: string, + input: NoticeInput, + ): Promise { + if (!id) return Promise.reject(new Error("chat notice ID is required")); + return Service.exclusive(context.plan, async () => { + let existing = context.chat.entries.filter(entry => entry.id === id); + if (existing.length > 0) { + let entry = existing[0]; + let { text, decision } = typeof input === "string" + ? { text: input, decision: undefined } + : input; + if ( + existing.length !== 1 + || entry.author.kind !== "system" + || entry.text !== text + || Object.hasOwn(entry, "streaming") + || Object.hasOwn(entry, "tools") + || Object.hasOwn(entry, "references") + || entry.decision?.questionnaireId !== decision?.questionnaireId + || entry.decision?.kind !== decision?.kind + || entry.decision?.generation !== decision?.generation + || entry.decision?.label !== decision?.label + || !isDeepStrictEqual(entry.decision, decision) + ) throw new Error(`chat notice ${id} conflicts with an existing entry`); + return entry; + } + return appendNoticeExclusive(context, input, id); + }); + } + + function noticeExclusive(context: Announcer, input: NoticeInput): Promise { + return appendNoticeExclusive(context, input, ulid()); + } + + async function refreshNoticeExclusive( + context: Announcer, + id: string, + input: { text: string; decision: NonNullable }, + ): Promise { + let matches = context.chat.entries.filter(entry => entry.id === id); + let previous = matches[0]; + if ( + matches.length !== 1 || !previous || previous.author.kind !== "system" + || (previous.decision?.kind !== input.decision.kind + && !(previous.decision?.kind === "scoped-choice" && input.decision.kind === "activity") + && !(previous.decision?.kind === "prompt" && input.decision.kind === "activity")) + || previous.decision.questionnaireId !== input.decision.questionnaireId + || Object.hasOwn(previous, "streaming") || Object.hasOwn(previous, "tools") + || Object.hasOwn(previous, "references") + ) throw new Error("chat notice cannot be refreshed"); + let index = context.chat.entries.indexOf(previous); + let entry: Wire.Entry = { + ...previous, + text: input.text, + decision: structuredClone(input.decision), + }; + context.chat.entries[index] = entry; + try { + await Service.persistExclusive(context.plan); + } catch (error) { + context.chat.entries[index] = previous; + throw error; + } + try { + announce(context.server, context.room, entry); + } catch (error) { + console.error("[chat] could not broadcast a persisted notice refresh:", error); + } + return entry; + } + + async function appendNoticeExclusive( + context: Announcer, + input: NoticeInput, + id: string, + ): Promise { + let { chat, room, server } = context; + let { text, decision } = typeof input === "string" + ? { text: input, decision: undefined } + : input; + let entry: Wire.Entry = { + id, + author: { kind: "system" }, + text, + ts: now(), + ...(decision ? { decision: { ...decision } } : {}), + }; + chat.entries.push(entry); + try { + await Service.persistExclusive(context.plan); + } catch (error) { + chat.entries = chat.entries.filter(value => value !== entry); + throw error; + } + try { + announce(server, room, entry); + } catch (error) { + console.error("[chat] could not broadcast a persisted notice:", error); + } + return entry; + } + + return { notice, noticeOnce, noticeExclusive, refreshNoticeExclusive }; +} diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 8a9cc96b..e2019298 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -29,11 +29,18 @@ import * as Service from "../plan/service"; import { instruction } from "@chopin/protocol/address"; import { annotatedText, compose, referenceCatalog, remember } from "./address"; +import { createNotices } from "./notices"; +import { createJobQueue, finishJob, jobReason, type JobTurn, safeProjection } from "./job-queue"; +import { translateJob } from "./job-translate"; +import { watchJobAbort } from "./job-fence"; +import { JOB_TOOLS } from "../agent/job-scope"; +import type { JobOutcome } from "../conversation-plan/jobs"; +export type { Announcer, NoticeInput } from "./notices"; import { broadcast, fail, reply, tell } from "../wire"; import type { Server } from "bun"; import type { TextStreamPart, ToolSet } from "ai"; -import type { Chat as Wire, Request } from "@chopin/protocol"; +import type { Chat as Wire, ConversationPlan, Request } from "@chopin/protocol"; import type { Config } from "../config"; import type { HostedAuth } from "../auth/routes"; import type { HostedRepository } from "../agent/repository"; @@ -71,7 +78,7 @@ const ACTIVE_TOOLS = new Set(PLANNER_TOOL_NAMES); * is only knowable at that moment — and `JSON.stringify` drops it, so the wire * shape stays exactly `Wire.Waiting`. */ -type Waiting = Wire.Waiting & { +export type Waiting = Wire.Waiting & { delivery?: Delivery; spent?: () => boolean; /** True when this came from the composer rather than another instruction. */ @@ -82,6 +89,8 @@ type Waiting = Wire.Waiting & { sessionId?: string; /** Verified member identity, retained only for a queued composer message. */ userId?: string; + /** A queued background turn, resolved only after its scope is cleaned up. */ + job?: JobTurn; }; export type ActiveMemberRequest = { @@ -103,6 +112,14 @@ export type Instruction = { }; export type Chat = { + job?: ConversationPlan.Job; + jobOutput?: string; + jobFailures?: number; + jobFailureCode?: "source-shape"; + jobCalls?: Map; + jobToolNames?: ReadonlySet; + /** The job shifted from the queue while its handoff save is pending. */ + handoffJob?: JobTurn; entries: Wire.Entry[]; waiting: Waiting[]; /** Serializes complete member send acceptance, including asynchronous resolution and persistence. */ @@ -405,6 +422,8 @@ export type Room = { repository: HostedRepository; activeOwner?: () => Promise; persist: () => Promise; + commitRoomMessage?: (entry: Wire.Entry) => Promise; + roomMessagePublished?: () => void; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; ownerAvailable?: () => Promise; jobs?: JobService; @@ -500,15 +519,19 @@ async function processSend(context: Room, ws: Socket, msg: Request): ...(references?.length ? { references } : {}), delivery: savedDelivery, }; - chat.entries.push(entry); + if (!context.commitRoomMessage) chat.entries.push(entry); try { - await context.persist(); + if (context.commitRoomMessage) await context.commitRoomMessage(entry); + else await context.persist(); } catch { - chat.entries = chat.entries.filter(value => value.id !== entry.id); + if (!context.commitRoomMessage) { + chat.entries = chat.entries.filter(value => value.id !== entry.id); + } return fail(ws, msg.rid, "could not save message"); } reply(ws, msg.rid, { kind: "chat:send", ts: 0, id: entry.id, queued: false }); announce(server, room, entry); + context.roomMessagePublished?.(); chat.backscroll = remember(chat.backscroll, { entryId: entry.id, handle, @@ -606,7 +629,12 @@ async function processSend(context: Room, ws: Socket, msg: Request): } /** Say something in the transcript without asking the agent for anything. */ -export function notice(context: Room, text: string): void | Promise { +export let { notice, noticeOnce, noticeExclusive, refreshNoticeExclusive } = createNotices({ + now, + announce, +}); + +function instructionNotice(context: Room, text: string): void | Promise { let { chat, room, server } = context; let entry: Wire.Entry = { id: ulid(), author: { kind: "system" }, text, ts: now() }; chat.entries.push(entry); @@ -667,15 +695,25 @@ export function instruct( startRun(context, handle, text, about.thread, context.claimantSessionId); }; - let announced = notice(context, said); + let announced = instructionNotice(context, said); return announced instanceof Promise ? announced.then(proceed) : proceed(); } +let jobState = safeProjection(state); +let jobQueued = safeProjection(queued); +let { job, cancelQueuedJobs, stopAfterPersistenceFailure } = createJobQueue({ + queued: jobQueued, + state: jobState, + startRun, + MAX_QUEUE, +}); +export { cancelQueuedJobs, job }; + /** Withdraw a queued message. Only whoever wrote it may. */ export function unqueue(context: Room, ws: Socket, msg: Request): void { let { chat, room, server } = context; let found = chat.waiting.find(item => item.id === msg.id); - if (!found || found.handle !== ws.data.handle) return; + if (!found || found.job || found.handle !== ws.data.handle) return; chat.waiting = chat.waiting.filter(item => item.id !== msg.id); queued(chat, server, room); } @@ -719,6 +757,7 @@ export function documentRoom(context: Room): DocumentRoom { anchors: () => Service.anchors(plan, server, room), changes: found => Service.changes(plan, server, room, found), jobs: context.jobs, + currentMemberRequest: () => currentMemberRequest(chat), readReference: async (id, repositoryId) => { let reference = chat.referenceCache.get(id); if (!reference) throw new Error("reference is not available in this Planner session"); @@ -907,7 +946,8 @@ async function repositorySession( ) throw new Error("The Planner session changed while opening. Try again."); chat.agent = opened; chat.owner = openingOwner; - consumeBootstrapBackscroll(chat); + if (!chat.job) consumeBootstrapBackscroll(chat); + else chat.bootstrapEntries = undefined; return { session: opened, binding }; } catch (err) { await opened?.destroy(); @@ -985,15 +1025,19 @@ async function run( reserved = false, member?: MemberRequest, references: Wire.Reference[] = [], + jobTurn?: JobTurn, ): Promise { let { chat, plan, room, server } = context; - if (chat.closed) return; + if (chat.closed) { + if (jobTurn) finishJob(jobTurn, { status: "failed", reason: "The document closed." }); + return; + } if (!reserved) { chat.busy = true; chat.turn = { id: ulid(), handle, started: now(), responded: false }; chat.acting = thread; - state(chat, server, room); + (jobTurn ? jobState : state)(chat, server, room); } else chat.acting = thread; chat.activeRequest = member && chat.turn @@ -1008,6 +1052,17 @@ async function run( } : undefined; chat.messageIds = new Map(); + if (jobTurn) { + chat.job = jobTurn.job; + chat.jobOutput = undefined; + chat.jobFailures = 0; + chat.jobFailureCode = undefined; + chat.jobCalls = new Map(); + } + let sendStarted = false; + let outcome: JobOutcome | undefined; + let persistenceError: unknown; + let releaseJobAbort: (() => void) | undefined; let opened: { session: PlannerSession; binding: ActiveOwnerBinding } | undefined; let turnController = new AbortController(); @@ -1017,30 +1072,55 @@ async function run( if (chat.agent !== opened.session || turnController.signal.aborted) { throw new Error("The Planner session changed before the turn started. Try again."); } - // Drained rather than copied: what the agent has been told once should - // not arrive again on the next turn. - let backscroll = chat.backscroll; - chat.backscroll = []; - let promptReferences = [ - ...backscroll.flatMap(said => said.references ?? []), - ...references, - ]; - retainReferences(chat, promptReferences); - let available = promptReferences.filter(reference => chat.referenceCache.has(reference.id)); - let prompt = compose(backscroll, handle, text, references, available); - - let result = await opened.session.stream( - prompt, - AbortSignal.any([opened.binding.signal, turnController.signal]), - ); + chat.jobToolNames = jobTurn + ? new Set(opened.session.activeTools ?? PLANNER_TOOL_NAMES) + : undefined; + let prompt: string; + if (jobTurn) { + prompt = jobTurn.prompt; + } else { + // Drained rather than copied: what the agent has been told once should + // not arrive again on the next turn. + let backscroll = chat.backscroll; + chat.backscroll = []; + let promptReferences = [ + ...backscroll.flatMap(said => said.references ?? []), + ...references, + ]; + retainReferences(chat, promptReferences); + let available = promptReferences.filter(reference => chat.referenceCache.has(reference.id)); + prompt = compose(backscroll, handle, text, references, available); + } + sendStarted = true; + let signal = AbortSignal.any([opened.binding.signal, turnController.signal]); + if (jobTurn) releaseJobAbort = watchJobAbort(chat, jobTurn, signal); + let result = await opened.session.stream(prompt, signal); for await (let part of result.fullStream) { translate(context, part); if (turnController.signal.aborted) break; } + if (jobTurn && chat.closed) throw new Error("The document closed."); if (chat.interruption) throw new Error(chat.interruption); + if (jobTurn && turnController.signal.aborted) throw new Error("The Planner turn was stopped."); + if (jobTurn) { + outcome = chat.jobOutput === undefined + ? { + status: "failed", + reason: chat.jobFailureCode + ?? `The Planner ended without calling ${JOB_TOOLS[jobTurn.job.kind]}.`, + } + : { status: "done", output: chat.jobOutput }; + } } catch (err) { console.error("[chat] turn failed:", err); - if (!chat.closed) { + if (jobTurn) { + outcome = chat.closed + ? { status: "failed", reason: "The document closed." } + : { + status: sendStarted || chat.interruption ? "failed" : "skipped", + reason: jobReason(err), + }; + } else if (!chat.closed) { say(chat, server, room, { id: ulid(), author: { kind: "system" }, @@ -1050,10 +1130,18 @@ async function run( } chat.interruption = undefined; } finally { + releaseJobAbort?.(); chat.activeRequest = undefined; chat.turnController = undefined; try { - await opened?.session.destroy(); + if (jobTurn) { + try { + await opened?.session.destroy(); + } catch (err) { + persistenceError = err; + console.error("[chat] could not close a finished turn:", err); + } + } else await opened?.session.destroy(); } finally { opened?.binding.release(); if (chat.agent === opened?.session) chat.agent = undefined; @@ -1063,7 +1151,28 @@ async function run( chat.writing = undefined; chat.tooling = undefined; settle(chat, server, room); - if (!chat.closed) await context.persist(); + if (jobTurn) { + chat.job = undefined; + chat.jobOutput = undefined; + chat.jobFailures = undefined; + chat.jobFailureCode = undefined; + chat.jobCalls = undefined; + } + chat.jobToolNames = undefined; + try { + if (!chat.closed) await context.persist(); + } catch (err) { + persistenceError = err; + console.error("[chat] could not save a finished turn:", err); + } + if (jobTurn) { + if (chat.closed) outcome = { status: "failed", reason: "The document closed." }; + else if (persistenceError) { + outcome = { status: "failed", reason: jobReason(persistenceError) }; + } else if (chat.interruption) { + outcome = { status: "failed", reason: jobReason(chat.interruption) }; + } + } /* * The agent's cursor outlives the turn by a moment. @@ -1083,13 +1192,38 @@ async function run( } } + if (persistenceError) { + stopAfterPersistenceFailure(chat, server, room); + } + if (jobTurn) { + finishJob(jobTurn, outcome ?? { status: "failed", reason: "The Planner turn ended." }); + } + if (chat.closed) return; let next = pending(chat); - if (next) { - await context.persist(); - if (chat.closed) return; - queued(chat, server, room); - let entry = next.message ? chat.entries.find(item => item.id === next.id) : undefined; + while (next) { + if (next.job) chat.handoffJob = next.job; + try { + await context.persist(); + } catch (err) { + if (chat.handoffJob === next.job) chat.handoffJob = undefined; + console.error("[chat] could not save a queued turn:", err); + if (next.job) finishJob(next.job, { status: "failed", reason: jobReason(err) }); + stopAfterPersistenceFailure(chat, server, room); + return; + } + if (chat.handoffJob === next.job) chat.handoffJob = undefined; + if (chat.closed) { + if (next.job) finishJob(next.job, { status: "failed", reason: "The document closed." }); + return; + } + if (next.job?.settled) { + next = pending(chat); + continue; + } + (next.job ? jobQueued : queued)(chat, server, room); + let nextId = next.id; + let entry = next.message ? chat.entries.find(item => item.id === nextId) : undefined; if (entry) announce(server, room, entry); await run( context, @@ -1100,13 +1234,14 @@ async function run( false, next.message && next.userId ? { entryId: next.id, userId: next.userId } : undefined, next.references, + next.job, ); - } else { - chat.busy = false; - chat.turn = undefined; - chat.acting = undefined; - state(chat, server, room); + return; } + chat.busy = false; + chat.turn = undefined; + chat.acting = undefined; + (jobTurn ? jobState : state)(chat, server, room); } function startRun( @@ -1118,9 +1253,23 @@ function startRun( reserved = false, member?: MemberRequest, references?: Wire.Reference[], + jobTurn?: JobTurn, ): void { - if (context.chat.closed) return; - let running = run(context, handle, text, thread, claimantSessionId, reserved, member, references); + if (context.chat.closed) { + if (jobTurn) finishJob(jobTurn, { status: "failed", reason: "The document closed." }); + return; + } + let running = run( + context, + handle, + text, + thread, + claimantSessionId, + reserved, + member, + references ?? [], + jobTurn, + ); context.chat.running = running; void running.finally(() => { if (context.chat.running === running) context.chat.running = undefined; @@ -1155,6 +1304,7 @@ export function pending(chat: Chat): Waiting | undefined { /** Project one AI SDK stream part into the shared Conversation. */ export function translate(context: Room, part: TextStreamPart): void { let { chat, room, server } = context; + if (chat.job || chat.jobToolNames) return translateJob(context, part); let ids = chat.messageIds ??= new Map(); switch (part.type) { case "finish": @@ -1324,6 +1474,10 @@ export async function resetAgent( chat.lifecycle++; chat.activeRequest = undefined; if (reason && chat.turnController) chat.interruption = reason; + if (chat.job) { + chat.job = undefined; + chat.jobOutput = undefined; + } chat.turnController?.abort(); chat.referenceCache.clear(); chat.bootstrapEntries = undefined; @@ -1333,8 +1487,17 @@ export async function resetAgent( /** Let go of the session. The conversation is resumable by id. */ export async function close(chat: Chat): Promise { chat.closed = true; + chat.job = undefined; + chat.jobOutput = undefined; chat.lifecycle++; chat.activeRequest = undefined; + for (let waiting of chat.waiting) { + if (waiting.job) finishJob(waiting.job, { status: "failed", reason: "The document closed." }); + } + if (chat.handoffJob) { + finishJob(chat.handoffJob, { status: "failed", reason: "The document closed." }); + chat.handoffJob = undefined; + } chat.waiting = []; chat.turnController?.abort(); chat.referenceCache.clear(); diff --git a/apps/server/src/conversation-plan/option-capacity.ts b/apps/server/src/conversation-plan/option-capacity.ts new file mode 100644 index 00000000..797fc37a --- /dev/null +++ b/apps/server/src/conversation-plan/option-capacity.ts @@ -0,0 +1,106 @@ +import { limits } from "@chopin/question"; +import type { Plan } from "../plan/service"; +import type { Record } from "../questions/records"; +import { MAX_CONTRIBUTIONS } from "./validation-fields"; + +type Capacity = Pick & { + conversationPlan: Pick; +}; +type Budget = { key: string; used: number; limit: number; message: string; reason?: "duplicate" }; + +export class OptionCapacityError extends Error { + constructor(message: string, readonly reason: "full" | "duplicate" = "full") { + super(message); + } +} + +/** Accepted option work owns its slot until the record or thread contains its ID. */ +export function assertOptionCapacity(plan: Capacity & Partial>): void { + let previous: Budget[] | undefined; + for (let budget of budgets(plan)) { + if (budget.used <= budget.limit) continue; + if (!previous) { + // Existing durable excess must not make unrelated document writes unavailable. + let saved = plan.persistence?.committedSidecar as { + conversationPlan?: Capacity["conversationPlan"]; + conversationPlanPendingEffects?: Capacity["conversationPlanPendingEffects"]; + pendingCardActions?: Capacity["pendingCardActions"]; + questions: Record[]; + } | undefined; + previous = saved + ? budgets({ + conversationPlan: saved.conversationPlan ?? { threads: [], events: [] }, + conversationPlanPendingEffects: saved.conversationPlanPendingEffects ?? [], + pendingCardActions: saved.pendingCardActions ?? [], + records: new Map(saved.questions.map(record => [record.id, record])), + }) + : []; + } + if (budget.used <= (previous.find(item => item.key === budget.key)?.used ?? 0)) continue; + throw new OptionCapacityError(budget.message, budget.reason); + } +} + +function budgets(plan: Capacity): Budget[] { + let additions = plan.conversationPlanPendingEffects.filter(effect => + effect.kind === "add-option" + ); + if ( + !additions.length && !plan.pendingCardActions.some(action => action.kind === "option-added") + ) return []; + let triggers = new Set(additions.map(effect => effect.trigger)); + let humanOptions = new Map(); + for (let event of additions.length ? plan.conversationPlan.events : []) { + if (triggers.has(event.id) && event.origin === "human" && event.type === "option.added") { + humanOptions.set(event.id, event.contribution.id); + } + } + let result: Budget[] = []; + for (let thread of plan.conversationPlan.threads) { + let reserved = new Set(); + for (let action of plan.pendingCardActions) { + if ( + action.kind === "option-added" && action.threadId === thread.id + && !thread.contributions.some(item => item.id === action.optionId) + ) reserved.add(action.optionId); + } + result.push({ + key: JSON.stringify([thread.id, "contributions"]), + used: thread.contributions.length + reserved.size, + limit: MAX_CONTRIBUTIONS, + message: "This discussion has reached its contribution limit", + }); + let pending = additions.filter(effect => + effect.threadId === thread.id && humanOptions.get(effect.trigger) === effect.optionId + ); + if (!pending.length) continue; + let record = thread.questionnaireId && plan.records.get(thread.questionnaireId); + if (!record || !["open", "reopened"].includes(record.status)) continue; + let options = record.definition.questions[0]?.options ?? []; + let byId = new Map(options.map(option => [option.id, option.label])); + for (let effect of pending) { + if (!byId.has(effect.optionId)) byId.set(effect.optionId, effect.label); + } + let labels = new Map(); + for (let text of byId.values()) { + let label = text.trim().toLowerCase(); + labels.set(label, (labels.get(label) ?? 0) + 1); + } + result.push({ + key: JSON.stringify([thread.id, "options"]), + used: byId.size, + limit: limits.MAX_DECISION_OPTIONS, + message: "This card is full, including options waiting to be added", + }); + for (let [label, used] of labels) { + result.push({ + key: JSON.stringify([thread.id, "label", label]), + used, + limit: 1, + message: "This option is already on the card", + reason: "duplicate", + }); + } + } + return result; +} diff --git a/apps/server/src/conversation-plan/prose-job-anchor.test.ts b/apps/server/src/conversation-plan/prose-job-anchor.test.ts new file mode 100644 index 00000000..3761a0b5 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job-anchor.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "../questions/service"; +import { openPlan } from "../testing/plan"; +import { proseInput, prosePrompt } from "./prose-job"; +import { CARD, OPTION, QUESTION } from "./prose-job.test-fixtures"; + +test("repeated saved choice after a failed prose job never reuses the prior rationale", async () => { + let prior = "Sentry grouped exceptions and preserved stack traces."; + let context = await openPlan(`${prior}\n\nFollow-up.\n`); + try { + let { plan } = context; + let newOption = "01K0N4W3B7P27CBAEC7A8C8WEB"; + plan.records.set( + CARD, + Questions.normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Monitoring", + question: "Which monitoring approach?", + multiple: false, + options: [ + { id: OPTION, label: "Sentry", description: "" }, + { id: newOption, label: "OpenTelemetry with Grafana", description: "" }, + ], + }], + }, + status: "answered", + origin: "conversation", + threadId: "thread-a", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [newOption], + history: [{ choices: [OPTION], owner: "mina", at: 1_758_644_000 }], + prose: [room.anchorAt(plan.document, 0, room.digests(plan.document)[0]!)], + }), + ); + plan.conversationPlan.threads = [{ + id: "thread-a", + questionnaireId: CARD, + question: "Which monitoring approach?", + questionAuthoring: "quoted", + status: "decided", + questionSources: [], + contributions: [ + { + kind: "reason", + text: "Exception grouping and stack traces", + targetId: OPTION, + sources: [], + }, + { kind: "reason", text: "Grafana is already available", targetId: newOption, sources: [] }, + { + kind: "reason", + text: "Keep operational ownership clear", + targetId: "thread-a", + sources: [], + }, + ], + stances: [], + stanceHistory: [], + decisionHistory: [], + candidates: [], + version: 1, + }] as never; + let input = proseInput(plan, CARD); + expect(input.chosen).toEqual(["OpenTelemetry with Grafana"]); + expect(input.reasons).toEqual([ + "Grafana is already available", + "Keep operational ownership clear", + ]); + let prompt = prosePrompt(input); + expect(prompt).toContain("Grafana is already available"); + expect(prompt).toContain("Keep operational ownership clear"); + expect(prompt).not.toContain("Exception grouping"); + expect(prompt).not.toContain("stack traces"); + // B's prose job can fail, leaving A's anchored paragraph through a second B save. + let saved = plan.records.get(CARD)!; + plan.records.set(CARD, { + ...saved, + history: [...saved.history, { choices: [newOption], owner: "mina", at: 1_758_645_000 }], + }); + let repeated = prosePrompt(proseInput(plan, CARD)); + expect(repeated).not.toContain(prior); + } finally { + await Service.close(context.plan); + } +}); + +test("same choice IDs and changed legacy text answers never use old prose as evidence", async () => { + let context = await openPlan("Existing rationale.\n"); + try { + let { plan } = context; + let second = "01K0N4W3B7P27CBAEC7A8C8WEB"; + let base = { + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Monitoring", + question: "Which monitoring approach?", + multiple: true, + options: [ + { id: OPTION, label: "Sentry", description: "" }, + { id: second, label: "Grafana", description: "" }, + ], + }], + }, + status: "answered", + origin: "conversation", + threadId: "thread-a", + owner: "mina", + decidedAt: 1_758_645_000, + prose: [room.anchorAt(plan.document, 0, room.digests(plan.document)[0]!)], + }; + plan.records.set( + CARD, + Questions.normalizeRecord({ + ...base, + choices: [OPTION, second], + history: [{ choices: [second, OPTION], owner: "mina", at: 1_758_644_000 }], + }), + ); + let sameChoice = prosePrompt(proseInput(plan, CARD)); + expect(sameChoice).toContain("Sentry; Grafana"); + expect(sameChoice).not.toContain("Existing rationale."); + plan.records.set( + CARD, + Questions.normalizeRecord({ + ...base, + choices: [], + answers: { [QUESTION]: "OpenTelemetry" }, + history: [{ + choices: [], + answers: { [QUESTION]: "Sentry" }, + owner: "mina", + at: 1_758_644_000, + }], + }), + ); + let changedText = prosePrompt(proseInput(plan, CARD)); + expect(changedText).toContain("Chosen: OpenTelemetry"); + expect(changedText).not.toContain("Existing rationale."); + } finally { + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/conversation-plan/prose-job.test-fixtures.ts b/apps/server/src/conversation-plan/prose-job.test-fixtures.ts new file mode 100644 index 00000000..9b974ef1 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.test-fixtures.ts @@ -0,0 +1,19 @@ +export const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; +export const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; +export const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + +export function record(overrides: Record = {}) { + return { + id: CARD, + origin: "conversation", + threadId: "thread-a", + status: "answered", + owner: "mina", + decidedAt: 1_758_645_000, + definition: { questions: [] }, + history: [], + optionOrigins: {}, + editors: [], + ...overrides, + } as never; +} diff --git a/apps/server/src/conversation-plan/prose-job.test.ts b/apps/server/src/conversation-plan/prose-job.test.ts new file mode 100644 index 00000000..d4b566b6 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test"; +import { appendProseEffect, proseIntent, prosePrompt } from "./prose-job"; +import { CARD, record } from "./prose-job.test-fixtures"; + +test("proseIntent uses the immutable Save generation, even for same-second re-decisions", () => { + expect(proseIntent(record())).toEqual({ + kind: "prose", + target: CARD, + trigger: `decided:${CARD}:1`, + }); + expect(proseIntent(record({ history: [{ choices: [], owner: "mina", at: 1_758_645_000 }] }))) + .toMatchObject({ trigger: `decided:${CARD}:2` }); + expect(proseIntent(record({ status: "reopened" }))).toBeUndefined(); + expect(proseIntent(record({ origin: "planner" }))).toBeUndefined(); + expect(proseIntent(record({ owner: undefined }))).toBeUndefined(); +}); + +test("appendProseEffect is bounded and idempotent for one saved generation", () => { + let first = appendProseEffect([], [], record()); + expect(first).toMatchObject([{ + key: `job:prose:${CARD}:1`, + kind: "job", + threadId: "thread-a", + intent: { kind: "prose", target: CARD, trigger: `decided:${CARD}:1` }, + }]); + expect(appendProseEffect(first, [], record())).toEqual(first); + expect(appendProseEffect([], [first[0]!.key], record())).toEqual([]); + expect(() => + appendProseEffect( + Array.from({ length: 1024 }, (_, index) => ({ + key: `dummy:${index}`, + kind: "job", + intent: { kind: "heading", target: "document", trigger: `m${index}` }, + })), + [], + record(), + ) + ).toThrow(/outbox is full/); +}); + +test("prosePrompt stays bounded, grounded, and names only its own writing tool", () => { + let prompt = prosePrompt({ + id: CARD, + question: "Which authentication approach?", + chosen: ["GitHub Apps"], + owner: "mina", + involved: ["mina", "jules"], + reasons: ["We need an organisation-level sign-in flow."], + }); + expect(prompt).toContain("[Background job: prose]"); + expect(prompt).toContain(`Decision card id: ${CARD}`); + expect(prompt).toContain("GitHub Apps"); + expect(prompt).toContain("replacement"); + expect(prompt).toContain("not evidence for this choice"); + expect(prompt).toContain("organisation-level sign-in flow"); + expect(prompt).toMatch(/do not add commitments/i); + expect(prompt).toContain("write_decision_prose"); + expect(prompt).toContain("Use no other writing tool."); + expect(prompt).not.toContain("making it a GitHub app"); + expect( + prosePrompt({ + id: CARD, + question: "Q".repeat(1000), + chosen: Array.from({ length: 30 }, () => "L".repeat(300)), + owner: "O".repeat(1000), + involved: Array.from({ length: 30 }, () => "I".repeat(300)), + reasons: Array.from({ length: 30 }, () => "R".repeat(1000)), + }).length, + ).toBeLessThan(9000); +}); diff --git a/apps/server/src/conversation-plan/prose-job.ts b/apps/server/src/conversation-plan/prose-job.ts new file mode 100644 index 00000000..da3ccce6 --- /dev/null +++ b/apps/server/src/conversation-plan/prose-job.ts @@ -0,0 +1,122 @@ +/** A saved conversation decision's durable job identity and bounded writing context. */ + +import { decisionGeneration, proseJobTrigger } from "../questions/card-actions"; +import * as Questions from "../questions/card-involved"; +import { type Effect, type JobIntent, MAX_EFFECTS } from "./effects"; + +import type { Plan } from "../plan/service"; +import type { Record as CardRecord } from "../questions/records"; + +export function proseIntent(record: CardRecord): JobIntent | undefined { + if ( + record.origin !== "conversation" || !record.threadId || record.status !== "answered" + || !record.owner || record.decidedAt === undefined + ) return; + return { + kind: "prose", + target: record.id, + trigger: proseJobTrigger(record.id, decisionGeneration(record)), + }; +} + +/** Save adds this to the same candidate as its answered record and mirror action. */ +export function appendProseEffect( + pending: readonly Effect[], + receipts: readonly string[], + record: CardRecord, +): Effect[] { + let intent = proseIntent(record); + if (!intent) return [...pending]; + let effect: Effect = { + key: `job:prose:${record.id}:${decisionGeneration(record)}`, + kind: "job", + threadId: record.threadId, + intent, + }; + if (receipts.includes(effect.key)) return [...pending]; + let existing = pending.find(item => item.key === effect.key); + if (existing) { + if ( + existing.kind !== "job" || existing.threadId !== effect.threadId + || existing.intent.kind !== intent.kind || existing.intent.target !== intent.target + || existing.intent.trigger !== intent.trigger + ) throw new Error("conversation effect key collision"); + return [...pending]; + } + if (pending.length >= MAX_EFFECTS) throw new Error("conversation effect outbox is full"); + return [...pending, effect]; +} + +export type ProsePromptInput = { + id: string; + question: string; + chosen: string[]; + owner: string; + involved: string[]; + reasons: string[]; +}; + +function quote(value: string, max: number): string { + return value.replace(/\s+/g, " ").trim().slice(0, max); +} + +export function prosePrompt(input: ProsePromptInput): string { + let others = input.involved.filter(handle => handle !== input.owner).slice(0, 8); + let lines = [ + "[Background job: prose]", + "A person saved a decision. Write exactly one grounded paragraph in the document's voice.", + "", + `Decision card id: ${quote(input.id, 200)}`, + `Question: ${quote(input.question, 500)}`, + `Chosen: ${input.chosen.slice(0, 10).map(value => quote(value, 200)).join("; ")}`, + `Saved by: ${quote(input.owner, 80)}${ + others.length ? `; discussed with ${others.map(value => quote(value, 80)).join(", ")}` : "" + }`, + ]; + if (input.reasons.length) { + lines.push( + "Reasons given in the discussion:", + ...input.reasons.slice(0, 12).map(reason => `- ${quote(reason, 300)}`), + ); + } + lines.push( + "", + "An earlier decision paragraph may still appear in `read_plan`. It is a replacement", + "target, not evidence for this choice. Do not reuse its claims or reasons unless the", + "current saved choice and applicable discussion reasons above support them.", + "State only the saved choice and supported reasons. Do not add commitments, implementation", + "details, or claims that the conversation did not establish. Do not name people.", + "One ordinary paragraph, at most 600 characters. Call `read_plan` for its revision, then", + "call `write_decision_prose` once with that revision, the card id, and the paragraph.", + "Use no other writing tool. Do not reply in chat.", + ); + return lines.join("\n"); +} + +export function proseInput(plan: Plan, id: string): ProsePromptInput { + let record = plan.records.get(id); + if (!record || !proseIntent(record)) throw new Error("decision is no longer saved"); + let question = record.definition.questions[0]; + let selected = new Set(record.choices ?? []); + let chosen: string[] = []; + for (let item of record.definition.questions) { + let options = item.options.filter(option => selected.has(option.id)); + if (options.length) chosen.push(...options.map(option => option.label)); + else if (record.answers?.[item.id]) chosen.push(record.answers[item.id]!); + } + let thread = plan.conversationPlan.threads.find(item => item.questionnaireId === id); + let reasons = thread?.contributions + .filter(item => + item.kind === "reason" && (!item.targetId || item.targetId === thread.id + || selected.has(item.targetId)) + ) + .map(item => item.text) ?? []; + return { + id, + question: question?.question ?? "", + chosen, + owner: record.owner!, + involved: Questions.involved(plan, record), + reasons, + }; +} diff --git a/apps/server/src/harness/agents.ts b/apps/server/src/harness/agents.ts index a14b42d3..f190b355 100644 --- a/apps/server/src/harness/agents.ts +++ b/apps/server/src/harness/agents.ts @@ -4,9 +4,12 @@ import { z } from "zod"; import { type DocumentRoom, documentTools } from "../agent/tools"; import { type HostedRepository, repositoryTools } from "../agent/repository"; +import { jobTools, scopedJobTools } from "../agent/job-tools"; +import { createScopedTools } from "../agent/scoped-tools"; +import type { ConversationPlan } from "@chopin/protocol"; import { harnessSelection } from "../config"; import { GITHUB_TOOL_SCHEMAS } from "./github-tools"; -import { PLANNER_TOOL_NAMES } from "./tool-names"; +import { BACKGROUND_TOOL_NAMES, PLANNER_TOOL_NAMES } from "./tool-names"; import { harnessFor } from "./harnesses"; import type { HarnessV1 } from "@ai-sdk/harness"; @@ -27,7 +30,13 @@ const githubPlaceholders: ToolSet = Object.fromEntries( ]), ); -const plannerTools: ToolSet = { ...documentTools, ...repositories, ...githubPlaceholders }; +const plannerTools: ToolSet = { + ...documentTools, + ...repositories, + ...githubPlaceholders, + ...jobTools, + ...createScopedTools(), +}; export { PLANNER_TOOL_NAMES } from "./tool-names"; type PlannerCallOptions = { @@ -39,28 +48,34 @@ type PlannerCallOptions = { model?: string; }; -export function createPlannerAgent(harness: HarnessV1) { +export function createPlannerAgent(harness: HarnessV1, profile?: ConversationPlan.JobKind) { + let names = profile ? BACKGROUND_TOOL_NAMES[profile] : PLANNER_TOOL_NAMES; return new HarnessAgent({ harness, tools: plannerTools, - activeTools: PLANNER_TOOL_NAMES, + activeTools: [...names], permissionMode: "allow-reads", callOptionsSchema: z.custom(), - prepareCall: ({ options, ...rest }) => ({ - ...rest, - model: options.model, - instructions: options.instructions, - tools: { ...rest.tools, ...options.githubTools }, - toolsContext: Object.fromEntries(PLANNER_TOOL_NAMES.map(name => [name, { - room: options.room, - repository: options.repository, - owner: options.owner, - }])), - }), + prepareCall: ({ options, ...rest }) => { + return { + ...rest, + model: options.model, + instructions: options.instructions, + tools: scopedJobTools({ ...rest.tools, ...options.githubTools }, options.room), + toolsContext: Object.fromEntries(names.map(name => [name, { + room: options.room, + repository: options.repository, + owner: options.owner, + }])), + }; + }, }); } export let plannerAgent = createPlannerAgent(harnessFor(harnessSelection())); +export let headingPlannerAgent = createPlannerAgent(harnessFor(harnessSelection()), "heading"); +export let refinePlannerAgent = createPlannerAgent(harnessFor(harnessSelection()), "refine"); +export let prosePlannerAgent = createPlannerAgent(harnessFor(harnessSelection()), "prose"); type WorkerCallOptions = { model: string; diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index a22664a3..e5e1346b 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -1,5 +1,6 @@ +import { BACKGROUND_TOOL_NAMES, PLANNER_TOOL_NAMES } from "./tool-names"; import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; -import { plannerAgent } from "./agents"; +import { headingPlannerAgent, plannerAgent, prosePlannerAgent, refinePlannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; @@ -26,12 +27,17 @@ type PlannerChannel = { }; export type PlannerSession = { + /** Snapshot of the selected current static Planner profile. */ + activeTools?: readonly string[]; stream: (prompt: string, abortSignal: AbortSignal) => ReturnType; destroy: () => Promise; }; export type PlannerSessionDependencies = { agent?: PlannerAgent; + headingAgent?: PlannerAgent; + refineAgent?: PlannerAgent; + proseAgent?: PlannerAgent; githubTools?: typeof githubTools; createSandbox?: () => Promise; registerCredential?: typeof registerCredential; @@ -43,6 +49,16 @@ export async function openPlannerSession( channel: PlannerChannel, deps: PlannerSessionDependencies = {}, ): Promise> { + let job = channel.room.plan.chat?.job; + if (job && !Object.hasOwn(BACKGROUND_TOOL_NAMES, job.kind)) { + return { + ok: false, + error: { + kind: "Unavailable", + cause: new Error(`Background ${job.kind} tools are not available`), + }, + }; + } try { if (owner.signal.aborted || !await owner.revalidate()) { return { @@ -73,7 +89,13 @@ export async function openPlannerSession( if (owner.signal.aborted) throw new Error("Planner owner unavailable"); let sessionId = crypto.randomUUID(); unregister = (deps.registerCredential ?? registerCredential)(sessionId, owner.currentToken); - let agent = deps.agent ?? plannerAgent; + let agent = deps.agent ?? (job?.kind === "heading" + ? deps.headingAgent ?? headingPlannerAgent + : job?.kind === "refine" || job?.kind === "suggest" + ? deps.refineAgent ?? refinePlannerAgent + : job?.kind === "prose" + ? deps.proseAgent ?? prosePlannerAgent + : plannerAgent); let opening = agent.createSession({ sessionId, sandboxSession: sandbox }); let deadline = new Promise((_, reject) => { timeout = setTimeout( @@ -94,6 +116,9 @@ export async function openPlannerSession( return { ok: true, value: { + activeTools: Object.freeze([ + ...(job ? BACKGROUND_TOOL_NAMES[job.kind] : PLANNER_TOOL_NAMES), + ]), stream: (prompt, abortSignal) => agent.stream({ session: active, diff --git a/apps/server/src/harness/tool-names.ts b/apps/server/src/harness/tool-names.ts index a099be7f..e87aea3e 100644 --- a/apps/server/src/harness/tool-names.ts +++ b/apps/server/src/harness/tool-names.ts @@ -1,3 +1,6 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import { WRITE_TOOLS } from "../agent/job-scope"; + export const PLANNER_TOOL_NAMES = [ "read_plan", "read_reference", @@ -16,3 +19,17 @@ export const PLANNER_TOOL_NAMES = [ "list_pull_requests", "pull_request_read", ]; + +function jobNames(own: string): readonly string[] { + return Object.freeze([...PLANNER_TOOL_NAMES.filter(name => !WRITE_TOOLS.has(name)), own]); +} + +export const BACKGROUND_TOOL_NAMES: Readonly> = + Object.freeze({ + heading: jobNames("draft_heading"), + refine: jobNames("refine_decision"), + suggest: jobNames("refine_decision"), + prose: jobNames("write_decision_prose"), + }); + +export const HEADING_TOOL_NAMES = BACKGROUND_TOOL_NAMES.heading; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7212ab18..f57b0bc9 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -310,6 +310,14 @@ async function receive(ws: Socket, raw: string): Promise { if (room.plan) await Questions.edit(room.plan, ws, frame); return; + case "question:discard": + if (room.plan) await Questions.discard(room.plan, server, room.id, ws, frame); + return; + + case "question:reopen": + if (room.plan) await Questions.reopen(room.plan, server, room.id, ws, frame); + return; + case "question:presence": if (room.plan) Questions.focus(room.plan, ws, frame); return; diff --git a/apps/server/src/mcp/update-lifecycle.test.ts b/apps/server/src/mcp/update-lifecycle.test.ts index 77f015a4..86c738e6 100644 --- a/apps/server/src/mcp/update-lifecycle.test.ts +++ b/apps/server/src/mcp/update-lifecycle.test.ts @@ -445,7 +445,7 @@ describe("hosted document rewrite lifecycle", () => { header: "Keep", question: "What should stay?", multiple: false, - options: [], + options: [{ id: "option", label: "Keep", description: "" }], }], }, }], diff --git a/apps/server/src/plan/conversation-analysis-persistence.test.ts b/apps/server/src/plan/conversation-analysis-persistence.test.ts new file mode 100644 index 00000000..57c1418b --- /dev/null +++ b/apps/server/src/plan/conversation-analysis-persistence.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; +import { enqueue } from "../conversation-plan/domain"; + +async function damage( + context: Awaited>, + mutate: (sidecar: Record) => void, +) { + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + mutate(sidecar); + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: crypto.randomUUID(), + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); +} + +test("conversation analysis and explicit retry identity survive a real sidecar commit", async () => { + let context = await openPlan(); + let state = enqueue(context.plan.conversationPlan, "message-1"); + context.plan.chat.entries.push({ + id: "message-1", + author: { kind: "member", handle: "ana" }, + text: "An outline?", + ts: 1, + }); + context.plan.conversationPlan = state; + context.plan.conversationPlanRetries = [{ id: "retry-1", messageId: "message-1" }]; + await Service.persist(context.plan); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(restored.conversationPlan).toEqual(state); + expect(restored.conversationPlanRetries).toEqual([{ id: "retry-1", messageId: "message-1" }]); + expect(restored.document.seq).toBe(0); + expect(restored.revision).toBe(0); + } finally { + await Service.close(restored); + } +}); + +test("retry restoration rejects malformed, duplicate and absent source identities", async () => { + let cases = [ + { value: [{ id: "retry", messageId: "missing" }], error: "without a source message" }, + { + value: [{ id: "retry", messageId: "m1", extra: true }], + error: "invalid conversation analysis retry", + }, + { + value: [{ id: "retry", messageId: "m1" }, { id: "retry", messageId: "m1" }], + error: "invalid conversation analysis retry", + }, + { + value: [{ id: "x".repeat(251), messageId: "m1" }], + error: "invalid conversation analysis retry", + }, + ]; + for (let item of cases) { + let context = await openPlan(); + await Service.close(context.plan); + await damage(context, sidecar => { + sidecar.conversationPlanRetries = item.value; + sidecar.transcript = [{ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "Hi", + ts: 1, + }]; + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow(item.error); + } +}); + +test("failed conversation persistence leaves durable state unchanged and can be retried", async () => { + let context = await openPlan(); + let original = context.storage.collaboration.commit; + let fatals: unknown[] = []; + context.backend.fatal = error => fatals.push(error); + context.plan.persistence.fatal = context.backend.fatal; + let fail = true; + context.storage.collaboration.commit = async input => { + if (fail) { + fail = false; + throw new Error("storage unavailable"); + } + return original(input); + }; + context.plan.chat.entries.push({ + id: "m1", + author: { kind: "member", handle: "ana" }, + text: "An outline?", + ts: 1, + }); + context.plan.conversationPlan = enqueue(context.plan.conversationPlan, "m1"); + await expect(Service.persist(context.plan)).rejects.toThrow("storage unavailable"); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = loaded.sidecar ?? loaded.snapshot!.sidecar; + expect((sidecar as { transcript: unknown[] }).transcript).toEqual([]); + expect(fatals).toHaveLength(1); + await Service.persist(context.plan); + await Service.close(context.plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(restored.conversationPlan.queue).toEqual([{ + messageId: "m1", + status: "pending", + attempts: 0, + }]); + } finally { + await Service.close(restored); + } +}); diff --git a/apps/server/src/plan/conversation-option-persistence.test.ts b/apps/server/src/plan/conversation-option-persistence.test.ts new file mode 100644 index 00000000..b0dd1bd2 --- /dev/null +++ b/apps/server/src/plan/conversation-option-persistence.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; +import { draft, legacyRecord } from "./question-record.test-fixtures"; +import { applyInference, initialState } from "../conversation-plan/domain"; +import { applyEvent } from "../conversation-plan/events"; + +async function accepted() { + let context = await openPlan("", { questions: [legacyRecord()], openQuestions: [draft()] }); + let message = { + id: "m1", + author: { kind: "member" as const, handle: "ana" }, + text: "Try 🧪 Cloud next", + ts: 1, + }; + let source = { + messageId: message.id, + author: message.author, + quote: "🧪 Cloud", + start: 4, + end: 12, + role: "option" as const, + }; + let state = applyEvent(initialState(), { + id: "open", + type: "thread.opened", + threadId: "t1", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "Where should we host?", + }); + state = applyInference(state, { + id: "option", + type: "option.added", + threadId: "t1", + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source, + contribution: { id: "saved-a", text: "Cloud", authoring: "scribe" }, + }, message); + let record = context.plan.records.get("saved-card")!; + context.plan.records.set(record.id, { + ...record, + threadId: "t1", + optionOrigins: { "saved-a": { origin: "chat", source } }, + }); + context.plan.chat.entries.push(message); + context.plan.conversationPlan = state; + await Service.persist(context.plan); + await Service.close(context.plan); + return context; +} + +test("saved chat option provenance is tied to the accepted event in its own thread", async () => { + let context = await accepted(); + let restored = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(restored.records.get("saved-card")?.optionOrigins["saved-a"]?.source?.quote) + .toBe("🧪 Cloud"); + expect(restored.conversationPlan.events.at(-1)?.type).toBe("option.added"); + } finally { + await Service.close(restored); + } +}); + +test("saved chat option provenance rejects wrong thread, contribution and exact source", async () => { + for (let kind of ["thread", "contribution", "source"]) { + let context = await accepted(); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as { + questions: Array<{ + threadId: string; + optionOrigins: Record< + string, + { origin: string; source: { start: number; end: number; quote: string } } + >; + }>; + }; + let record = sidecar.questions[0]!; + if (kind === "thread") record.threadId = "other"; + if (kind === "contribution") { + record.optionOrigins["saved-b"] = record.optionOrigins["saved-a"]!; + delete record.optionOrigins["saved-a"]; + } + if (kind === "source") { + record.optionOrigins["saved-a"]!.source = { + ...record.optionOrigins["saved-a"]!.source, + start: 0, + end: 3, + quote: "Try", + }; + } + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: `wrong-${kind}`, + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("option source outside its card thread or evidence"); + } +}); diff --git a/apps/server/src/plan/conversation-outbox-persistence.test.ts b/apps/server/src/plan/conversation-outbox-persistence.test.ts new file mode 100644 index 00000000..a8c46ebf --- /dev/null +++ b/apps/server/src/plan/conversation-outbox-persistence.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +describe("durable conversation outboxes", () => { + it("restores the effect outbox and rejects malformed present entries", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let pending = { + key: "job:heading:document", + kind: "job" as const, + intent: { kind: "heading" as const, target: "document", trigger: "m1" }, + }; + await Service.exclusive(plan, async () => { + plan.conversationPlanEffects = ["insert:old-thread"]; + plan.conversationPlanPendingEffects = [pending]; + await Service.persistExclusive(plan); + }); + await Service.close(plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + expect(restored.conversationPlanEffects).toEqual(["insert:old-thread"]); + expect(restored.conversationPlanPendingEffects).toEqual([pending]); + await Service.close(restored); + + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + sidecar.conversationPlanPendingEffects = [{ ...pending, unexpected: true }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "invalid-effect-outbox", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("conversation effects"); + }); + + it("restores pending card actions and rejects malformed present entries", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let cardId = ulid(); + let action = { + id: `card:${cardId}:reopened:1`, + cardId, + threadId: "thread-a", + actor: "ana", + at: 9, + kind: "reopened" as const, + generation: 1, + }; + await Service.exclusive(plan, async () => { + plan.pendingCardActions = [action]; + await Service.persistExclusive(plan); + }); + await Service.close(plan); + let restored = await Service.open(context.channel.id, context.backend, context.server); + expect(restored.pendingCardActions).toEqual([action]); + await Service.close(restored); + + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + sidecar.pendingCardActions = [{ ...action, generation: 0 }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "invalid-card-action-outbox", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("card actions"); + }); + + it("rejects pending analysis whose source message is absent", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let epoch = plan.document.epoch; + await Service.close(plan); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + let analysis = sidecar.conversationPlan as Record; + analysis.queue = [{ messageId: "missing", status: "pending", attempts: 0 }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "orphan-analysis", + epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("without a source message"); + }); +}); diff --git a/apps/server/src/plan/conversation-persistence.test-fixtures.ts b/apps/server/src/plan/conversation-persistence.test-fixtures.ts new file mode 100644 index 00000000..80589fd8 --- /dev/null +++ b/apps/server/src/plan/conversation-persistence.test-fixtures.ts @@ -0,0 +1,35 @@ +import { MemoryStorage } from "../storage/memory/adapter"; +import type { Server } from "bun"; +import type { SocketData } from "../wire"; +import * as Service from "./service"; + +export async function hosted() { + let now = new Date("2026-08-13T12:00:00.000Z"); + let storage = new MemoryStorage(); + await storage.users.put({ id: "U_octocat", login: "octocat", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Release", + createdBy: "U_octocat", + now, + }); + let lease = await storage.leases.acquire("writer", "test", 60_000); + if (!lease) throw new Error("could not acquire test lease"); + let frames: Array> = []; + let server = { + publish(_topic: string, value: string) { + frames.push(JSON.parse(value) as Record); + }, + } as unknown as Server; + let backend: Service.Backend = { + storage, + lease: () => lease, + fatal: err => { + throw err; + }, + }; + return { storage, channel, lease, frames, server, backend, now }; +} diff --git a/apps/server/src/plan/notice-validation.ts b/apps/server/src/plan/notice-validation.ts new file mode 100644 index 00000000..14ba0dbd --- /dev/null +++ b/apps/server/src/plan/notice-validation.ts @@ -0,0 +1,149 @@ +import { isDeepStrictEqual } from "node:util"; +import { ULID } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import { assertSourceShape, validateSource } from "../conversation-plan/sources"; +import type { ConversationPlan } from "@chopin/protocol"; +import type * as Chat from "../chat/service"; +import type { JsonValue } from "../storage/model"; +import type { Record as CardRecord } from "../questions/records"; + +export function validateNotice( + entry: Record, + savedMessages: Map>, +): void { + if (Object.hasOwn(entry, "decision")) { + let author = entry.author as Record; + let decision = entry.decision; + if ( + author.kind !== "system" || !decision || typeof decision !== "object" + || Array.isArray(decision) + ) throw new Error("hosted channel has an invalid transcript entry"); + let value = decision as Record; + if (value.kind === "scoped-choice") { + let fields = [ + "questionnaireId", + "kind", + "threadId", + "proposalId", + "cardId", + "optionId", + "label", + "scope", + "generation", + "triggerEventId", + "sources", + ]; + if ( + Object.keys(value).some(key => !fields.includes(key)) + || fields.some(key => !Object.hasOwn(value, key)) + || typeof value.cardId !== "string" || !ULID.test(value.cardId) + || value.questionnaireId !== value.cardId + || typeof value.optionId !== "string" || !ULID.test(value.optionId) + || [value.threadId, value.proposalId, value.triggerEventId].some(id => + typeof id !== "string" || !id || id.length > 200 + ) + || typeof value.label !== "string" || !value.label.trim() + || value.label.length > Question.limits.MAX_LABEL + || value.scope !== "spike" + || !Number.isSafeInteger(value.generation) || (value.generation as number) < 0 + || !Array.isArray(value.sources) + || value.sources.length !== (value.triggerEventId === value.proposalId ? 1 : 2) + ) throw new Error("hosted channel has an invalid transcript entry"); + let sourceIds = new Set(); + for (let rawSource of value.sources) { + try { + assertSourceShape(rawSource); + if ( + rawSource.role !== "support" || rawSource.author.kind !== "member" + || sourceIds.has(rawSource.messageId) + ) throw new Error("invalid scoped source"); + let message = savedMessages.get(rawSource.messageId); + if (!message) throw new Error("missing scoped source message"); + validateSource(rawSource, message as Chat.Chat["entries"][number]); + sourceIds.add(rawSource.messageId); + } catch (error) { + throw new Error("hosted channel has an invalid scoped choice source", { + cause: error, + }); + } + } + } else if ( + Object.keys(value).some(key => + ![ + "questionnaireId", + "kind", + "generation", + "label", + "sourceMessageIds", + "suggestedOptionId", + ].includes(key) + ) + || typeof value.questionnaireId !== "string" + || !(value.questionnaireId === "document" || ULID.test(value.questionnaireId)) + || (value.kind !== "prompt" && value.kind !== "activity") + || (Object.hasOwn(value, "label") + && (typeof value.label !== "string" || value.label.length > 200)) + || (value.kind === "prompt" && ( + value.questionnaireId === "document" + || !Number.isSafeInteger(value.generation) || (value.generation as number) < 0 + || (Object.hasOwn(value, "sourceMessageIds") && ( + !Array.isArray(value.sourceMessageIds) + || value.sourceMessageIds.length > 8 + || value.sourceMessageIds.some(id => typeof id !== "string" || !id || id.length > 200) + || new Set(value.sourceMessageIds).size !== value.sourceMessageIds.length + )) + || (Object.hasOwn(value, "suggestedOptionId") && ( + !Object.hasOwn(value, "sourceMessageIds") + || typeof value.suggestedOptionId !== "string" + || !ULID.test(value.suggestedOptionId) + )) + )) + || (value.kind === "activity" && ( + Object.hasOwn(value, "generation") || Object.hasOwn(value, "sourceMessageIds") + || Object.hasOwn(value, "suggestedOptionId") + )) + ) throw new Error("hosted channel has an invalid transcript entry"); + } +} + +export function validateScopedNotices( + transcript: Chat.Chat["entries"], + conversationPlan: ConversationPlan.State, + records: CardRecord[], +): void { + let scopedTriggers = new Set(); + for (let entry of transcript as unknown as Chat.Chat["entries"]) { + let notice = entry.decision; + if (notice?.kind !== "scoped-choice") continue; + let thread = conversationPlan.threads.find(item => item.id === notice.threadId); + let record = records.find(item => item.id === notice.cardId); + let proposalIndex = conversationPlan.events.findIndex(item => item.id === notice.proposalId); + let triggerIndex = conversationPlan.events.findIndex(item => item.id === notice.triggerEventId); + let proposal = conversationPlan.events[proposalIndex]; + let trigger = conversationPlan.events[triggerIndex]; + if ( + scopedTriggers.has(notice.triggerEventId) + || !thread || thread.questionnaireId !== notice.cardId + || !record || record.threadId !== notice.threadId + || notice.generation > record.history.length + || proposal?.type !== "scoped-choice.proposed" + || proposal.threadId !== notice.threadId || proposal.cardId !== notice.cardId + || proposal.optionId !== notice.optionId || proposal.label !== notice.label + || proposal.scope !== notice.scope + || triggerIndex < proposalIndex + || (trigger?.type !== "scoped-choice.proposed" + && trigger?.type !== "scoped-choice.agreed") + || trigger.type === "scoped-choice.proposed" && trigger.id !== proposal.id + || trigger.type === "scoped-choice.agreed" && ( + trigger.threadId !== notice.threadId || trigger.proposalId !== proposal.id + || trigger.cardId !== notice.cardId || trigger.optionId !== notice.optionId + || trigger.label !== notice.label || trigger.scope !== notice.scope + ) + || !isDeepStrictEqual(notice.sources, [ + proposal.source, + ...(trigger.type === "scoped-choice.agreed" ? [trigger.source] : []), + ]) + ) throw new Error("hosted channel has a scoped choice notice without accepted evidence"); + scopedTriggers.add(notice.triggerEventId); + } +} diff --git a/apps/server/src/plan/persistence.test.ts b/apps/server/src/plan/persistence.test.ts index 87c15c1c..d24bdf3f 100644 --- a/apps/server/src/plan/persistence.test.ts +++ b/apps/server/src/plan/persistence.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { ulid } from "@chopin/dialect"; +import { $createTextNode, $getRoot, $isParagraphNode } from "lexical"; import * as Y from "yjs"; import * as Room from "./room"; @@ -55,13 +56,14 @@ describe("hosted plan persistence", () => { Service.source(plan), [], ); - let mutation = Room.insertDecision(peer, { - id: ulid(), - quote: "Durable first", - by: "octocat", - at: "2026-08-13T12:00:00.000Z", - notes: [{ by: "octocat", text: "Do not acknowledge early" }], - })!; + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("seed paragraph is missing"); + paragraph.append($createTextNode(" Durable first.")); + }, { discrete: true }); + await Room.settle(); + let update = Y.encodeStateAsUpdate(peer.doc, before); let frames: Array> = []; let ws = { data: { room: context.channel.id, handle: "octocat", client: "client", canEdit: true }, @@ -88,7 +90,7 @@ describe("hosted plan persistence", () => { rid: "request-1", id: "update-1", epoch: plan.document.epoch, - update: Buffer.from(mutation.update).toString("base64"), + update: Buffer.from(update).toString("base64"), }; Service.submit(plan, ws, message); diff --git a/apps/server/src/plan/planner-jobs-failure.test.ts b/apps/server/src/plan/planner-jobs-failure.test.ts new file mode 100644 index 00000000..d4c8ec92 --- /dev/null +++ b/apps/server/src/plan/planner-jobs-failure.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { storedRunning } from "./planner-jobs.test-fixtures"; + +test("failed interruption commit rejects open and the next open retries it", async () => { + let context = await storedRunning(); + let original = context.storage.collaboration.commit; + let fail = true; + let fatals: unknown[] = []; + context.backend.fatal = error => fatals.push(error); + context.storage.collaboration.commit = async input => { + if (fail) { + fail = false; + throw new Error("storage unavailable"); + } + return original(input); + }; + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("storage unavailable"); + let afterFailure = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let saved = afterFailure.sidecar ?? afterFailure.snapshot!.sidecar; + expect( + (saved as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("running"); + expect(fatals).toHaveLength(1); + let opened = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(opened.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + let afterRetry = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let retried = afterRetry.sidecar ?? afterRetry.snapshot!.sidecar; + expect( + (retried as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("failed"); + } finally { + await Service.close(opened); + } +}); + +test("a second reopen does not recommit an already interrupted job", async () => { + let context = await storedRunning(); + let first = await Service.open(context.channel.id, context.backend, context.server); + await Service.close(first); + let committed = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let original = context.storage.collaboration.commit; + let commits = 0; + context.storage.collaboration.commit = async input => { + commits++; + return original(input); + }; + let second = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(second.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + expect(commits).toBe(0); + let after = (await context.storage.collaboration.load(context.channel.id, context.now))!; + expect(after.channel.revision).toBe(committed.channel.revision); + } finally { + await Service.close(second); + } +}); diff --git a/apps/server/src/plan/planner-jobs-restoration.test.ts b/apps/server/src/plan/planner-jobs-restoration.test.ts new file mode 100644 index 00000000..7722e87c --- /dev/null +++ b/apps/server/src/plan/planner-jobs-restoration.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; +import { storedRunning } from "./planner-jobs.test-fixtures"; + +test("a missing Planner job field restores as empty and stays optional", async () => { + let context = await openPlan(); + try { + expect(context.plan.conversationPlanJobs).toEqual([]); + await Service.persist(context.plan); + let saved = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = saved.sidecar ?? saved.snapshot!.sidecar; + expect(Object.hasOwn(sidecar as object, "conversationPlanJobs")).toBe(false); + } finally { + await Service.close(context.plan); + } +}); + +test("observational stored reads leave a running Planner job untouched", async () => { + let context = await storedRunning(); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let original = context.storage.collaboration.commit; + let commits = 0; + context.storage.collaboration.commit = async input => { + commits++; + return original(input); + }; + await Service.readStored(loaded); + expect(commits).toBe(0); + let after = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = after.sidecar ?? after.snapshot!.sidecar; + expect( + (sidecar as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("running"); +}); + +test("one malformed Planner job rejects the entire saved sidecar", async () => { + let context = await openPlan(); + await Service.close(context.plan); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + sidecar.conversationPlanJobs = [{ id: "bad" }]; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "bad-planner-job", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)) + .rejects.toThrow("invalid Planner job"); +}); + +test("archived documents durably interrupt running jobs during maintenance open", async () => { + let context = await storedRunning(); + await context.storage.channels.archive({ id: context.channel.id, now: context.now }); + let opened = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(opened.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = loaded.sidecar ?? loaded.snapshot!.sidecar; + expect( + (sidecar as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs[0]?.status, + ).toBe("failed"); + } finally { + await Service.close(opened); + } +}); diff --git a/apps/server/src/plan/planner-jobs.test-fixtures.ts b/apps/server/src/plan/planner-jobs.test-fixtures.ts new file mode 100644 index 00000000..976e6097 --- /dev/null +++ b/apps/server/src/plan/planner-jobs.test-fixtures.ts @@ -0,0 +1,23 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; + +export const AT = "2026-09-25T10:00:00.000Z"; + +export let running: ConversationPlan.Job = { + id: "refine:W1:m1", + kind: "refine", + target: "W1", + trigger: "m1", + status: "running", + attempts: 0, + at: AT, +}; + +export async function storedRunning(extra: ConversationPlan.Job[] = []) { + let context = await openPlan(); + context.plan.conversationPlanJobs = [running, ...extra]; + await Service.persist(context.plan); + await Service.close(context.plan); + return context; +} diff --git a/apps/server/src/plan/planner-jobs.test.ts b/apps/server/src/plan/planner-jobs.test.ts new file mode 100644 index 00000000..78f2ceb4 --- /dev/null +++ b/apps/server/src/plan/planner-jobs.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Service from "./service"; +import { AT, storedRunning } from "./planner-jobs.test-fixtures"; + +test("restart durably records an interrupted Planner job before opening", async () => { + let context = await storedRunning([{ + id: "heading:document:m0", + kind: "heading", + target: "document", + trigger: "m0", + status: "done", + attempts: 1, + output: "{}", + at: AT, + }]); + let before = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let beforeSidecar = (before.sidecar ?? before.snapshot!.sidecar) as { + revision: number; + documentSeq: number; + }; + let opened = await Service.open(context.channel.id, context.backend, context.server); + try { + expect(opened.conversationPlanJobs.map(job => [job.status, job.reason])).toEqual([ + ["failed", "interrupted"], + ["done", undefined], + ]); + let after = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = after.sidecar ?? after.snapshot!.sidecar; + expect((sidecar as { conversationPlanJobs: ConversationPlan.Job[] }) + .conversationPlanJobs.map(job => job.status)).toEqual(["failed", "done"]); + expect((sidecar as { documentSeq: number }).documentSeq).toBe(beforeSidecar.documentSeq); + expect((sidecar as { revision: number }).revision).toBe(beforeSidecar.revision); + expect(opened.document.seq).toBe(beforeSidecar.documentSeq); + expect(opened.revision).toBe(beforeSidecar.revision); + } finally { + await Service.close(opened); + } +}); + +test("open waits for interrupted-job durability before exposing the plan", async () => { + let context = await storedRunning(); + let original = context.storage.collaboration.commit; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + context.storage.collaboration.commit = async input => { + entered.resolve(); + await release.promise; + return original(input); + }; + let returned = false; + let opening = Service.open(context.channel.id, context.backend, context.server).then(plan => { + returned = true; + return plan; + }); + await entered.promise; + let held = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let heldSidecar = held.sidecar ?? held.snapshot!.sidecar; + let jobs = (heldSidecar as { conversationPlanJobs: ConversationPlan.Job[] }).conversationPlanJobs; + expect(jobs[0]?.status).toBe("running"); + expect(returned).toBe(false); + release.resolve(); + let opened = await opening; + try { + expect(opened.conversationPlanJobs[0]).toMatchObject({ + status: "failed", + reason: "interrupted", + }); + } finally { + await Service.close(opened); + } +}); diff --git a/apps/server/src/plan/projection-submit.test-fixtures.ts b/apps/server/src/plan/projection-submit.test-fixtures.ts new file mode 100644 index 00000000..ab438f02 --- /dev/null +++ b/apps/server/src/plan/projection-submit.test-fixtures.ts @@ -0,0 +1,88 @@ +import { ulid } from "@chopin/dialect"; +import { $createTextNode, $getRoot, $isParagraphNode } from "lexical"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import type { Socket } from "../wire"; + +export function socket( + context: Awaited>, + frames: Array>, +) { + return { + data: { room: context.channel.id, handle: "octocat", client: "client", canEdit: true }, + send(value: string) { + frames.push(JSON.parse(value) as Record); + }, + publish(_topic: string, value: string) { + frames.push(JSON.parse(value) as Record); + }, + close() {}, + } as unknown as Socket; +} + +export async function submitUpdate( + context: Awaited>, + plan: Service.Plan, + peer: Room.Document, + update: Uint8Array, + frames: Array>, +): Promise { + Service.submit(plan, socket(context, frames), { + kind: "plan:update", + ts: 0, + rid: ulid(), + id: ulid(), + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + peer.doc.destroy(); +} + +export async function seededPlan(context: Awaited>) { + let plan = await Service.open(context.channel.id, context.backend, context.server); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + Service.source(plan), + [], + ); + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("seed paragraph is missing"); + paragraph.append($createTextNode("Neighbor prose.")); + }, { discrete: true }); + await Room.settle(); + await submitUpdate( + context, + plan, + peer, + Y.encodeStateAsUpdate(peer.doc, before), + [], + ); + return plan; +} + +export async function submitResearchUpdate( + context: Awaited>, + plan: Service.Plan, + update: Uint8Array, + rid: string, +) { + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid, + id: rid, + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + return replies; +} diff --git a/apps/server/src/plan/protected-projection.test-fixtures.ts b/apps/server/src/plan/protected-projection.test-fixtures.ts new file mode 100644 index 00000000..c748bc9b --- /dev/null +++ b/apps/server/src/plan/protected-projection.test-fixtures.ts @@ -0,0 +1,111 @@ +import { DecisionNode, QuestionnaireNode, ulid } from "@chopin/dialect"; +import { $createTextNode, $getRoot, $isParagraphNode, $nodesOfType } from "lexical"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import * as Questions from "../questions/service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { seededPlan, socket } from "./projection-submit.test-fixtures"; + +export async function protectedFixture(kind: "decision" | "questionnaire") { + let context = await hosted(); + let plan = await seededPlan(context); + let id = ulid(); + if (kind === "decision") { + let quote = "Neighbor prose."; + let passage = Room.passageAt(plan.document, [0], quote, 0, quote.length); + let note = { by: "octocat", text: "Keep the original wording." }; + plan.threads.set(id, { + id, + status: "accepted", + passage, + notes: [{ id: ulid(), handle: "octocat", text: note.text, ts: 1 }], + quote, + resolver: "octocat", + at: Math.floor(Date.now() / 1_000), + }); + let mutation = Room.insertDecision(plan.document, { + id, + quote, + by: "octocat", + at: new Date().toISOString(), + notes: [note], + }); + if (!mutation) throw new Error("decision projection was not inserted"); + await Service.publish(plan, context.server, context.channel.id, mutation); + } else { + let optionId = ulid(); + id = await Questions.insertConversationCard(plan, context.server, context.channel.id, { + threadId: ulid(), + header: "Hosting", + question: "Where should we host?", + options: [{ id: optionId, label: "S3" }], + }); + let replies: Array> = []; + let ws = socket(context, replies); + let entry = plan.questions.open.get(id)!; + let questionId = entry.definition.questions[0]!.id; + let human = entry.model.fork(); + human.api.val([questionId, "choice"]).set(optionId); + let patch = human.api.flush(); + if (!patch) throw new Error("answer choice made no patch"); + await Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: ulid(), + id, + patch: [...patch.toBinary()], + }); + await Questions.submit(plan, context.server, context.channel.id, ws, { + kind: "question:submit", + ts: 0, + rid: ulid(), + id, + revision: plan.questions.open.get(id)!.revision, + }); + if (plan.records.get(id)?.status !== "answered") { + throw new Error(`questionnaire sidecar did not become answered: ${JSON.stringify(replies)}`); + } + } + return { context, plan, id }; +} + +export async function changeProjection( + peer: Room.Document, + kind: "decision" | "questionnaire", + id: string, + change: "remove" | "alter" | "move-and-edit" | "remove-and-edit" | "alter-and-edit", +): Promise { + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + if (kind === "decision") { + let node = $nodesOfType(DecisionNode).find(candidate => candidate.getId() === id); + if (!node) throw new Error("decision projection is missing from peer"); + if (change === "remove" || change === "remove-and-edit") node.remove(); + else if (change === "alter" || change === "alter-and-edit") { + node.setDecision({ ...node.getDecision(), quote: "Forged quote." }); + } + if (change !== "remove" && change !== "alter") { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("neighbor paragraph is missing"); + paragraph.append($createTextNode(" Edited.")); + if (change === "move-and-edit") paragraph.insertBefore(node); + } + } else { + let node = $nodesOfType(QuestionnaireNode).find(candidate => candidate.getId() === id); + if (!node) throw new Error("questionnaire projection is missing from peer"); + if (change === "remove" || change === "remove-and-edit") node.remove(); + else if (change === "alter" || change === "alter-and-edit") { + node.setQuestionnaire({ ...node.getQuestionnaire(), status: "open" }); + } + if (change !== "remove" && change !== "alter") { + let paragraph = $getRoot().getFirstChild(); + if (!$isParagraphNode(paragraph)) throw new Error("neighbor paragraph is missing"); + paragraph.append($createTextNode(" Edited.")); + if (change === "move-and-edit") paragraph.insertBefore(node); + } + } + }, { discrete: true }); + await Room.settle(); + return Y.encodeStateAsUpdate(peer.doc, before); +} diff --git a/apps/server/src/plan/protected-projection.test.ts b/apps/server/src/plan/protected-projection.test.ts new file mode 100644 index 00000000..8a5a7bdc --- /dev/null +++ b/apps/server/src/plan/protected-projection.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "bun:test"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { socket } from "./projection-submit.test-fixtures"; +import { changeProjection, protectedFixture } from "./protected-projection.test-fixtures"; + +describe("hosted plan persistence", () => { + for (let kind of ["decision", "questionnaire"] as const) { + for (let change of ["remove", "alter"] as const) { + it(`rejects a browser ${change} of an accepted ${kind} projection before ack`, async () => { + let { context, plan, id } = await protectedFixture(kind); + let originalSource = Service.source(plan); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await changeProjection(peer, kind, id, change); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "protected-projection", + id: "protected-projection", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + if (kind === "decision") { + expect(plan.threads.get(id)?.status).toBe("accepted"); + } else { + expect(plan.records.get(id)?.status).toBe("answered"); + } + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + } + + for (let change of ["remove-and-edit", "alter-and-edit"] as const) { + it(`rejects a prose edit bundled with ${change} of an accepted ${kind}`, async () => { + let { context, plan, id } = await protectedFixture(kind); + let originalSource = Service.source(plan); + let originalEpoch = plan.document.epoch; + let peer = await Room.restore( + originalEpoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + let activePlan = plan; + try { + let update = await changeProjection(peer, kind, id, change); + let replies: Array> = []; + Service.submit(activePlan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "combined-projection-edit", + id: "combined-projection-edit", + epoch: originalEpoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await activePlan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(activePlan.document.epoch).not.toBe(originalEpoch); + expect(Service.source(activePlan)).toBe(originalSource); + expect(Service.source(activePlan)).not.toContain("Edited."); + if (kind === "decision") { + expect(activePlan.threads.get(id)?.status).toBe("accepted"); + } else { + expect(activePlan.records.get(id)?.status).toBe("answered"); + } + + await Service.close(activePlan); + activePlan = await Service.open( + context.channel.id, + context.backend, + context.server, + ); + expect(Service.source(activePlan)).toBe(originalSource); + if (kind === "decision") { + expect(activePlan.threads.get(id)?.status).toBe("accepted"); + } else { + expect(activePlan.records.get(id)?.status).toBe("answered"); + } + } finally { + peer.doc.destroy(); + await Service.close(activePlan); + } + }); + } + + it(`accepts moving an exact ${kind} projection with adjacent prose edits`, async () => { + let { context, plan, id } = await protectedFixture(kind); + let originalSource = Service.source(plan); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await changeProjection(peer, kind, id, "move-and-edit"); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "move-projection", + id: "move-projection", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(true); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(false); + let source = Service.source(plan); + expect(source).toContain("Edited."); + expect(source).toContain(`id="${id}"`); + expect(source.indexOf(kind === "decision" ? " { + it("fills legacy defaults and persists them through open, close and reopen", async () => { + let context = await stored([legacyRecord()], [draft()]); + let plan = context.plan; + try { + expect(plan.records.get("saved-card")).toMatchObject({ + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }); + expect(Store.snapshot(plan.questions, "saved-card")).toMatchObject({ + open: true, + definition: definition(), + }); + } finally { + await Service.close(plan); + } + let saved = await context.storage.collaboration.load(context.channel.id, context.now); + expect(saved?.sidecar).toMatchObject({ + questions: [{ origin: "planner", history: [], optionOrigins: {}, editors: [] }], + }); + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")?.definition).toEqual(definition()); + } finally { + await Service.close(reopened); + } + }); + + it("persists owner and decision time derived from a legacy answered record", async () => { + let record = { + ...legacyRecord(), + status: "answered", + answers: { "saved-question": "Cloud" }, + resolver: "ana", + at: 123, + }; + let context = await stored([record], []); + try { + expect(context.plan.records.get("saved-card")).toMatchObject({ + ...record, + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + owner: "ana", + decidedAt: 123, + }); + } finally { + await Service.close(context.plan); + } + let saved = await context.storage.collaboration.load(context.channel.id, context.now); + expect(saved?.sidecar).toMatchObject({ questions: [{ owner: "ana", decidedAt: 123 }] }); + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")).toMatchObject({ owner: "ana", decidedAt: 123 }); + } finally { + await Service.close(reopened); + } + }); + + it("restores open and reopened pending definitions with their original empty draft", async () => { + for (let status of ["open", "reopened"] as const) { + let value = definition(); + value.questions[0]!.options = []; + let record = { + ...legacyRecord(value), + status, + origin: "conversation" as const, + threadId: "saved-thread", + history: [], + optionOrigins: {}, + editors: [], + }; + let context = await stored([record], [draft(value)]); + let plan = context.plan; + try { + expect(plan.records.get("saved-card")).toMatchObject(record); + let entry = Store.get(plan.questions, "saved-card")!; + expect(Question.read(entry.model, entry.definition)["saved-question"]).toEqual({ + mode: "choices", + choice: null, + custom: "", + options: {}, + }); + } finally { + await Service.close(plan); + } + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")?.status).toBe(status); + } finally { + await Service.close(reopened); + } + } + }); + + it("restores a reopened card's suggestion without changing the shared draft", async () => { + let suggested = { optionId: "saved-b", messageIds: ["message-1"], revision: 2 }; + let context = await stored([{ ...legacyRecord(), status: "reopened" }], [{ + ...draft(), + revision: 2, + suggested, + }]); + let plan = context.plan; + try { + let entry = Store.get(plan.questions, "saved-card")!; + expect(entry.suggested).toEqual(suggested); + expect(Question.read(entry.model, entry.definition)["saved-question"]?.choice).toBeNull(); + } finally { + await Service.close(plan); + } + let reopened = await context.open(); + try { + expect(Store.get(reopened.questions, "saved-card")?.suggested).toEqual(suggested); + } finally { + await Service.close(reopened); + } + }); + + it("rejects malformed durable records and noncanonical saved definitions", async () => { + for ( + let change of [ + { origin: "unknown" }, + { editors: ["ana", "ana"] }, + { choices: ["missing-option"] }, + { history: [{ choices: [], owner: "ana", at: 1 }] }, + { definition: { questions: [{ ...definition().questions[0]!, header: "" }] } }, + ] + ) { + expect(await rejected(stored([{ ...legacyRecord(), ...change }], [draft()]))).toBeInstanceOf( + Error, + ); + } + }); + + it("rejects an orphan draft rather than restoring it without its record", async () => { + expect(await rejected(stored([], [draft()]))).toBeInstanceOf(Error); + }); + + it("requires identical saved question IDs, option IDs, order and display text", async () => { + for ( + let field of [ + "question-id", + "question", + "header", + "multiple", + "option-id", + "label", + "description", + "order", + ] + ) { + let value = definition(); + let question = value.questions[0]!; + if (field === "question-id") question.id = "different-question"; + if (field === "question") question.question = "A different question?"; + if (field === "header") question.header = "Different header"; + if (field === "multiple") question.multiple = true; + if (field === "option-id") question.options[0]!.id = "different-option"; + if (field === "label") question.options[0]!.label = "Different label"; + if (field === "description") question.options[0]!.description = "Different description"; + if (field === "order") question.options.reverse(); + expect(await rejected(stored([legacyRecord()], [draft(value)]))).toBeInstanceOf(Error); + } + }); +}); diff --git a/apps/server/src/plan/question-record-sources.test.ts b/apps/server/src/plan/question-record-sources.test.ts new file mode 100644 index 00000000..3c33b3fa --- /dev/null +++ b/apps/server/src/plan/question-record-sources.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import * as Service from "./service"; +import { applyInference, initialState } from "../conversation-plan/domain"; +import { draft, legacyRecord, rejected, stored } from "./question-record.test-fixtures"; + +// Saved option-source checks from archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/plan/service.ts; no classifier or external service is involved. +function sourced() { + let message = { + id: "message-1", + text: "Try 🧪 Cloud next", + ts: 1, + author: { kind: "member" as const, handle: "ana" }, + }; + let source = { + messageId: message.id, + author: message.author, + quote: "🧪 Cloud", + start: 4, + end: 12, + role: "option" as const, + }; + let record = { + ...legacyRecord(), + optionOrigins: { "saved-a": { origin: "chat" as const, source } }, + }; + return { message, source, record }; +} + +describe("durable option source restoration", () => { + it("checks a member's exact UTF-16 quote and preserves its source through close", async () => { + let { message, source, record } = sourced(); + let context = await stored( + [{ + ...record, + optionOrigins: { "saved-a": { origin: "planner", source } }, + }], + [draft()], + [message], + ); + let plan = context.plan; + try { + await Service.exclusive(plan, async () => { + let threadId = "source-thread"; + let state = applyInference(initialState(), { + id: "source-opening", + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source: { + ...source, + quote: message.text, + start: 0, + end: message.text.length, + role: "question", + }, + question: message.text, + }, message); + plan.conversationPlan = applyInference(state, { + id: "source-option", + type: "option.added", + threadId, + observedThreadVersion: 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: message.ts, + source, + contribution: { + id: "saved-a", + text: source.quote, + authoring: "quoted", + targetId: threadId, + }, + }, message); + let saved = plan.records.get("saved-card")!; + saved.threadId = threadId; + saved.optionOrigins = record.optionOrigins; + await Service.persistExclusive(plan); + }); + expect(plan.records.get("saved-card")?.optionOrigins).toEqual(record.optionOrigins); + } finally { + await Service.close(plan); + } + let reopened = await context.open(); + try { + expect(reopened.records.get("saved-card")?.optionOrigins).toEqual(record.optionOrigins); + } finally { + await Service.close(reopened); + } + }); + + it("accepts an agent source only when the saved author is also the agent", async () => { + let { message, source, record } = sourced(); + let author = { kind: "agent" as const }; + let context = await stored( + [{ + ...record, + optionOrigins: { "saved-a": { origin: "planner", source: { ...source, author } } }, + }], + [draft()], + [{ ...message, author }], + ); + let plan = context.plan; + try { + expect(plan.records.get("saved-card")?.optionOrigins["saved-a"]?.source?.author).toEqual( + author, + ); + } finally { + await Service.close(plan); + } + }); + + it("rejects absent, changed, streaming, or wrongly attributed saved messages", async () => { + let { message, record } = sourced(); + for ( + let transcript of [ + [], + [{ ...message, text: "Try another host next" }], + [{ ...message, streaming: true }], + [{ ...message, author: { kind: "member" as const, handle: "ben" } }], + [{ ...message, author: { kind: "agent" } }], + ] + ) { + expect(await rejected(stored([record], [draft()], transcript))).toBeInstanceOf(Error); + } + }); +}); diff --git a/apps/server/src/plan/question-record.test-fixtures.ts b/apps/server/src/plan/question-record.test-fixtures.ts new file mode 100644 index 00000000..d43b0683 --- /dev/null +++ b/apps/server/src/plan/question-record.test-fixtures.ts @@ -0,0 +1,55 @@ +import * as Question from "@chopin/question"; +import * as Service from "./service"; +import { openPlan } from "../testing/plan"; + +import type { Definition } from "@chopin/question"; + +export function definition(): Definition { + return { + questions: [{ + id: "saved-question", + header: "Hosting", + question: "Where should we host?", + multiple: false, + options: [ + { id: "saved-a", label: "Cloud", description: "Managed" }, + { id: "saved-b", label: "Own server", description: "" }, + ], + }], + }; +} + +export function draft(value = definition()) { + return { + id: "saved-card", + definition: value, + model: [...Question.create(value).toBinary()], + revision: 0, + }; +} + +export function legacyRecord(value = definition()) { + return { id: "saved-card", definition: value, status: "open" }; +} + +export async function stored( + questions: unknown[], + openQuestions: unknown[], + transcript: unknown[] = [], +) { + let context = await openPlan("# Questions\n", { questions, openQuestions, transcript }); + return { + ...context, + open: () => Service.open(context.channel.id, context.backend, context.server), + }; +} + +export async function rejected(pending: ReturnType) { + try { + let context = await pending; + await Service.close(context.plan); + return undefined; + } catch (err) { + return err; + } +} diff --git a/apps/server/src/plan/research-placement-1.test.ts b/apps/server/src/plan/research-placement-1.test.ts new file mode 100644 index 00000000..9e258d54 --- /dev/null +++ b/apps/server/src/plan/research-placement-1.test.ts @@ -0,0 +1,39 @@ +import { expect, it, spyOn } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +it("persists a server-authored Research card once before broadcasting", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + try { + let id = ulid(); + await Service.placeResearchReference(plan, id); + expect(Service.source(plan)).toContain(``); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + if (!stored) throw new Error("document is missing"); + expect((await Service.readStored(stored)).source).toContain(``); + await Service.placeResearchReference(plan, id); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + } finally { + await Service.close(plan); + } +}); + +it("does not expose a Research card when its document commit fails", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let commit = spyOn(context.storage.collaboration, "commit").mockImplementation(async () => { + throw new Error("document commit failed"); + }); + try { + await expect(Service.placeResearchReference(plan, ulid())) + .rejects.toThrow("document commit failed"); + expect(Service.source(plan)).not.toContain(" frame.kind === "plan:update")).toEqual([]); + } finally { + commit.mockRestore(); + await Service.close(plan); + } +}); diff --git a/apps/server/src/plan/research-placement-2.test.ts b/apps/server/src/plan/research-placement-2.test.ts new file mode 100644 index 00000000..d1796c43 --- /dev/null +++ b/apps/server/src/plan/research-placement-2.test.ts @@ -0,0 +1,57 @@ +import { expect, it, spyOn } from "bun:test"; +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +it("holds a Research card from both clients until its durable commit finishes", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let first: string[] = []; + let second: string[] = []; + let publish = spyOn(context.server, "publish").mockImplementation((_topic, value) => { + first.push(String(value)); + second.push(String(value)); + return 0; + }); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commitOriginal = context.storage.collaboration.commit; + let commit = spyOn(context.storage.collaboration, "commit").mockImplementation(async input => { + entered.resolve(); + await release.promise; + return commitOriginal(input); + }); + try { + let placement = Service.placeResearchReference(plan, ulid()); + await entered.promise; + expect(first).toEqual([]); + expect(second).toEqual([]); + release.resolve(); + await placement; + expect(first).toHaveLength(1); + expect(second).toEqual(first); + } finally { + commit.mockRestore(); + publish.mockRestore(); + await Service.close(plan); + } +}); + +it("defers Research placement while implementation holds the document", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let id = ulid(); + try { + plan.claiming = true; + expect(await Service.placeResearchReference(plan, id)).toBe("deferred"); + expect(Service.source(plan)).not.toContain(``); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toEqual([]); + plan.claiming = false; + expect(await Service.placeResearchReference(plan, id)).toBe("placed"); + expect(Service.source(plan)).toContain(``); + expect(context.frames.filter(frame => frame.kind === "plan:update")).toHaveLength(1); + } finally { + plan.claiming = false; + await Service.close(plan); + } +}); diff --git a/apps/server/src/plan/research-placement.test-fixtures.ts b/apps/server/src/plan/research-placement.test-fixtures.ts new file mode 100644 index 00000000..a2bf769f --- /dev/null +++ b/apps/server/src/plan/research-placement.test-fixtures.ts @@ -0,0 +1,24 @@ +import { ulid } from "@chopin/dialect"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +export async function pendingInlineResearch( + context: Awaited>, + channelId = context.channel.id, +) { + let id = ulid(); + return context.storage.research.start({ + id, + channelId, + title: "Research request", + question: "What should we research?", + origin: "inline", + createdBy: "U_octocat", + turnId: ulid(), + messageId: ulid(), + requestId: ulid(), + idempotencyKey: `inline-research-${id}`, + fingerprint: `inline-research-${id}`, + now: context.now, + lease: context.lease, + }); +} diff --git a/apps/server/src/plan/research-projection-authority.test.ts b/apps/server/src/plan/research-projection-authority.test.ts new file mode 100644 index 00000000..0b540a84 --- /dev/null +++ b/apps/server/src/plan/research-projection-authority.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { pendingInlineResearch } from "./research-placement.test-fixtures"; +import { ulid } from "@chopin/dialect"; +import { socket, submitResearchUpdate } from "./projection-submit.test-fixtures"; +import { insertResearchProjection } from "./research-projection.test-fixtures"; +import { linkedResearchJob, setResearchJobState } from "./research-projection-jobs.test-fixtures"; + +describe("hosted plan persistence", () => { + it("rejects a Research projection for an inline request with no linked work", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate( + context, + plan, + update, + "unlinked-research-projection", + ); + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + let stored = await context.storage.research.get(context.channel.id, request.workspace.id); + expect(stored?.workspace.origin).toBe("inline"); + expect(stored?.turns[0]?.kind).toBe("initial"); + expect(stored?.turns[0]?.evidenceJobId).toBeUndefined(); + expect(stored?.turns[0]?.answerJobId).toBeUndefined(); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + + it("rejects a Research projection backed by a request from another channel", async () => { + let context = await hosted(); + let otherChannel = await context.storage.channels.create({ + id: ulid(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Other document", + createdBy: "U_octocat", + now: context.now, + }); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context, otherChannel.id); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "foreign-research-projection", + id: "foreign-research-projection", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + expect(await context.storage.research.get(context.channel.id, request.workspace.id)) + .toBeUndefined(); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + + it("rejects inserting a stale Research reference after its linked request failed", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context); + let job = await linkedResearchJob(context, request.workspace.id, "evidence"); + await setResearchJobState(context, job.id, "failed"); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate(context, plan, update, "stale-research-insert"); + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); +}); diff --git a/apps/server/src/plan/research-projection-jobs.test-fixtures.ts b/apps/server/src/plan/research-projection-jobs.test-fixtures.ts new file mode 100644 index 00000000..57496274 --- /dev/null +++ b/apps/server/src/plan/research-projection-jobs.test-fixtures.ts @@ -0,0 +1,101 @@ +import { ulid } from "@chopin/dialect"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; + +export async function linkedResearchJob( + context: Awaited>, + workspaceId: string, + role: "evidence" | "answer", +) { + let detail = await context.storage.research.get(context.channel.id, workspaceId); + let initial = detail?.turns.find(turn => turn.kind === "initial"); + if (!initial) throw new Error("initial research turn is missing"); + let type = `research-${role}`; + let jobId = ulid(); + let targetKey = `${type}:workspace:${workspaceId}:turn:${initial.id}:${role}`; + let created = await context.storage.jobs.enqueue({ + id: jobId, + channelId: context.channel.id, + type, + version: 1, + origin: "user", + targetKey, + idempotencyKey: `research-test-${jobId}`, + fingerprint: `research-test-${jobId}`, + input: { workspaceId, turnId: initial.id, query: initial.question }, + availableAt: context.now, + now: context.now, + lease: context.lease, + }); + await context.storage.research.linkJob({ + channelId: context.channel.id, + workspaceId, + turnId: initial.id, + role, + jobId: created.job.id, + now: context.now, + lease: context.lease, + }); + return created.job; +} + +export async function setResearchJobState( + context: Awaited>, + jobId: string, + state: "failed" | "cancelled" | "completed", +) { + let [claimed] = await context.storage.jobs.claim({ + channelId: context.channel.id, + claimOwner: `research-test-${jobId}`, + count: 1, + ttlMs: 30_000, + now: context.now, + lease: context.lease, + }); + if (!claimed) throw new Error("research job was not claimable"); + if (state === "failed") { + return context.storage.jobs.fail({ + channelId: context.channel.id, + jobId, + claimOwner: claimed.claimOwner!, + claimGeneration: claimed.claimGeneration, + reason: "test-failure", + now: context.now, + lease: context.lease, + }); + } + if (state === "completed") { + return context.storage.jobs.settle({ + channelId: context.channel.id, + jobId, + claimOwner: claimed.claimOwner!, + claimGeneration: claimed.claimGeneration, + artifact: { completed: true }, + now: context.now, + lease: context.lease, + }); + } + return context.storage.jobs.cancel({ + channelId: context.channel.id, + jobId, + now: context.now, + lease: context.lease, + }); +} + +export async function publishReadyResearch( + context: Awaited>, + workspaceId: string, +) { + let answer = await linkedResearchJob(context, workspaceId, "answer"); + await setResearchJobState(context, answer.id, "completed"); + await context.storage.research.publishInitialReport({ + channelId: context.channel.id, + workspaceId, + answerJobId: answer.id, + title: "Published research", + initial: await Service.initial("# Published research\n"), + now: context.now, + lease: context.lease, + }); +} diff --git a/apps/server/src/plan/research-projection-lifecycle.test.ts b/apps/server/src/plan/research-projection-lifecycle.test.ts new file mode 100644 index 00000000..a27e773e --- /dev/null +++ b/apps/server/src/plan/research-projection-lifecycle.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { pendingInlineResearch } from "./research-placement.test-fixtures"; +import { submitResearchUpdate } from "./projection-submit.test-fixtures"; +import { + addResearchReference, + removeResearchProjection, +} from "./research-projection.test-fixtures"; +import { + linkedResearchJob, + publishReadyResearch, + setResearchJobState, +} from "./research-projection-jobs.test-fixtures"; + +describe("hosted plan persistence", () => { + it("rejects removing an inline Research reference while linked work is active", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let request = await pendingInlineResearch(context); + let job = await linkedResearchJob(context, request.workspace.id, "evidence"); + let peer = await addResearchReference( + context, + plan, + request.workspace.id, + "active-research-add", + ); + try { + let update = await removeResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate(context, plan, update, "active-research-remove"); + + expect((await context.storage.jobs.get(context.channel.id, job.id))?.job.state) + .toBe("pending"); + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toContain(``); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + + for (let terminal of ["failed", "cancelled", "ready"] as const) { + it(`allows removing an inline Research reference after ${terminal} work`, async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let request = await pendingInlineResearch(context); + let evidence = await linkedResearchJob(context, request.workspace.id, "evidence"); + let peer = await addResearchReference( + context, + plan, + request.workspace.id, + `terminal-research-add-${terminal}`, + ); + if (terminal === "failed" || terminal === "cancelled") { + await setResearchJobState(context, evidence.id, terminal); + } else if (terminal === "ready") { + await setResearchJobState(context, evidence.id, "completed"); + await publishReadyResearch(context, request.workspace.id); + } + try { + let update = await removeResearchProjection(peer, request.workspace.id); + let replies = await submitResearchUpdate( + context, + plan, + update, + `terminal-research-remove-${terminal}`, + ); + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(true); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(false); + expect(Service.source(plan)).not.toContain(``); + } finally { + peer.doc.destroy(); + await Service.close(plan); + } + }); + } +}); diff --git a/apps/server/src/plan/research-projection-race.test.ts b/apps/server/src/plan/research-projection-race.test.ts new file mode 100644 index 00000000..4459fb5b --- /dev/null +++ b/apps/server/src/plan/research-projection-race.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { pendingInlineResearch } from "./research-placement.test-fixtures"; +import { socket } from "./projection-submit.test-fixtures"; +import { insertResearchProjection } from "./research-projection.test-fixtures"; +import { linkedResearchJob, setResearchJobState } from "./research-projection-jobs.test-fixtures"; + +describe("hosted plan persistence", () => { + it("resets a Research insertion when its job fails after validation", async () => { + let context = await hosted(); + let plan = await Service.open(context.channel.id, context.backend, context.server); + let originalSource = Service.source(plan); + let request = await pendingInlineResearch(context); + let job = await linkedResearchJob(context, request.workspace.id, "evidence"); + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + originalSource, + [], + ); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let originalCommit = context.storage.collaboration.commit; + context.storage.collaboration.commit = input => { + if (!input.researchProjections?.length) return originalCommit(input); + entered.resolve(); + return release.promise.then(() => originalCommit(input)); + }; + try { + let update = await insertResearchProjection(peer, request.workspace.id); + let replies: Array> = []; + Service.submit(plan, socket(context, replies), { + kind: "plan:update", + ts: 0, + rid: "racing-research-insert", + id: "racing-research-insert", + epoch: plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Promise.race([ + entered.promise, + Bun.sleep(1_000).then(() => { + throw new Error("Research commit did not start"); + }), + ]); + await setResearchJobState(context, job.id, "failed"); + release.resolve(); + await plan.flushing; + + expect(replies.some(frame => frame.kind === "plan:ack")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:update")).toBe(false); + expect(context.frames.some(frame => frame.kind === "plan:reset")).toBe(true); + expect(Service.source(plan)).toBe(originalSource); + let saved = await context.storage.collaboration.load(context.channel.id, context.now); + expect(saved?.snapshot?.source).toBe(originalSource); + expect(saved?.updates).toHaveLength(0); + } finally { + release.resolve(); + context.storage.collaboration.commit = originalCommit; + peer.doc.destroy(); + await Service.close(plan); + } + }); +}); diff --git a/apps/server/src/plan/research-projection.test-fixtures.ts b/apps/server/src/plan/research-projection.test-fixtures.ts new file mode 100644 index 00000000..ee628223 --- /dev/null +++ b/apps/server/src/plan/research-projection.test-fixtures.ts @@ -0,0 +1,52 @@ +import { $createResearchNode, ResearchNode } from "@chopin/dialect"; +import { $getRoot, $nodesOfType } from "lexical"; +import * as Y from "yjs"; +import * as Room from "./room"; +import * as Service from "./service"; +import { hosted } from "./conversation-persistence.test-fixtures"; +import { submitResearchUpdate } from "./projection-submit.test-fixtures"; + +export async function insertResearchProjection( + peer: Room.Document, + id: string, +): Promise { + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => $getRoot().append($createResearchNode(id)), { discrete: true }); + await Room.settle(); + return Y.encodeStateAsUpdate(peer.doc, before); +} + +export async function removeResearchProjection( + peer: Room.Document, + id: string, +): Promise { + let before = Y.encodeStateVector(peer.doc); + peer.editor.update(() => { + let node = $nodesOfType(ResearchNode).find(candidate => candidate.getId() === id); + if (!node) throw new Error("research projection is missing from peer"); + node.remove(); + }, { discrete: true }); + await Room.settle(); + return Y.encodeStateAsUpdate(peer.doc, before); +} + +export async function addResearchReference( + context: Awaited>, + plan: Service.Plan, + workspaceId: string, + rid: string, +) { + let peer = await Room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + Service.source(plan), + [], + ); + let update = await insertResearchProjection(peer, workspaceId); + let replies = await submitResearchUpdate(context, plan, update, rid); + if (!replies.some(frame => frame.kind === "plan:ack")) { + peer.doc.destroy(); + throw new Error("pending research reference was not accepted"); + } + return peer; +} diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index de126edb..0af53e56 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -35,6 +35,11 @@ import { ulid, } from "@chopin/dialect"; import { $getAnchorAndFocusForUserState } from "@lexical/yjs"; +import { + newResearchProjections, + protectProjections, + removedResearchProjections, +} from "./projections"; import * as Questionnaires from "./questionnaires"; import { createCardProjections, QuestionnaireProjectionError } from "./card-projections"; export { QuestionnaireProjectionError } from "./card-projections"; @@ -48,6 +53,7 @@ import { $nodesOfType, } from "lexical"; +import type { ResearchProjectionChange } from "../storage/model"; import type { Binding, Provider } from "@lexical/yjs"; import type { LexicalEditor, LexicalNode } from "lexical"; import type { Root, RootContent } from "mdast"; @@ -332,7 +338,7 @@ export function mark(target: Document): void { } export type Applied = - | { ok: true; seq: number } + | { ok: true; seq: number; researchProjections: ResearchProjectionChange[] } | { ok: false; issues: string[] }; /** @@ -342,21 +348,50 @@ export type Applied = * document proves the shared tree still serialises to something the dialect * accepts, and doing that per keystroke would be absurd. */ -export async function apply(target: Document, updates: Uint8Array[]): Promise { - if (updates.length === 0) return { ok: true, seq: target.seq }; +export async function apply( + target: Document, + updates: Uint8Array[], + authorizeResearch?: (id: string, action: "add" | "remove") => Promise, +): Promise { + if (updates.length === 0) return { ok: true, seq: target.seq, researchProjections: [] }; + let before = parse(project(target)).children; + let researchProjections: ResearchProjectionChange[] = []; for (let update of updates) Y.applyUpdate(target.doc, update, REMOTE); await settle(); try { - project(target); + let after = parse(project(target)).children; + 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"] }; + } + if (!authorized) return { ok: false, issues: ["protected-projection"] }; + allowed.add(id); + researchProjections.push({ id, action }); + } + } + if (protectProjections(before, after, added, removed)) { + return { ok: false, issues: ["protected-projection"] }; + } } catch (err) { if (!(err instanceof PlanValidationError)) throw err; return { ok: false, issues: err.issues.map(issue => issue.code) }; } target.seq += updates.length; - return { ok: true, seq: target.seq }; + return { ok: true, seq: target.seq, researchProjections }; } /** diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index a6a448a7..1cb402d1 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -12,8 +12,20 @@ import * as Y from "yjs"; import { createHash } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import { ULID } from "@chopin/dialect"; import { MENTION } from "@chopin/protocol/address"; +import * as Question from "@chopin/question"; + +import { validateSource } from "../conversation-plan/sources"; +import { restoreState as restoreConversationPlan } from "../conversation-plan/domain"; +import { assertEventCapacity } from "../conversation-plan/events"; +import { assertOptionCapacity } from "../conversation-plan/option-capacity"; +import { type Effect, restoreEffectOutbox } from "../conversation-plan/effects"; +import * as Jobs from "../conversation-plan/jobs"; +import { restorePendingCardActions } from "../questions/card-actions"; +import { validateNotice, validateScopedNotices } from "./notice-validation"; import * as presence from "./presence"; import * as edit from "./edit"; @@ -27,14 +39,22 @@ import { claimEligibility, restoreLifecycle, transition } from "../tasks/lifecyc import { broadcast, fail, relay, reply, tell } from "../wire"; import type { Server } from "bun"; -import type { Plan as Wire, Request } from "@chopin/protocol"; +import type { ConversationPlan, Plan as Wire, Request } from "@chopin/protocol"; import type { Socket, SocketData } from "../wire"; import type { Presence } from "./presence"; import type { Document } from "./room"; import type { Block } from "./edit"; import type { Brief, CreationOrigin } from "../mcp"; -import type { InitialChannel, JsonValue, Lease, StoredChannel } from "../storage/model"; +import { researchProjectionAllowed, ResearchProjectionConflict } from "../storage/model"; +import type { + InitialChannel, + JsonValue, + Lease, + ResearchProjectionChange, + StoredChannel, +} from "../storage/model"; import type { StorageAdapter } from "../storage/port"; +import type { PendingCardAction } from "../questions/card-actions"; import type { ClaimInput, ClaimResult, Graph, Run } from "../tasks/graphs"; import type { Lifecycle, LifecycleInput, LifecycleResult } from "../tasks/lifecycle"; @@ -139,6 +159,18 @@ export type Plan = { comments: Comments.Threads; /** The conversation driving the agent. */ chat: Chat.Chat; + /** Replayable conversation-derived cards and durable inference queue. */ + conversationPlan: ConversationPlan.State; + /** Durable request IDs for explicit analysis retries. */ + conversationPlanRetries: Array<{ id: string; messageId: string }>; + /** Background Planner work retained across restarts. */ + conversationPlanJobs: ConversationPlan.Job[]; + /** Completed delivery keys and unfinished durable conversation work. */ + conversationPlanEffects: string[]; + conversationPlanPendingEffects: Effect[]; + /** Card actions waiting to be mirrored into their conversation thread. */ + pendingCardActions: PendingCardAction[]; + /** * Every questionnaire this plan has ever held, answered or not. * @@ -207,6 +239,12 @@ type Sidecar = { openQuestions: Questions.StoredOpen[]; threads: Comments.Record[]; transcript: Chat.Chat["entries"]; + conversationPlan?: ConversationPlan.State; + conversationPlanRetries?: Array<{ id: string; messageId: string }>; + conversationPlanJobs?: ConversationPlan.Job[]; + conversationPlanEffects?: string[]; + conversationPlanPendingEffects?: Effect[]; + pendingCardActions?: PendingCardAction[]; }; function state(plan: Plan): Sidecar { @@ -225,6 +263,20 @@ function state(plan: Plan): Sidecar { openQuestions: Questions.dump(plan.questions), threads: [...plan.threads.values()], transcript: plan.chat.entries, + conversationPlan: plan.conversationPlan, + ...(plan.conversationPlanRetries.length + ? { conversationPlanRetries: plan.conversationPlanRetries } + : {}), + ...(plan.conversationPlanJobs.length + ? { conversationPlanJobs: plan.conversationPlanJobs } + : {}), + ...(plan.conversationPlanEffects.length + ? { conversationPlanEffects: plan.conversationPlanEffects } + : {}), + ...(plan.conversationPlanPendingEffects.length + ? { conversationPlanPendingEffects: plan.conversationPlanPendingEffects } + : {}), + ...(plan.pendingCardActions.length ? { pendingCardActions: plan.pendingCardActions } : {}), }; } @@ -234,6 +286,8 @@ function jsonState(plan: Plan): { value: JsonValue; text: string } { } function capture(plan: Plan): Captured { + assertEventCapacity(plan.conversationPlan, plan.pendingCardActions.length); + if (plan.persistence) assertOptionCapacity(plan); let sidecar = jsonState(plan); let source = room.project(plan.document); return { @@ -344,6 +398,16 @@ function legacyCreation( return creation({ brief, origin }); } +function definitionShape(value: Question.Definition) { + return value.questions.map(question => [ + question.id, + question.header, + question.question, + question.multiple, + question.options.map(option => [option.id, option.label, option.description]), + ]); +} + function restoredState( value: JsonValue, pristine: boolean, @@ -385,6 +449,14 @@ function restoredState( if (Object.hasOwn(item, "execution")) expected.push("execution"); if (Object.hasOwn(item, "lifecycle")) expected.push("lifecycle"); if (Object.hasOwn(item, "mcpUpdates")) expected.push("mcpUpdates"); + if (Object.hasOwn(item, "conversationPlan")) expected.push("conversationPlan"); + if (Object.hasOwn(item, "conversationPlanRetries")) expected.push("conversationPlanRetries"); + if (Object.hasOwn(item, "conversationPlanJobs")) expected.push("conversationPlanJobs"); + if (Object.hasOwn(item, "conversationPlanEffects")) expected.push("conversationPlanEffects"); + if (Object.hasOwn(item, "conversationPlanPendingEffects")) { + expected.push("conversationPlanPendingEffects"); + } + if (Object.hasOwn(item, "pendingCardActions")) expected.push("pendingCardActions"); expected.sort(); if ( keys.length !== expected.length @@ -416,18 +488,24 @@ function restoredState( } let lifecycle = hasLifecycle ? restoredLifecycle : undefined; let questions = objects(item.questions, "question record"); - for (let question of questions) { - if ( - (question.status !== "open" && question.status !== "answered" - && question.status !== "cancelled") - || !question.definition - || typeof question.definition !== "object" - || Array.isArray(question.definition) - ) throw new Error("hosted channel has an invalid question record"); - } + let records = questions.map(question => Questions.normalizeRecord(question)); let openQuestions = objects(item.openQuestions, "open questionnaire"); + let recordIds = new Set(records.map(record => record.id)); let openIds = new Set(openQuestions.map(entry => entry.id as string)); - if (questions.some(record => (record.status === "open") !== openIds.has(record.id as string))) { + let definitions = new Map( + records.map(record => [record.id, Question.identified(record.definition)]), + ); + if ( + [...openIds].some(id => !recordIds.has(id)) + || records.some(record => Questions.isOpenStatus(record.status) !== openIds.has(record.id)) + || openQuestions.some(entry => { + let record = definitions.get(entry.id as string); + if (!record) return true; + let draft = Question.identified(entry.definition); + return JSON.stringify(definitionShape(record)) + !== JSON.stringify(definitionShape(draft)); + }) + ) { throw new Error("hosted channel question records disagree with their drafts"); } let threads = objects(item.threads, "comment thread"); @@ -441,6 +519,19 @@ function restoredState( ) throw new Error("hosted channel has an invalid comment thread"); } let transcript = objects(item.transcript, "transcript entry"); + let savedMessages = new Map(transcript.map(entry => [entry.id, entry])); + for (let record of records) { + for (let origin of Object.values(record.optionOrigins)) { + if (!origin.source) continue; + let message = savedMessages.get(origin.source.messageId); + if (!message) throw new Error("hosted channel has option source without a message"); + try { + validateSource(origin.source, message as Chat.Chat["entries"][number]); + } catch (err) { + throw new Error("hosted channel has invalid option source", { cause: err }); + } + } + } let referenceIds = new Set(); for (let entry of transcript) { if ( @@ -450,6 +541,7 @@ function restoredState( || typeof entry.author !== "object" || Array.isArray(entry.author) ) throw new Error("hosted channel has an invalid transcript entry"); + validateNotice(entry, savedMessages); if (Object.hasOwn(entry, "references")) { let author = entry.author as Record; if (author.kind !== "member") { @@ -473,6 +565,52 @@ function restoredState( } } let mcpUpdates = restoreMcpUpdates(item.mcpUpdates); + let conversationPlan = restoreConversationPlan( + item.conversationPlan, + transcript as unknown as Chat.Chat["entries"], + ); + validateScopedNotices(transcript as unknown as Chat.Chat["entries"], conversationPlan, records); + for (let record of records) { + for (let [optionId, origin] of Object.entries(record.optionOrigins)) { + if ( + origin.origin === "chat" && origin.source + && !conversationPlan.events.some(event => + event.type === "option.added" && event.threadId === record.threadId + && event.contribution.id === optionId + && isDeepStrictEqual(event.source, origin.source) + ) + ) { + throw new Error("hosted channel has option source outside its card thread or evidence"); + } + if (origin.source?.role !== "question") continue; + let thread = conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === record.id + ); + let option = record.definition.questions.flatMap(item => item.options) + .find(item => item.id === optionId); + if ( + !Questions.matchesQuestionSource(origin.source, thread) + || !option || !Questions.questionMentionsOption(origin.source.quote, option.label) + ) { + throw new Error( + "hosted channel has option question source outside its card thread or evidence", + ); + } + } + } + let conversationPlanRetries = restoreConversationPlanRetries(item.conversationPlanRetries); + let conversationPlanJobs = Jobs.restore(item.conversationPlanJobs); + let outbox = restoreEffectOutbox( + item.conversationPlanPendingEffects, + item.conversationPlanEffects, + ); + let pendingCardActions = restorePendingCardActions(item.pendingCardActions); + let messageIds = new Set(transcript.map(entry => entry.id as string)); + if ( + conversationPlan.queue.some(entry => !messageIds.has(entry.messageId)) + || conversationPlan.analysis.some(entry => !messageIds.has(entry.messageId)) + || conversationPlanRetries.some(entry => !messageIds.has(entry.messageId)) + ) throw new Error("hosted channel has conversation analysis without a source message"); return { version: 1, revision: item.revision, @@ -482,13 +620,43 @@ function restoredState( ...(execution ? { execution } : {}), ...(lifecycle ? { lifecycle } : {}), ...(mcpUpdates.length > 0 ? { mcpUpdates } : {}), - questions: questions as never[], + questions: records, openQuestions: openQuestions as unknown as Questions.StoredOpen[], threads: threads as never[], transcript: transcript as unknown as Chat.Chat["entries"], + conversationPlan, + ...(conversationPlanRetries.length ? { conversationPlanRetries } : {}), + ...(conversationPlanJobs.length ? { conversationPlanJobs } : {}), + ...(outbox.receipts.length ? { conversationPlanEffects: outbox.receipts } : {}), + ...(outbox.pending.length ? { conversationPlanPendingEffects: outbox.pending } : {}), + ...(pendingCardActions.length ? { pendingCardActions } : {}), }; } +function restoreConversationPlanRetries( + value: JsonValue | undefined, +): Array<{ id: string; messageId: string }> { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 4096) { + throw new Error("hosted channel has invalid conversation analysis retries"); + } + let seen = new Set(); + return value.map(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("hosted channel has invalid conversation analysis retry"); + } + let item = raw as Record; + if ( + Object.keys(item).sort().join(",") !== "id,messageId" + || typeof item.id !== "string" || !item.id || item.id.length > 250 + || typeof item.messageId !== "string" || !item.messageId || item.messageId.length > 200 + || seen.has(item.id) + ) throw new Error("hosted channel has invalid conversation analysis retry"); + seen.add(item.id); + return { id: item.id, messageId: item.messageId }; + }); +} + function restoreMcpUpdates(value: JsonValue | undefined): McpUpdateRecord[] { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error("hosted channel has invalid MCP updates"); @@ -598,6 +766,8 @@ async function commitHosted( operationId: string, captured: Captured, allowArchived = false, + researchProjections: ResearchProjectionChange[] = [], + notifyDocumentPersisted = true, ): Promise { let durable = plan.persistence; if (!update && captured.sidecarText === durable.lastSidecar) { @@ -616,6 +786,7 @@ async function commitHosted( sidecar: captured.sidecar, events: [], now: new Date(), + ...(researchProjections.length > 0 ? { researchProjections } : {}), ...(allowArchived ? { allowArchived: true } : {}), }); if (!result.repeated) { @@ -645,7 +816,7 @@ async function commitHosted( if (plan.document.epoch === captured.epoch) { plan.document.checkpoint = new Uint8Array(captured.document); } - if (update && sourceChanged && durable.onDocumentPersisted) { + if (update && sourceChanged && notifyDocumentPersisted && durable.onDocumentPersisted) { try { durable.onDocumentPersisted({ channelId: durable.channelId, @@ -659,7 +830,7 @@ async function commitHosted( } scheduleCheckpoint(plan); } catch (err) { - durable.fatal(err); + if (!(err instanceof ResearchProjectionConflict)) durable.fatal(err); throw err; } } @@ -779,6 +950,9 @@ type RestoredHosted = { document: Document; needsInitialCheckpoint: boolean; sidecar: Sidecar; + /** Exact saved sidecar, before interrupted jobs are normalized in memory. */ + persistedSidecar: JsonValue; + interruptedJobs: boolean; }; /** Prepare the sidecar half of one atomic claim for a plan that is not live. */ @@ -854,13 +1028,21 @@ async function restoreHosted(id: string, loaded: StoredChannel): Promise).conversationPlanJobs + : undefined; + let interruptedJobs = Array.isArray(persistedJobs) + && persistedJobs.some(job => (job as { status: string }).status === "running"); if (loaded.snapshot) { if ( loaded.snapshot.revision > loaded.channel.revision @@ -893,7 +1075,7 @@ async function restoreHosted(id: string, loaded: StoredChannel): Promise { let loaded = await backend.storage.collaboration.load(id, new Date()); if (!loaded) throw new Error(`channel ${id} does not exist`); - let { document, needsInitialCheckpoint, sidecar } = await restoreHosted(id, loaded); + let { document, needsInitialCheckpoint, sidecar, persistedSidecar, interruptedJobs } = + await restoreHosted(id, loaded); let plan: Plan = { id, @@ -943,8 +1126,16 @@ export async function open( questions: Questions.restore(sidecar.openQuestions), comments: Comments.create(), chat: Chat.restore(sidecar.transcript), + conversationPlan: sidecar.conversationPlan ?? restoreConversationPlan(undefined), + conversationPlanRetries: sidecar.conversationPlanRetries ?? [], + conversationPlanJobs: sidecar.conversationPlanJobs ?? [], + conversationPlanEffects: sidecar.conversationPlanEffects ?? [], + conversationPlanPendingEffects: sidecar.conversationPlanPendingEffects ?? [], + pendingCardActions: sidecar.pendingCardActions ?? [], outlines: new Map(), - records: new Map(sidecar.questions.map(record => [record.id, record])), + records: new Map( + sidecar.questions.map(record => [record.id, Questions.normalizeRecord(record)]), + ), threads: new Map(sidecar.threads.map(record => [record.id, record])), revision: sidecar.revision, graph: sidecar.graph, @@ -973,7 +1164,20 @@ export async function open( committedSidecar: committed.sidecar, closing: false, }; - if (needsInitialCheckpoint) await checkpointHosted(plan); + if (interruptedJobs) { + plan.persistence.lastSidecar = JSON.stringify(persistedSidecar); + plan.persistence.committedSidecar = persistedSidecar; + } + try { + if (interruptedJobs) await persistExclusive(plan, true); + if (needsInitialCheckpoint) await checkpointHosted(plan); + } catch (error) { + if (plan.persistence.checkpointTimer) clearTimeout(plan.persistence.checkpointTimer); + Questions.shutdown(plan.questions); + presence.destroy(plan.presence); + plan.document.doc.destroy(); + throw error; + } // Guarded because this is the last thing between a channel and being open. A // plan whose highlights are stale is worth having; one that refuses to open @@ -990,7 +1194,22 @@ export async function open( /** Cheap identity of the whole relationship snapshot, for spotting a change. */ function signature(plan: Plan): string { - return JSON.stringify([Questions.anchors(plan), Comments.anchors(plan)]); + return JSON.stringify([Questions.anchors(plan), Comments.anchors(plan), Questions.prose(plan)]); +} + +function proseOrphans(plan: Plan): Map { + return new Map(Questions.prose(plan).map(item => [item.widget, item.orphaned])); +} + +function announceProseChanges(plan: Plan, before: Map): void { + for (let item of Questions.prose(plan)) { + if (before.get(item.widget) === item.orphaned) continue; + try { + Questions.announce(plan, plan.server, plan.id, item.widget); + } catch (err) { + console.error("[plan] could not announce decided prose metadata:", err); + } + } } /** Everything a joining client needs to start from. */ @@ -1007,6 +1226,7 @@ export function greet(plan: Plan, ws: Socket, msg: Request): void revision: plan.revision, anchors: Questions.anchors(plan), threads: Comments.anchors(plan), + prose: Questions.prose(plan), limits: room.LIMITS, ...(hello ? { awareness: encode(hello) } : {}), }); @@ -1056,6 +1276,52 @@ function schedule(plan: Plan): void { }, GROUP_MS); } +async function rejectBatch(plan: Plan, batch: Queued[], issues: string[]): Promise { + console.warn("[plan] rejected batch:", issues.join(", ")); + let proseBefore = proseOrphans(plan); + + let now = Date.now(); + for (let item of batch) { + let gauge = meter(plan, item.ws); + gauge.invalid = [...recent(gauge.invalid, INVALID_WINDOW_MS), now]; + if (gauge.invalid.length >= INVALID_LIMIT) { + item.ws.close(ABUSIVE, "repeated invalid plan updates"); + } + } + + let rebuilt = await room.rebuild(plan.document); + plan.document = rebuilt; + // Cursors describe positions in a history that no longer exists. The + // agent's is in there too, and the interval repeating it would outlive + // the presence it repeats. + clearInterval(plan.attention); + plan.attention = undefined; + presence.destroy(plan.presence); + plan.presence = presence.create(); + // So do anchors and passages, and unlike a cursor nobody re-announces + // them. Without this every highlight in the room stays dark until the + // agent happens to edit. + // + // Guarded because the `plan:reset` below is what tells everyone to + // re-open. A throw here would strand the whole room on an epoch that no + // longer exists, to save some highlights that are already stale. + try { + Questions.rebase(plan); + Comments.rebase(plan); + } catch (err) { + console.error("[plan] could not carry anchors onto the rebuilt document:", err); + } + await replaceHosted(plan, `epoch:${rebuilt.epoch}`, capture(plan)); + + broadcast(plan.server, plan.id, { + kind: "plan:reset", + ts: 0, + epoch: rebuilt.epoch, + reason: "rebuilt", + }); + announceProseChanges(plan, proseBefore); +} + /** * Apply one batch. * @@ -1068,55 +1334,34 @@ async function commit(plan: Plan): Promise { if (batch.length === 0) return; plan.queue = []; - let outcome = await room.apply(plan.document, batch.map(item => item.update)); + let outcome = await room.apply( + plan.document, + batch.map(item => item.update), + async (id, action) => { + if (!ULID.test(id)) return false; + 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; + let job = jobId ? await plan.persistence.storage.jobs.get(plan.id, jobId) : undefined; + return researchProjectionAllowed( + plan.id, + { id, action }, + request?.workspace, + initial, + job?.job, + ); + }, + ); if (!outcome.ok) { - console.warn("[plan] rejected batch:", outcome.issues.join(", ")); - - let now = Date.now(); - for (let item of batch) { - let gauge = meter(plan, item.ws); - gauge.invalid = [...recent(gauge.invalid, INVALID_WINDOW_MS), now]; - if (gauge.invalid.length >= INVALID_LIMIT) { - item.ws.close(ABUSIVE, "repeated invalid plan updates"); - } - } - - let rebuilt = await room.rebuild(plan.document); - plan.document = rebuilt; - // Cursors describe positions in a history that no longer exists. The - // agent's is in there too, and the interval repeating it would outlive - // the presence it repeats. - clearInterval(plan.attention); - plan.attention = undefined; - presence.destroy(plan.presence); - plan.presence = presence.create(); - // So do anchors and passages, and unlike a cursor nobody re-announces - // them. Without this every highlight in the room stays dark until the - // agent happens to edit. - // - // Guarded because the `plan:reset` below is what tells everyone to - // re-open. A throw here would strand the whole room on an epoch that no - // longer exists, to save some highlights that are already stale. - try { - Questions.rebase(plan); - Comments.rebase(plan); - } catch (err) { - console.error("[plan] could not carry anchors onto the rebuilt document:", err); - } - await replaceHosted(plan, `epoch:${rebuilt.epoch}`, capture(plan)); - - broadcast(plan.server, plan.id, { - kind: "plan:reset", - ts: 0, - epoch: rebuilt.epoch, - reason: "rebuilt", - }); + await rejectBatch(plan, batch, outcome.issues); return; } let relationshipsChanged = false; let before = signature(plan); + let previousRecords = new Map(plan.records); + let previousThreads = new Map(plan.threads); try { Questions.rebase(plan); Comments.rebase(plan); @@ -1124,12 +1369,29 @@ async function commit(plan: Plan): Promise { } catch (err) { console.error("[plan] could not carry anchors forward:", err); } + let previousRevision = plan.revision; if (room.project(plan.document) !== plan.persistence.committedSource) plan.revision++; let merged = Y.mergeUpdates(batch.map(item => item.update)); let operationId = `plan:${plan.document.epoch}:${ createHash("sha256").update(merged).digest("hex") }`; - await commitHosted(plan, merged, operationId, capture(plan)); + try { + await commitHosted( + plan, + merged, + operationId, + capture(plan), + false, + outcome.researchProjections, + ); + } catch (err) { + if (!(err instanceof ResearchProjectionConflict)) throw err; + plan.revision = previousRevision; + plan.records = previousRecords; + plan.threads = previousThreads; + await rejectBatch(plan, batch, ["research-reference-conflict"]); + return; + } for (let item of batch) { reply(item.ws, item.rid, { @@ -1185,6 +1447,70 @@ export async function publish( } } +class ImplementationActiveError extends Error { + constructor() { + super("implementation is active"); + } +} + +/** Commit a staged document and sidecar before bringing the live room forward. */ +export async function publishStaged( + plan: Plan, + server: Server, + roomId: string, + candidate: Plan, + mutation?: room.Mutation, + options?: { notifyDocumentPersisted?: boolean }, +): Promise { + if (implementationActive(plan)) throw new ImplementationActiveError(); + let source = room.project(candidate.document); + if (!mutation && source !== room.project(plan.document)) { + throw new Error("staged document changed without a mutation"); + } + if (mutation) candidate.document.seq++; + if (source !== plan.persistence.committedSource) candidate.revision++; + let operationId = mutation + ? `server:${candidate.document.epoch}:${ + createHash("sha256").update(mutation.update).digest("hex") + }` + : `state:${crypto.randomUUID()}`; + await commitHosted( + plan, + mutation?.update, + operationId, + capture(candidate), + false, + [], + options?.notifyDocumentPersisted !== false, + ); + if (mutation) { + Y.applyUpdate(plan.document.doc, mutation.update); + await room.settle(); + } + plan.document.seq = candidate.document.seq; + plan.revision = candidate.revision; + plan.records = candidate.records; + plan.threads = candidate.threads; + plan.questions = candidate.questions; + plan.conversationPlan = candidate.conversationPlan; + plan.pendingCardActions = candidate.pendingCardActions; + plan.conversationPlanPendingEffects = candidate.conversationPlanPendingEffects; + plan.conversationPlanEffects = candidate.conversationPlanEffects; + if (mutation) { + try { + broadcast(server, roomId, { + kind: "plan:update", + ts: 0, + epoch: plan.document.epoch, + update: encode(mutation.update), + seq: plan.document.seq, + }); + } catch (err) { + console.error("[plan] could not broadcast a persisted update:", err); + } + } +} + export async function rewrite( plan: Plan, nextSource: string, @@ -1242,7 +1568,7 @@ export async function rewrite( plan.mcpUpdates.push(recorded); plan.outlines = candidate.outlines; if (changed) { - Questions.rebase(plan); + Questions.rebase(plan, before); Questions.invalidate(plan, "plan_changed"); Comments.rebase(plan); Comments.invalidate(plan, "plan_changed"); @@ -1356,13 +1682,24 @@ export function anchors( server: Server, roomId: string, ): void { + let prose = Questions.prose(plan); broadcast(server, roomId, { kind: "plan:anchors", ts: 0, epoch: plan.document.epoch, widgets: Questions.anchors(plan), threads: Comments.anchors(plan), + prose, }); + // Other post-commit callers also rebase prose; repeat metadata so none can + // publish a stale collapse state after an edit or rewrite. + for (let item of prose) { + try { + Questions.announce(plan, server, roomId, item.widget); + } catch (err) { + console.error("[plan] could not announce decided prose metadata:", err); + } + } } /** @@ -1476,6 +1813,34 @@ export function departed(plan: Plan, ws: Socket): void { }); } +/** Release a successfully opened document that has never been exposed to a room. */ +export async function abortOpening(plan: Plan): Promise { + if (plan.timer) clearTimeout(plan.timer); + clearInterval(plan.attention); + let persistence = plan.persistence; + persistence.closing = true; + if (persistence.checkpointTimer) clearTimeout(persistence.checkpointTimer); + persistence.checkpointTimer = undefined; + try { + let results = await Promise.allSettled([Chat.close(plan.chat), plan.flushing]); + let failed = results.filter(result => result.status === "rejected"); + if (failed.length === 1) throw failed[0]!.reason; + if (failed.length > 1) { + throw new AggregateError(failed.map(result => result.reason), "document cleanup failed"); + } + } finally { + try { + Questions.shutdown(plan.questions); + } finally { + try { + presence.destroy(plan.presence); + } finally { + plan.document.doc.destroy(); + } + } + } +} + /** Write anything outstanding and let go. */ export async function close(plan: Plan): Promise { if (plan.timer) clearTimeout(plan.timer); @@ -1502,3 +1867,29 @@ export function source(plan: Plan): string { export function size(plan: Plan): number { return Y.encodeStateAsUpdate(plan.document.doc).byteLength; } + +export function placeResearchReference(plan: Plan, id: string): Promise<"placed" | "deferred"> { + return exclusive(plan, async () => { + if (implementationActive(plan)) return "deferred"; + 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.insertResearch(document, id); + if (!mutation) return "placed"; + try { + await publishStaged(plan, plan.server, plan.id, { ...plan, document }, mutation); + } catch (err) { + if (err instanceof ImplementationActiveError) return "deferred"; + throw err; + } + return "placed"; + } finally { + document.doc.destroy(); + } + }); +} diff --git a/apps/server/src/plan/staged-publication-guards.test.ts b/apps/server/src/plan/staged-publication-guards.test.ts new file mode 100644 index 00000000..d380d9f6 --- /dev/null +++ b/apps/server/src/plan/staged-publication-guards.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { openPlan } from "../testing/plan"; +import * as room from "./room"; +import * as Service from "./service"; +import { staged } from "./staged-publication.test-fixtures"; + +test("staged source changes require their document mutation and respect active implementation", async () => { + let { plan, server, broadcasts } = await openPlan("# Before\n"); + let { candidate, mutation } = await staged(plan, "# After\n"); + try { + await expect(Service.publishStaged(plan, server, plan.id, candidate)) + .rejects.toThrow("staged document changed without a mutation"); + plan.claiming = true; + await expect(Service.publishStaged(plan, server, plan.id, candidate, mutation)) + .rejects.toThrow("implementation is active"); + expect(room.project(plan.document)).toBe("# Before\n"); + expect(broadcasts).toEqual([]); + } finally { + plan.claiming = false; + candidate.document.doc.destroy(); + await Service.close(plan); + } +}); + +test("staged notification opt-out preserves the default durable document callback", async () => { + let { plan, server } = await openPlan("# Before\n"); + let observed: string[] = []; + plan.persistence.onDocumentPersisted = event => { + observed.push(event.source); + }; + let first = await staged(plan, "# First\n"); + let second: Awaited> | undefined; + try { + await Service.exclusive( + plan, + () => + Service.publishStaged(plan, server, plan.id, first.candidate, first.mutation, { + notifyDocumentPersisted: false, + }), + ); + expect(observed).toEqual([]); + second = await staged(plan, "# Second\n"); + let next = second; + await Service.exclusive( + plan, + () => Service.publishStaged(plan, server, plan.id, next.candidate, next.mutation), + ); + expect(observed).toEqual(["# Second\n"]); + } finally { + first.candidate.document.doc.destroy(); + second?.candidate.document.doc.destroy(); + await Service.close(plan); + } +}); diff --git a/apps/server/src/plan/staged-publication.test-fixtures.ts b/apps/server/src/plan/staged-publication.test-fixtures.ts new file mode 100644 index 00000000..35413c9a --- /dev/null +++ b/apps/server/src/plan/staged-publication.test-fixtures.ts @@ -0,0 +1,25 @@ +import * as Y from "yjs"; +import * as edit from "./edit"; +import * as room from "./room"; +import type { Plan } from "./service"; + +export async function staged(plan: Plan, source: string) { + let document = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + document.seq = plan.document.seq; + let candidate = { + ...plan, + document, + records: new Map(plan.records), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + conversationPlanEffects: [...plan.conversationPlanEffects, "heading:document:m1"], + }; + let result = edit.replace(candidate, plan.revision, source); + if (!result.ok || !result.mutation) throw new Error("test edit must change the document"); + return { candidate, mutation: result.mutation }; +} diff --git a/apps/server/src/plan/staged-publication.test.ts b/apps/server/src/plan/staged-publication.test.ts new file mode 100644 index 00000000..98d8aec1 --- /dev/null +++ b/apps/server/src/plan/staged-publication.test.ts @@ -0,0 +1,73 @@ +import { expect, spyOn, test } from "bun:test"; +import { openPlan } from "../testing/plan"; +import * as room from "./room"; +import * as Service from "./service"; +import { staged } from "./staged-publication.test-fixtures"; + +test("staged document and receipts stay private until their fenced commit succeeds", async () => { + let context = await openPlan("# Before\n"); + let { plan, storage, server, broadcasts } = context; + let { candidate, mutation } = await staged(plan, "# After\n"); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commit = storage.collaboration.commit.bind(storage.collaboration); + let pending = spyOn(storage.collaboration, "commit").mockImplementation(async input => { + entered.resolve(); + await release.promise; + return commit(input); + }); + try { + let publishing = Service.exclusive( + plan, + () => Service.publishStaged(plan, server, plan.id, candidate, mutation), + ); + await entered.promise; + expect(room.project(plan.document)).toBe("# Before\n"); + expect(plan.conversationPlanEffects).toEqual([]); + expect(broadcasts).toEqual([]); + let saved = await storage.collaboration.load(plan.id, new Date()); + expect(saved?.sidecar).not.toHaveProperty("conversationPlanEffects"); + release.resolve(); + await publishing; + expect(room.project(plan.document)).toBe("# After\n"); + expect(plan.conversationPlanEffects).toEqual(["heading:document:m1"]); + expect(broadcasts.map(frame => frame.kind)).toEqual(["plan:update"]); + await Service.close(plan); + let reopened = await Service.open(plan.id, context.backend, server); + expect(room.project(reopened.document)).toBe("# After\n"); + expect(reopened.conversationPlanEffects).toEqual(["heading:document:m1"]); + await Service.close(reopened); + } finally { + release.resolve(); + pending.mockRestore(); + candidate.document.doc.destroy(); + } +}); + +test("failed staged commit leaves live and reopened document and receipts unchanged", async () => { + let context = await openPlan("# Before\n"); + let { plan, server, storage, broadcasts } = context; + let { candidate, mutation } = await staged(plan, "# After\n"); + let failing = spyOn(storage.collaboration, "commit").mockRejectedValue(new Error("disk full")); + try { + await expect( + Service.exclusive( + plan, + () => Service.publishStaged(plan, server, plan.id, candidate, mutation), + ), + ).rejects.toThrow("disk full"); + expect(room.project(plan.document)).toBe("# Before\n"); + expect(plan.revision).toBe(0); + expect(plan.document.seq).toBe(0); + expect(plan.conversationPlanEffects).toEqual([]); + expect(broadcasts).toEqual([]); + } finally { + failing.mockRestore(); + candidate.document.doc.destroy(); + } + await Service.close(plan); + let reopened = await Service.open(plan.id, context.backend, server); + expect(room.project(reopened.document)).toBe("# Before\n"); + expect(reopened.conversationPlanEffects).toEqual([]); + await Service.close(reopened); +}); diff --git a/apps/server/src/question-service-deferral.test-fixtures.ts b/apps/server/src/question-service-deferral.test-fixtures.ts new file mode 100644 index 00000000..816b2971 --- /dev/null +++ b/apps/server/src/question-service-deferral.test-fixtures.ts @@ -0,0 +1,111 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; + +import type { Plan } from "./plan/service"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export const d02ProposalText = "We should use the VPS for the beta."; + +export const d02DeferralText = + "I take back my VPS preference for now. Let’s verify backups before choosing hosting."; + +export const d02DeferralQuote = "Let’s verify backups before choosing hosting."; + +export function d02Source( + messageId: string, + handle: string, + text: string, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + let start = text === d02DeferralQuote ? d02DeferralText.indexOf(text) : 0; + return { + messageId, + author: { kind: "member", handle }, + quote: text, + start, + end: start + text.length, + role, + }; +} + +export function d02Settlement(plan: Plan, cardId: string) { + let record = plan.records.get(cardId); + let threadId = record?.threadId; + let thread = plan.conversationPlan.threads.find(item => item.id === threadId); + let optionId = record?.definition.questions[0]?.options[0]?.id; + if (!record || !threadId || !thread || !optionId) { + throw new Error("D02 settlement test needs a linked open card"); + } + let proposal = { + id: "d02:settle-m5", + type: "settle.suggested", + threadId, + observedThreadVersion: thread.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 5, + source: d02Source("m5", "Bram", d02ProposalText, "resolution"), + optionId, + } as unknown as ConversationPlan.Event; + let deferral = { + id: "d02:settle-deferred-m7", + type: "settle.deferred", + threadId, + observedThreadVersion: thread.version + 1, + origin: "classifier", + actor: { kind: "classifier" }, + at: 7, + proposalId: proposal.id, + source: d02Source("m7", "Bram", d02DeferralQuote, "constraint"), + } as unknown as ConversationPlan.Event; + let pendingSettle = { optionId, proposer: "Bram", messageId: "m5" }; + let transcript: Chat.Entry[] = [ + { + id: "m5", + author: { kind: "member", handle: "Bram" }, + text: d02ProposalText, + ts: 5, + }, + { + id: "m7", + author: { kind: "member", handle: "Bram" }, + text: d02DeferralText, + ts: 7, + }, + { + id: "d02:save-prompt", + author: { kind: "system" }, + text: "Ready to decide: Where should the beta be hosted?", + ts: 8, + decision: { + questionnaireId: cardId, + kind: "prompt", + generation: record.history.length, + sourceMessageIds: ["m5"], + suggestedOptionId: optionId, + }, + }, + ]; + plan.chat.entries.push(...transcript); + plan.conversationPlan = { + ...plan.conversationPlan, + events: [...plan.conversationPlan.events, proposal], + threads: plan.conversationPlan.threads.map(item => + item.id === threadId + ? { ...item, version: thread.version + 1, pendingSettle } + : item + ), + }; + return { proposal, deferral, transcript, optionId, threadId }; +} + +export function activateD02Deferral(plan: Plan, deferral: ConversationPlan.Event): void { + plan.conversationPlan = { + ...plan.conversationPlan, + events: [...plan.conversationPlan.events, deferral], + threads: plan.conversationPlan.threads.map(thread => + thread.id === deferral.threadId + ? { ...thread, version: thread.version + 1 } + : thread + ), + }; +} diff --git a/apps/server/src/question-service.test-fixtures.ts b/apps/server/src/question-service.test-fixtures.ts new file mode 100644 index 00000000..6d10ff04 --- /dev/null +++ b/apps/server/src/question-service.test-fixtures.ts @@ -0,0 +1,120 @@ +import { afterEach } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; + +import * as Service from "./plan/service"; + +import { openPlan } from "./testing/plan"; +import type { Server } from "bun"; +import type { Plan } from "./plan/service"; +import type { Socket, SocketData } from "./wire"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export function createQuestionServiceFixture( + readPlans: () => Plan[], + writePlans: (value: Plan[]) => void, +) { + let plans: Plan[] = []; + + async function opened(source = ""): Promise { + let { plan } = await openPlan(source); + plans.push(plan); + return plan; + } + + async function restart(context: Awaited>): Promise { + await Service.close(context.plan); + plans.splice(plans.indexOf(context.plan), 1); + let plan = await Service.open(context.channel.id, context.backend, context.server); + plans.push(plan); + return plan; + } + + function definition(count = 1) { + return Questions.identify({ + questions: Array.from({ length: count }, (_, index) => ({ + header: `Decision ${index + 1}`, + question: `What should decision ${index + 1} be?`, + multiple: false, + options: [{ label: "Choose this", description: "The selected option." }], + })), + }); + } + + function asking( + plan: Plan, + server: Server, + value: ReturnType, + placement?: Parameters[4], + ) { + let created = Promise.withResolvers(); + let waiting = Questions.ask(plan, server, "test", value, placement, created.resolve); + return { created: created.promise, waiting }; + } + + async function answer(plan: Plan): Promise { + for (let record of [...plan.records.values()].toReversed()) { + let item = record.definition.questions[0]!; + let opened = Store.snapshot(plan.questions, record.id); + if (!opened.open) throw new Error("question was not open"); + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "mode"]).set("choices"); + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("answer produced no patch"); + let edited = Store.edit(plan.questions, record.id, [...patch.toBinary()]); + if (!edited.open || !edited.accepted) throw new Error("could not save answer"); + let claimed = Store.claimSubmit(plan.questions, record.id, edited.revision, item.header); + if (!claimed.ok) throw new Error("could not settle question"); + Store.commit(plan.questions, claimed.claim); + } + } + + async function selectFirstOption(plan: Plan, ws: Socket, id: string): Promise { + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let question = opened.definition.questions[0]!; + let optionId = question.options[0]!.id; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([question.id, "choice"]).set(optionId); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + await Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "d02-select", + id, + patch: [...patch.toBinary()], + }); + return optionId; + } + + function member(handle = "ana") { + let frames: Array> = []; + let ws = { + data: { handle, client: `client-${handle}`, room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; + } + let cleanup = async () => { + for (let plan of plans) await Service.close(plan); + plans = []; + }; + afterEach(async () => { + plans = readPlans(); + try { + await cleanup(); + } finally { + writePlans(plans); + } + }); + return { plans, opened, restart, definition, asking, answer, selectFirstOption, member }; +} diff --git a/apps/server/src/questions-service-durability-1.test.ts b/apps/server/src/questions-service-durability-1.test.ts new file mode 100644 index 00000000..57ee250d --- /dev/null +++ b/apps/server/src/questions-service-durability-1.test.ts @@ -0,0 +1,174 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; + +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking, selectFirstOption, member } = fixture; + +// Retained durability scenarios share the existing draft-selection helper. +test("discarding an open card commits its hidden node before releasing the Planner", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let ana = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + let before = context.broadcasts.length; + + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + off(); + + expect(await asked.waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(ana.frames.at(-1)).toMatchObject({ kind: "question:discard", ok: true, resolver: "ana" }); + expect(plan.records.get(id)).toMatchObject({ status: "discarded", resolver: "ana" }); + expect(room.project(plan.document)).toContain( + `thread="${plan.records.get(id)?.threadId}" status="discarded"`, + ); + expect(Store.snapshot(plan.questions, id).open).toBe(false); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual([ + "plan:update", + "question:resolved", + "question:meta", + ]); + expect(events).toEqual([{ + kind: "discarded", + id, + threadId: plan.records.get(id)?.threadId, + actor: "ana", + }]); + let reopened = await restart(context); + expect(reopened.records.get(id)).toMatchObject({ status: "discarded", resolver: "ana" }); + expect(room.project(reopened.document)).toContain( + `thread="${reopened.records.get(id)?.threadId}" status="discarded"`, + ); + expect(Questions.meta(reopened, reopened.records.get(id)!)).toMatchObject({ + status: "discarded", + resolver: "ana", + }); +}); + +test("discarding a decided card retains its answer, prose, and original decider", async () => { + let context = await openPlan("Decision prose.\n"); + let plan = context.plan; + plans.push(plan); + let digest = room.digests(plan.document)[0]!; + let asked = asking(plan, context.server, definition(), { + revision: plan.revision, + blocks: [[{ index: 0, digest }]], + }); + await asked.created; + let id = [...plan.records.keys()][0]!; + let question = plan.records.get(id)!.definition.questions[0]!; + let option = question.options[0]!.id; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let ana = member("ana"); + await selectFirstOption(plan, ana.ws, id); + await Questions.submit(plan, context.server, "test", ana.ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: Store.get(plan.questions, id)!.revision, + }); + await asked.waiting; + let answered = plan.records.get(id)!; + let prose = room.anchorAt(plan.document, 0, room.digests(plan.document)[0]!); + let history = [{ choices: [option], owner: "earlier", at: 1 }]; + plan.records.set(id, { ...answered, prose: [prose], history }); + let source = room.project(plan.document); + let ben = member("ben"); + + await Questions.discard(plan, context.server, "test", ben.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + + let after = room.project(plan.document); + expect(ben.frames.at(-1)).toMatchObject({ ok: true, resolver: "ben" }); + expect(after).toBe(source.replace('status="decided"', 'status="discarded"')); + expect(after).toContain("Decision prose."); + expect(after).toContain(` { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let ana = member(); + let before = context.broadcasts.length; + let request = (rid: string, target = id) => + Questions.discard( + plan, + context.server, + "test", + ana.ws, + { kind: "question:discard", ts: 0, rid, id: target }, + ); + await request("first"); + await asked.waiting; + let after = context.broadcasts.length; + await request("second"); + await request("unknown", "missing"); + + expect(ana.frames.find(frame => frame.rid === "second")).toMatchObject({ + ok: false, + reason: "resolved", + status: "discarded", + resolver: "ana", + }); + expect(ana.frames.find(frame => frame.rid === "unknown")).toMatchObject({ + ok: false, + reason: "resolved", + status: "discarded", + resolver: "system", + }); + expect(after).toBeGreaterThan(before); + expect(context.broadcasts).toHaveLength(after); +}); diff --git a/apps/server/src/questions-service-durability-2.test.ts b/apps/server/src/questions-service-durability-2.test.ts new file mode 100644 index 00000000..44875166 --- /dev/null +++ b/apps/server/src/questions-service-durability-2.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; +import * as Service from "./plan/service"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; + +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { definition, asking, selectFirstOption, member } = fixture; + +// Retained durability scenarios share the existing draft-selection helper. +test("a rejected discard commit keeps the selected draft, record, revision, and waiter", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let open = Store.snapshot(plan.questions, id); + if (!open.open) throw new Error("question was not open"); + let question = open.definition.questions[0]!; + let option = question.options[0]!.id; + let ana = member(); + await selectFirstOption(plan, ana.ws, id); + let source = room.project(plan.document); + let revision = plan.revision; + let draftRevision = Store.get(plan.questions, id)!.revision; + let storedBefore = await context.storage.collaboration.load(context.channel.id, context.now); + let broadcasts = context.broadcasts.length; + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + let settled = false; + void asked.waiting.then(() => settled = true); + let original = context.storage.collaboration.commit; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + entered.resolve(); + await release.promise; + throw new Error("storage unavailable"); + }; + try { + let pending = Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "failed", + id, + }); + await entered.promise; + expect(ana.frames.find(frame => frame.rid === "failed")).toBeUndefined(); + expect(room.project(plan.document)).toBe(source); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.revision).toBe(draftRevision); + release.resolve(); + await pending; + } finally { + release.resolve(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + off(); + } + + expect(ana.frames.find(frame => frame.rid === "failed")).toMatchObject({ + kind: "question:discard", + ok: false, + reason: "resolving", + }); + expect(plan.revision).toBe(revision); + expect(room.project(plan.document)).toBe(source); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.revision).toBe(draftRevision); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect(Question.read(Store.get(plan.questions, id)!.model, open.definition)[question.id]!.choice) + .toBe(option); + expect(settled).toBe(false); + expect(events).toEqual([]); + expect(context.broadcasts).toHaveLength(broadcasts); + let storedAfter = await context.storage.collaboration.load(context.channel.id, context.now); + expect(storedAfter).toEqual(storedBefore); + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "retry", + id, + }); + expect(ana.frames.find(frame => frame.rid === "retry")).toMatchObject({ ok: true }); + expect(await asked.waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); +}); + +test("a queued discard reads the decision committed ahead of it", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let open = Store.snapshot(plan.questions, id); + if (!open.open) throw new Error("question was not open"); + let question = open.definition.questions[0]!; + let option = question.options[0]!.id; + let ana = member("ana"); + let ben = member("ben"); + await selectFirstOption(plan, ana.ws, id); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + let submitting = Questions.submit(plan, context.server, "test", ana.ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: Store.get(plan.questions, id)!.revision, + }); + let discarding = Questions.discard(plan, context.server, "test", ben.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + try { + expect(ben.frames.find(frame => frame.rid === "discard")).toBeUndefined(); + } finally { + release.resolve(); + await Promise.all([blocker, submitting, discarding]); + off(); + } + + expect(await asked.waiting).toMatchObject([{ status: "answered", resolver: "ana" }]); + expect(ana.frames.find(frame => frame.rid === "save")).toMatchObject({ ok: true }); + expect(ben.frames.find(frame => frame.rid === "discard")).toMatchObject({ ok: true }); + expect(plan.records.get(id)).toMatchObject({ + status: "discarded", + owner: "ana", + resolver: "ben", + }); + expect(room.project(plan.document)).toContain(` frame.kind === "question:resolved")).toHaveLength(1); + expect(events.map(event => event.kind)).toEqual(["decided", "discarded"]); +}); diff --git a/apps/server/src/questions-service-durability-3.test.ts b/apps/server/src/questions-service-durability-3.test.ts new file mode 100644 index 00000000..11dcb034 --- /dev/null +++ b/apps/server/src/questions-service-durability-3.test.ts @@ -0,0 +1,213 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; +import * as Service from "./plan/service"; + +import { openPlan, storedQuestion } from "./testing/plan"; + +import type { Plan } from "./plan/service"; + +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { definition, asking, member } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("an implementation started ahead of a queued discard leaves the card open", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let source = room.project(plan.document); + let revision = plan.revision; + let broadcasts = context.broadcasts.length; + let ana = member(); + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + plan.execution = { id: "run-1" } as never; + }); + await entered.promise; + let discarding = Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "blocked", + id, + }); + try { + expect(ana.frames.find(frame => frame.rid === "blocked")).toBeUndefined(); + } finally { + release.resolve(); + await Promise.all([blocker, discarding]); + } + + expect(ana.frames.find(frame => frame.rid === "blocked")).toMatchObject({ + kind: "session:error", + message: "implementation is active", + }); + expect(room.project(plan.document)).toBe(source); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.snapshot(plan.questions, id).open).toBe(true); + expect(plan.revision).toBe(revision); + expect(context.broadcasts).toHaveLength(broadcasts); + plan.execution = undefined; + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "cleanup", + id, + }); + await asked.waiting; +}); + +test("a reopened card can be discarded while its draft is live", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + await Service.exclusive(plan, async () => { + plan.records.set(id, { ...plan.records.get(id)!, status: "reopened" }); + let mutation = room.projectCard(plan.document, id, { status: "reopened" }); + if (!mutation) throw new Error("card projection did not change"); + await Service.publish(plan, context.server, "test", mutation); + }); + let ana = member(); + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + + expect(ana.frames.at(-1)).toMatchObject({ ok: true, resolver: "ana" }); + expect(await asked.waiting).toEqual([{ status: "cancelled", resolver: "ana" }]); + expect(plan.records.get(id)?.status).toBe("discarded"); + expect(room.project(plan.document)).toContain('status="discarded"'); +}); + +test("discard refuses a missing document projection without changing the record or draft", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + await Service.exclusive(plan, async () => { + let mutation = room.removeQuestionnaire(plan.document, id); + if (!mutation) throw new Error("card was not in the document"); + await Service.publish(plan, context.server, "test", mutation); + }); + let record = plan.records.get(id)!; + let revision = plan.revision; + let broadcasts = context.broadcasts.length; + let ana = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(plan, event => events.push(event)); + await Questions.discard(plan, context.server, "test", ana.ws, { + kind: "question:discard", + ts: 0, + rid: "missing", + id, + }); + off(); + + expect(ana.frames.at(-1)).toMatchObject({ + kind: "question:discard", + ok: false, + reason: "resolving", + }); + expect(plan.records.get(id)).toBe(record); + expect(Store.snapshot(plan.questions, id).open).toBe(true); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect(plan.revision).toBe(revision); + expect(context.broadcasts).toHaveLength(broadcasts); + expect(events).toEqual([]); + void asked.waiting; +}); + +test("legacy question records gain card defaults on restore", async () => { + let value = definition(); + let { plan } = await openPlan("", { + questions: [{ + id: "w1", + definition: value, + status: "answered", + answers: {}, + resolver: "ana", + at: 1, + }], + }); + plans.push(plan); + expect(plan.records.get("w1")).toMatchObject({ + status: "answered", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + owner: "ana", + decidedAt: 1, + }); +}); + +test("restore rejects orphaned or duplicate open drafts and records", async () => { + let value = definition(); + let single = Question.decision(value); + let record = { id: "w1", definition: value, status: "open" }; + let draft = { + id: "w1", + definition: single, + widget: "w1", + model: storedQuestion(single), + revision: 0, + }; + await expect(openPlan("", { questions: [], openQuestions: [draft] })).rejects.toThrow( + "question records disagree with their drafts", + ); + await expect(openPlan("", { questions: [record, record], openQuestions: [draft] })) + .rejects.toThrow("invalid or duplicate question record id"); + await expect(openPlan("", { questions: [record], openQuestions: [draft, draft] })) + .rejects.toThrow("invalid or duplicate open questionnaire id"); +}); + +test("a reopened record restores only with its open draft", async () => { + let value = definition(); + let single = Question.decision(value); + let record = { + id: "w1", + definition: value, + status: "reopened", + origin: "conversation", + history: [{ choices: [value.questions[0]!.options[0]!.id], owner: "ana", at: 1 }], + optionOrigins: {}, + editors: [], + }; + let { plan } = await openPlan("", { + questions: [record], + openQuestions: [{ + id: "w1", + definition: single, + widget: "w1", + model: storedQuestion(single), + revision: 0, + }], + }); + plans.push(plan); + expect(plan.records.get("w1")?.status).toBe("reopened"); + expect(Store.get(plan.questions, "w1")).toBeDefined(); + await expect(openPlan("", { questions: [record] })).rejects.toThrow( + "question records disagree with their drafts", + ); +}); diff --git a/apps/server/src/questions-service-durability-4.test.ts b/apps/server/src/questions-service-durability-4.test.ts new file mode 100644 index 00000000..c4cfac8b --- /dev/null +++ b/apps/server/src/questions-service-durability-4.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; +import type { Server } from "bun"; +import type { Plan } from "./plan/service"; +import type { SocketData } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { opened, definition, asking } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("present malformed card provenance is refused on restore", async () => { + let base = { + id: "w1", + definition: definition(), + status: "answered", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }; + let invalid: Array<[string, object]> = [ + ["unknown status", { status: "maybe" }], + ["origin", { origin: "unknown" }], + ["null origin", { origin: null }], + ["history", { history: null }], + ["history entry", { history: [{ choices: [], owner: "ana" }] }], + ["option origins", { optionOrigins: [] }], + ["option origin entry", { + optionOrigins: { + [base.definition.questions[0]!.options[0]!.id]: { + origin: "unknown", + }, + }, + }], + ["option origin rationale", { + optionOrigins: { + [base.definition.questions[0]!.options[0]!.id]: { + origin: "planner", + rationale: [], + }, + }, + }], + ["editors", { editors: ["ana", null] }], + ]; + for (let [, fields] of invalid) { + await expect(openPlan("", { questions: [{ ...base, ...fields }] })).rejects.toThrow( + "invalid question record", + ); + } +}); + +test("a card record restores its chosen IDs and provenance", async () => { + let value = definition(); + let option = value.questions[0]!.options[0]!.id; + let threadId = "t".repeat(200); + let { plan } = await openPlan("", { + questions: [{ + id: "w1", + definition: value, + status: "answered", + origin: "conversation", + threadId, + owner: "ben", + decidedAt: 2, + choices: [option], + history: [{ choices: [option], owner: "ana", at: 1 }], + optionOrigins: { [option]: { origin: "human", by: "ana" } }, + editors: ["ana", "ben"], + }], + }); + plans.push(plan); + expect(plan.records.get("w1")).toMatchObject({ + origin: "conversation", + threadId, + owner: "ben", + decidedAt: 2, + choices: [option], + history: [{ choices: [option], owner: "ana", at: 1 }], + optionOrigins: { [option]: { origin: "human", by: "ana" } }, + editors: ["ana", "ben"], + }); +}); + +test("stored card choices and metadata reject unknown, duplicate or malformed values", async () => { + let value = definition(); + let option = value.questions[0]!.options[0]!.id; + let base = { + id: "w1", + definition: value, + status: "answered", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + }; + let invalid: Array<[string, object]> = [ + ["malformed definition cardinality", { + definition: { + questions: [{ ...value.questions[0]!, multiple: "no" }], + }, + }], + ["unknown chosen option", { choices: ["missing"] }], + ["duplicate or excessive chosen options", { choices: [option, option] }], + ["malformed choices", { choices: null }], + ["unknown historical option", { history: [{ choices: ["missing"], owner: "ana", at: 1 }] }], + ["duplicate historical choices", { + history: [{ choices: [option, option], owner: "ana", at: 1 }], + }], + ["unknown option origin", { optionOrigins: { missing: { origin: "chat" } } }], + ["duplicate editors", { editors: ["ana", "ana"] }], + ["milliseconds, not Unix seconds", { decidedAt: 1_790_334_980_000 }], + ["nonfinite history timestamp", { history: [{ choices: [], owner: "ana", at: Infinity }] }], + ["invalid owner", { owner: null }], + ["invalid thread", { threadId: [] }], + ["thread too long for a card", { threadId: "x".repeat(201) }], + ["invalid prose anchor", { prose: [{ epoch: "e", position: "p", digest: "bad" }] }], + ["malformed prose position", { + prose: [{ epoch: "e", position: "p", digest: `sha256:${"0".repeat(64)}` }], + }], + ]; + for (let [, fields] of invalid) { + await expect(openPlan("", { questions: [{ ...base, ...fields }] })).rejects.toThrow( + "invalid question record", + ); + } +}); + +test("a batched ask creates independently addressed decision records and nodes", async () => { + let plan = await opened(); + let definition = Questions.identify({ + questions: [ + { + header: "Storage", + question: "Where should room state live?", + multiple: false, + options: [{ label: "MDX on disk", description: "Readable." }], + }, + { + header: "Scope", + question: "What belongs in the first cut?", + multiple: true, + options: [{ label: "Anchors", description: "Link prose." }], + }, + ], + }); + + let server = { publish() {} } as unknown as Server; + let asked = asking(plan, server, definition); + await asked.created; + let records = [...plan.records.values()]; + + expect(records).toHaveLength(2); + expect(records.map(record => ({ + origin: record.origin, + history: record.history, + optionOrigins: record.optionOrigins, + editors: record.editors, + }))).toEqual([ + { origin: "planner", history: [], optionOrigins: {}, editors: [] }, + { origin: "planner", history: [], optionOrigins: {}, editors: [] }, + ]); + expect(records.map(record => record.definition.questions)).toEqual([ + [definition.questions[0]], + [definition.questions[1]], + ]); + expect(room.project(plan.document).match(/ plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking, selectFirstOption, member } = fixture; + +// Retained durability scenarios share the existing draft-selection helper. +test("submitting a chosen option projects its id and preserves it after restart", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let record = [...plan.records.values()][0]!; + let question = record.definition.questions[0]!; + let { ws, frames } = member(); + await selectFirstOption(plan, ws, record.id); + let revision = Store.get(plan.questions, record.id)!.revision; + await Questions.submit(plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id: record.id, + revision, + }); + await asked.waiting; + + let projected = ` frame.rid === "save")).toMatchObject({ ok: true }); + expect(room.project(plan.document)).toContain(projected); + let reopened = await restart(context); + expect(room.project(reopened.document)).toContain(projected); +}); + +test("a current-revision manual Save is refused before the deferred draft is claimed", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let actor = member("Bram"); + let optionId = await selectFirstOption(plan, actor.ws, id); + let seed = d02Settlement(plan, id); + activateD02Deferral(plan, seed.deferral); + await Service.persist(plan); + let source = room.project(plan.document); + let events = structuredClone(plan.conversationPlan.events); + let transcript = structuredClone(plan.chat.entries); + let storedBefore = await context.storage.collaboration.load(context.channel.id, context.now); + let revision = Store.get(plan.questions, id)!.revision; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + let completed = false; + let submitting = Questions.submit(plan, context.server, "test", actor.ws, { + kind: "question:submit", + ts: 0, + rid: "deferred-manual-save", + id, + revision, + }).then(() => completed = true); + try { + await Bun.sleep(20); + expect(completed).toBe(true); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect(actor.frames.find(frame => frame.rid === "deferred-manual-save")).toMatchObject({ + kind: "question:submit", + ok: false, + reason: "invalid", + message: expect.stringMatching(/paused pending verification/i), + }); + } finally { + release.resolve(); + await Promise.all([blocker, submitting]); + } + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + plan.records.get(id)!.definition, + )[plan.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBe(optionId); + expect(room.project(plan.document)).toBe(source); + expect(plan.conversationPlan.events).toEqual(events); + expect(plan.chat.entries).toEqual(transcript); + expect(await context.storage.collaboration.load(context.channel.id, context.now)).toEqual( + storedBefore, + ); +}); + +test("a deferral committed ahead of a claimed submit restores the selected draft", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let actor = member("Bram"); + let optionId = await selectFirstOption(plan, actor.ws, id); + let seed = d02Settlement(plan, id); + await Service.persist(plan); + let source = room.project(plan.document); + let transcript = structuredClone(plan.chat.entries); + let revision = Store.get(plan.questions, id)!.revision; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let blocker = Service.exclusive(plan, async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + let deferring = Service.exclusive(plan, async () => { + activateD02Deferral(plan, seed.deferral); + await Service.persistExclusive(plan); + }); + let submitting = Questions.submit(plan, context.server, "test", actor.ws, { + kind: "question:submit", + ts: 0, + rid: "racing-save", + id, + revision, + }); + let eventCount = plan.conversationPlan.events.length; + try { + await Bun.sleep(20); + expect(Store.get(plan.questions, id)?.claim).toBe("submit"); + } finally { + release.resolve(); + await Promise.all([blocker, deferring, submitting]); + } + let reply = actor.frames.find(frame => frame.rid === "racing-save"); + expect(reply).toMatchObject({ + kind: "question:submit", + ok: false, + reason: "invalid", + message: expect.stringMatching(/paused pending verification/i), + }); + expect(plan.records.get(id)?.status).toBe("open"); + expect(Store.get(plan.questions, id)?.claim).toBeUndefined(); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + plan.records.get(id)!.definition, + )[plan.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBe(optionId); + expect(room.project(plan.document)).toBe(source); + expect(plan.conversationPlan.events).toHaveLength(eventCount + 1); + expect(plan.conversationPlan.events.at(-1)).toMatchObject({ + id: seed.deferral.id, + proposalId: seed.proposal.id, + source: expect.objectContaining({ + messageId: "m7", + quote: d02DeferralQuote, + role: "constraint", + }), + }); + expect(plan.chat.entries).toEqual(transcript); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + expect(stored?.sidecar).toMatchObject({ + conversationPlan: { + events: expect.arrayContaining([ + expect.objectContaining({ + id: seed.deferral.id, + proposalId: seed.proposal.id, + }), + ]), + }, + }); +}); diff --git a/apps/server/src/questions-service-durability-6.test.ts b/apps/server/src/questions-service-durability-6.test.ts new file mode 100644 index 00000000..b7c00f49 --- /dev/null +++ b/apps/server/src/questions-service-durability-6.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./questions/service"; + +import * as room from "./plan/room"; + +import { replay } from "./conversation-plan/domain"; +import { buildTargetingRequest } from "./conversation-plan/questions"; +import { extractQuotes } from "./conversation-plan/quotes"; +import { openPlan } from "./testing/plan"; +import type { Server } from "bun"; +import type { Plan } from "./plan/service"; +import type { Socket, SocketData } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { opened, restart, definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("Planner ask links each card to a durable, targetable thread with its original options", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let value = definition(2); + let asked = asking(plan, context.server, value); + await asked.created; + + let records = [...plan.records.values()]; + expect(records).toHaveLength(2); + expect(plan.conversationPlan.threads).toHaveLength(2); + for (let [index, record] of records.entries()) { + let question = value.questions[index]!; + let thread = plan.conversationPlan.threads[index]!; + expect(record.threadId).toBe(thread.id); + expect(thread).toMatchObject({ + question: question.question, + questionSources: [], + questionAuthoring: "scribe", + questionnaireId: record.id, + status: "exploring", + }); + expect(thread.contributions.map(option => ({ + id: option.id, + text: option.text, + sources: option.sources, + }))).toEqual(question.options.map(option => ({ + id: option.id, + text: option.label, + sources: [], + }))); + expect(room.project(plan.document)).toContain(`thread="${thread.id}"`); + } + let quoted = value.questions[0]!.options[0]!.label; + let message = { + id: "exact-option-quote", + author: { kind: "member" as const, handle: "ana" }, + text: `I support ${quoted}.`, + ts: 1, + }; + let request = buildTargetingRequest( + message, + [], + plan.conversationPlan.threads, + extractQuotes(message.text), + ); + let criteria = (request.questions.c0_option as { criteria: Record }).criteria; + expect(criteria[value.questions[0]!.options[0]!.id]).toContain(quoted); + expect( + (request.questions.c0_thread as { criteria: Record }) + .criteria[records[0]!.threadId!], + ).toContain(value.questions[0]!.question); + + let reopened = await restart(context); + expect( + [...reopened.records.values()].map(record => ({ id: record.id, threadId: record.threadId })), + ) + .toEqual(records.map(record => ({ id: record.id, threadId: record.threadId }))); + expect(reopened.conversationPlan).toEqual(plan.conversationPlan); + expect(replay(reopened.conversationPlan.events).threads).toEqual( + reopened.conversationPlan.threads, + ); + expect(room.project(reopened.document)).toEqual(room.project(plan.document)); +}); + +test("an active implementation leaves an open questionnaire in the plan when discarding is refused", async () => { + let plan = await opened(); + let server = { publish() {} } as unknown as Server; + let asked = asking(plan, server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let replies: Array<{ kind: string; message?: string; rid?: string; ts?: number }> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + } as unknown as Socket; + plan.execution = { id: "run-1" } as never; + + await Questions.discard(plan, server, "test", ws, { + kind: "question:discard", + ts: 0, + rid: "cancel", + id, + }); + + expect(replies).toEqual([{ + kind: "session:error", + message: "implementation is active", + ts: expect.any(Number), + rid: "cancel", + }]); + expect(plan.records.get(id)?.status).toBe("open"); + expect(room.project(plan.document)).toContain(" { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let record = [...plan.records.values()][0]!; + plan.records.set(record.id, { ...record, status: "reopened" }); + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(record.id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "grow", + id: record.id, + label: "GitHub Apps", + }); + expect(frames.find(frame => frame.kind === "question:option")).toMatchObject({ + ok: true, + id: record.id, + }); + expect(plan.records.get(record.id)?.status).toBe("reopened"); + await Questions.discard(plan, context.server, "test", ws, { + kind: "question:discard", + ts: 0, + rid: "cleanup", + id: record.id, + }); + await asked.waiting; +}); diff --git a/apps/server/src/questions-service-durability-7.test.ts b/apps/server/src/questions-service-durability-7.test.ts new file mode 100644 index 00000000..9c447685 --- /dev/null +++ b/apps/server/src/questions-service-durability-7.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; +import type { Socket } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("adding an option changes the record, document, and durable state before publication", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish(_topic: string, raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let original = context.storage.collaboration.commit; + let release = Promise.withResolvers(); + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + await release.promise; + return original(input); + }; + let oldSource = room.project(plan.document); + let oldRevision = plan.revision; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let item = opened.definition.questions[0]; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let pending = Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "add", + id, + label: "GitHub Apps", + }); + await Bun.sleep(20); + let editing = Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select", + id, + patch: [...patch.toBinary()], + }); + await Bun.sleep(10); + try { + expect(frames).toHaveLength(0); + expect(context.broadcasts.filter(frame => frame.kind === "question:option-added")).toHaveLength( + 0, + ); + expect(room.project(plan.document)).toBe(oldSource); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.revision).toBe(oldRevision); + } finally { + release.resolve(); + } + await Promise.all([pending, editing]); + (context.storage.collaboration as { commit: typeof original }).commit = original; + + let response = frames.find(frame => frame.kind === "question:option"); + let changed = context.broadcasts.find(frame => frame.kind === "question:option-added"); + let update = context.broadcasts.findLast(frame => frame.kind === "plan:update"); + let options = Store.get(plan.questions, id)!.definition.questions[0].options; + expect(response).toMatchObject({ ok: true, option: { label: "GitHub Apps" } }); + expect(frames.find(frame => frame.rid === "select")).toMatchObject({ + open: true, + accepted: true, + applied: true, + revision: 1, + }); + expect(changed).toMatchObject({ id, option: { label: "GitHub Apps" } }); + expect(update).toBeDefined(); + expect(options.map(option => option.label)).toEqual(["Choose this", "GitHub Apps"]); + expect(plan.records.get(id)?.definition.questions[0].options).toEqual(options); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + Store.get(plan.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + let liveSource = room.project(plan.document); + expect(liveSource).toContain('label="GitHub Apps"'); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toEqual(options); + expect( + Question.read( + Store.get(reopened.questions, id)!.model, + Store.get(reopened.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + expect(room.project(reopened.document)).toBe(liveSource); +}); + +test("two competing adds with the same label commit one option", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + + await Promise.all([ + Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-2", + ts: 0, + rid: "first", + id, + label: "GitHub Apps", + }), + Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-3", + ts: 0, + rid: "second", + id, + label: "github apps", + }), + ]); + + let options = Store.get(plan.questions, id)!.definition.questions[0].options; + expect(frames.find(frame => frame.rid === "first")).toMatchObject({ ok: true }); + expect(frames.find(frame => frame.rid === "second")).toMatchObject({ + ok: false, + reason: "duplicate", + }); + expect(options.map(option => option.label)).toEqual(["Choose this", "GitHub Apps"]); + expect(Store.get(plan.questions, id)?.revision).toBe(0); + expect(context.broadcasts.filter(frame => frame.kind === "question:option-added")).toHaveLength( + 1, + ); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toEqual(options); +}); diff --git a/apps/server/src/questions-service-durability-8.test.ts b/apps/server/src/questions-service-durability-8.test.ts new file mode 100644 index 00000000..daed732a --- /dev/null +++ b/apps/server/src/questions-service-durability-8.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; +import * as Service from "./plan/service"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; +import type { Socket } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { restart, definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("a failed option commit leaves the live and durable card at their prior shape", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish(_topic: string, raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let source = room.project(plan.document); + let revision = plan.revision; + let broadcasts = context.broadcasts.length; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let item = opened.definition.questions[0]; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let original = context.storage.collaboration.commit; + let release = Promise.withResolvers(); + let commits = 0; + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + if (++commits === 1) { + await release.promise; + throw new Error("storage unavailable"); + } + return original(input); + }; + try { + let adding = Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-retry", + ts: 0, + rid: "failed", + id, + label: "GitHub Apps", + }); + await Bun.sleep(20); + let editing = Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "select-after-failure", + id, + patch: [...patch.toBinary()], + }); + await Bun.sleep(10); + expect(frames).toHaveLength(0); + release.resolve(); + await Promise.all([adding, editing]); + } finally { + release.resolve(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + + expect(frames.find(frame => frame.rid === "failed")).toMatchObject({ kind: "session:error" }); + expect(frames.find(frame => frame.rid === "select-after-failure")).toMatchObject({ + open: true, + accepted: true, + applied: true, + }); + expect(context.broadcasts.slice(broadcasts).map(frame => frame.kind)).toEqual(["question:meta"]); + expect(plan.revision).toBe(revision); + expect(room.project(plan.document)).toBe(source); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + Store.get(plan.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + let reopened = await restart(context); + expect(room.project(reopened.document)).toBe(source); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect( + Question.read( + Store.get(reopened.questions, id)!.model, + Store.get(reopened.questions, id)!.definition, + )[item.id]!.choice, + ).toBe(item.options[0]!.id); + await Questions.addOption(reopened, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-retry", + ts: 0, + rid: "retry", + id, + label: "GitHub Apps", + }); + expect(frames.find(frame => frame.rid === "retry")).toMatchObject({ ok: true }); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toHaveLength(2); +}); + +test("a failed draft commit leaves the selection unacknowledged and unchanged", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question was not open"); + let item = opened.definition.questions[0]; + let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) + .fork() as unknown as Question.Model; + model.api.val([item.id, "choice"]).set(item.options[0]!.id); + let patch = model.api.flush(); + if (!patch) throw new Error("selection produced no patch"); + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await Questions.edit(plan, ws, { + kind: "question:edit", + ts: 0, + rid: "edit-failed", + id, + patch: [...patch.toBinary()], + }); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + + expect(frames).toMatchObject([{ kind: "session:error", rid: "edit-failed" }]); + expect(Store.get(plan.questions, id)?.revision).toBe(0); + expect( + Question.read( + Store.get(plan.questions, id)!.model, + Store.get(plan.questions, id)!.definition, + )[item.id]!.choice, + ).toBeNull(); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.revision).toBe(0); + expect( + Question.read( + Store.get(reopened.questions, id)!.model, + Store.get(reopened.questions, id)!.definition, + )[item.id]!.choice, + ).toBeNull(); +}); + +test("adding to a card removed from the document leaves its record and draft unchanged", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + await Service.exclusive(plan, async () => { + let mutation = room.removeQuestionnaire(plan.document, id); + if (!mutation) throw new Error("card was not in the document"); + await Service.publish(plan, context.server, "test", mutation); + }); + let source = room.project(plan.document); + let revision = Store.get(plan.questions, id)!.revision; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-retry", + ts: 0, + rid: "missing", + id, + label: "GitHub Apps", + }); + + expect(frames).toMatchObject([{ kind: "session:error", rid: "missing" }]); + expect(Store.get(plan.questions, id)?.revision).toBe(revision); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + expect(room.project(plan.document)).toBe(source); + let reopened = await restart(context); + expect(Store.get(reopened.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(room.project(reopened.document)).toBe(source); +}); diff --git a/apps/server/src/questions-service-durability-9.test.ts b/apps/server/src/questions-service-durability-9.test.ts new file mode 100644 index 00000000..6ca97bfd --- /dev/null +++ b/apps/server/src/questions-service-durability-9.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./questions/service"; +import * as Store from "./questions/store"; +import * as room from "./plan/room"; + +import { openPlan } from "./testing/plan"; + +import type { Plan } from "./plan/service"; +import type { Socket } from "./wire"; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; + +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { definition, asking } = fixture; + +// Retained durability scenarios adapted to the keyed shared-option protocol. +test("adding to a card whose authoritative record is closed is refused", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let record = plan.records.get(id)!; + plan.records.set(id, { ...record, status: "cancelled" }); + let source = room.project(plan.document); + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + + try { + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "closed", + id, + label: "GitHub Apps", + }); + + expect(frames).toMatchObject([{ ok: false, reason: "resolved", rid: "closed" }]); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + expect(room.project(plan.document)).toBe(source); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + expect(JSON.stringify(stored?.sidecar)).not.toContain("GitHub Apps"); + } finally { + plan.records.set(id, record); + } +}); + +test("non-string, empty and overlong option labels are refused without a mutation", async () => { + let context = await openPlan(); + let plan = context.plan; + plans.push(plan); + let asked = asking(plan, context.server, definition()); + await asked.created; + let id = [...plan.records.keys()][0]!; + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + } as unknown as Socket; + let invalid = [ + 123, + ["Nested"], + { text: "Object" }, + " ", + "x".repeat(Question.limits.MAX_LABEL + 1), + ]; + for (let [index, label] of invalid.entries()) { + await Questions.addOption(plan, context.server, "test", ws, { + kind: "question:option", + question: plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-2", + ts: 0, + rid: `invalid-${index}`, + id, + label: label as unknown as string, + }); + } + + expect(frames).toHaveLength(invalid.length); + for (let frame of frames) expect(frame).toMatchObject({ ok: false, reason: "invalid" }); + expect(Store.get(plan.questions, id)?.definition.questions[0].options).toHaveLength(1); + expect(plan.records.get(id)?.definition.questions[0].options).toHaveLength(1); + expect(context.broadcasts.filter(frame => frame.kind === "question:option-added")).toHaveLength( + 0, + ); +}); diff --git a/apps/server/src/questions.anchors.test.ts b/apps/server/src/questions.anchors.test.ts index 6b57db49..1946ad76 100644 --- a/apps/server/src/questions.anchors.test.ts +++ b/apps/server/src/questions.anchors.test.ts @@ -51,7 +51,7 @@ function stored(anchors?: object) { header: "Cache", question: "How long do we cache?", multiple: false, - options: [], + options: [{ id: "cache-60", label: "60 seconds", description: "" }], }], }, answers: { q1: "60 seconds" }, diff --git a/apps/server/src/questions.service.test.ts b/apps/server/src/questions.service.test.ts index 1924319e..5eb851a5 100644 --- a/apps/server/src/questions.service.test.ts +++ b/apps/server/src/questions.service.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import * as Question from "@chopin/question"; @@ -6,65 +6,18 @@ import * as Questions from "./questions/service"; import * as Store from "./questions/store"; import * as room from "./plan/room"; import * as Service from "./plan/service"; -import { openPlan } from "./testing/plan"; import type { Server } from "bun"; import type { Plan } from "./plan/service"; import type { Socket, SocketData } from "./wire"; -let plans: Plan[] = []; - -afterEach(async () => { - for (let plan of plans) await Service.close(plan); - plans = []; +import { createQuestionServiceFixture } from "./question-service.test-fixtures"; +let plans: Plan[]; +let fixture = createQuestionServiceFixture(() => plans, value => { + plans = value; }); - -async function opened(source = ""): Promise { - let { plan } = await openPlan(source); - plans.push(plan); - return plan; -} - -function definition(count = 1) { - return Questions.identify({ - questions: Array.from({ length: count }, (_, index) => ({ - header: `Decision ${index + 1}`, - question: `What should decision ${index + 1} be?`, - multiple: false, - options: [{ label: "Choose this", description: "The selected option." }], - })), - }); -} - -function asking( - plan: Plan, - server: Server, - value: ReturnType, - placement?: Parameters[4], -) { - let created = Promise.withResolvers(); - let waiting = Questions.ask(plan, server, "test", value, placement, created.resolve); - return { created: created.promise, waiting }; -} - -async function answer(plan: Plan): Promise { - for (let record of [...plan.records.values()].toReversed()) { - let item = record.definition.questions[0]!; - let opened = Store.snapshot(plan.questions, record.id); - if (!opened.open) throw new Error("question was not open"); - let model = Question.crdt.Model.fromBinary(new Uint8Array(opened.model)) - .fork() as unknown as Question.Model; - model.api.val([item.id, "mode"]).set("choices"); - model.api.val([item.id, "choice"]).set(item.options[0]!.id); - let patch = model.api.flush(); - if (!patch) throw new Error("answer produced no patch"); - let edited = Store.edit(plan.questions, record.id, [...patch.toBinary()]); - if (!edited.open || !edited.accepted) throw new Error("could not save answer"); - let claimed = Store.claimSubmit(plan.questions, record.id, edited.revision, item.header); - if (!claimed.ok) throw new Error("could not settle question"); - Store.commit(plan.questions, claimed.claim); - } -} +plans = fixture.plans; +let { opened, definition, asking, answer } = fixture; test("a batched ask creates independently addressed decision records and nodes", async () => { let plan = await opened(); diff --git a/apps/server/src/questions/anchors.test.ts b/apps/server/src/questions/anchors.test.ts index bd79607d..f0c4005b 100644 --- a/apps/server/src/questions/anchors.test.ts +++ b/apps/server/src/questions/anchors.test.ts @@ -27,6 +27,10 @@ function record(over: Partial = {}): Question { return { id: "w1", status: "open", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], definition: { questions: [ { id: "q1", header: "Cache", question: "Where do we cache?", multiple: false, options: [] }, diff --git a/apps/server/src/questions/card-insertion.ts b/apps/server/src/questions/card-insertion.ts new file mode 100644 index 00000000..e907f63c --- /dev/null +++ b/apps/server/src/questions/card-insertion.ts @@ -0,0 +1,133 @@ +import { ULID, ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; +import { isDeepStrictEqual } from "node:util"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { validateSource } from "../conversation-plan/sources"; +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { ConversationPlan } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function insertConversationCard( + plan: Plan, + server: Server, + roomId: string, + input: { + threadId: string; + header: string; + question: string; + options: Array<{ id: string; label: string; source?: ConversationPlan.SourceRef }>; + }, +): Promise { + let found = [...plan.records.values()].find(record => record.threadId === input.threadId); + if (found) return found.id; + return Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let existing = [...plan.records.values()].find(record => record.threadId === input.threadId); + if (existing) return existing.id; + let id = ulid(); + let questionId = ulid(); + let options = input.options.slice(0, Question.limits.MAX_DECISION_OPTIONS).map(option => { + if (!ULID.test(option.id)) Question.reject("Conversation option ID is invalid"); + if (option.source) { + if ( + option.source.role !== "option" + || !plan.conversationPlan.events.some(event => + event.type === "option.added" && event.threadId === input.threadId + && event.contribution.id === option.id + && event.contribution.text.trim().slice(0, Question.limits.MAX_LABEL) + === option.label.trim() + && isDeepStrictEqual(event.source, option.source) + ) + ) Question.reject("Conversation option source is invalid"); + let entry = plan.chat.entries.find(item => item.id === option.source?.messageId); + if (!entry) Question.reject("Conversation option source is missing"); + validateSource(option.source, entry); + } + return { + id: option.id, + label: option.label.slice(0, Question.limits.MAX_LABEL), + description: "", + }; + }); + let single = Question.decision(Question.identified({ + questions: [{ + id: questionId, + header: input.header.slice(0, Question.limits.MAX_HEADER), + question: input.question.slice(0, Question.limits.MAX_QUESTION), + multiple: false, + options, + }], + })); + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + try { + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + Store.reopen(stagedQuestions, id, single, id); + let records = new Map(plan.records); + records.set(id, { + id, + definition: single, + status: "open", + origin: "conversation", + threadId: input.threadId, + history: [], + optionOrigins: Object.fromEntries(options.map(option => { + let source = input.options.find(item => item.id === option.id)?.source; + return [option.id, { origin: "chat", ...(source ? { source } : {}) }]; + })), + editors: [], + }); + let mutation = room.insertQuestionnaires(stagedDocument, [{ + value: { + id, + thread: input.threadId, + status: "open", + questions: [{ + id: questionId, + header: single.questions[0].header, + prompt: single.questions[0].question, + multiple: false, + options: options.map(option => ({ id: option.id, label: option.label })), + }], + }, + }]); + if (!mutation) throw new Error("conversation card could not be inserted"); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + }, mutation); + } finally { + stagedDocument.doc.destroy(); + } + broadcast(server, roomId, { + kind: "question:asked", + ts: 0, + id, + definition: single, + widget: id, + }); + announce(plan, server, roomId, id); + return id; + }); +} diff --git a/apps/server/src/questions/card-involved.ts b/apps/server/src/questions/card-involved.ts new file mode 100644 index 00000000..b7d07c41 --- /dev/null +++ b/apps/server/src/questions/card-involved.ts @@ -0,0 +1,37 @@ +import * as Store from "./store"; +import type { Plan } from "../plan/service"; +import type { Record } from "./records"; + +/** Owner first, then card editors and the conversation's member participants. */ +export function involved(plan: Plan, record: Record): string[] { + let live = Store.get(plan.questions, record.id)?.editors ?? new Set(); + let thread = plan.conversationPlan.threads.find(item => + item.questionnaireId === record.id || item.id === record.threadId + ); + let sources = thread + ? [ + ...thread.questionSources, + ...thread.contributions.flatMap(item => item.sources), + ...thread.stances.flatMap(item => item.sources), + ...thread.stanceHistory.flatMap(item => item.sources), + ...(thread.decision?.sources ?? []), + ...thread.decisionHistory.flatMap(item => item.sources), + ...thread.candidates.flatMap(item => item.sources), + ] + : []; + let authors = sources.flatMap(source => + source.author.kind === "member" ? [source.author.handle] : [] + ); + let stances = thread + ? [...thread.stances, ...thread.stanceHistory].map(item => item.participant) + : []; + return [ + ...new Set([ + ...(record.owner ? [record.owner] : []), + ...record.editors, + ...live, + ...authors, + ...stances, + ]), + ].slice(0, 8); +} diff --git a/apps/server/src/questions/card-notifications.ts b/apps/server/src/questions/card-notifications.ts new file mode 100644 index 00000000..1df498da --- /dev/null +++ b/apps/server/src/questions/card-notifications.ts @@ -0,0 +1,82 @@ +import { appendCardAction, captureCardAction } from "./card-actions"; + +import * as Prose from "./prose"; + +import * as Store from "./store"; +import * as Jobs from "../conversation-plan/jobs"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire } from "@chopin/protocol"; + +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import type { Record } from "./records"; + +import { involved } from "./card-involved"; +import type { CardEvent } from "./card-event"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +const listeners = new WeakMap void>>(); + +export function listen(plan: Plan, listener: (event: CardEvent) => void): () => void { + let set = listeners.get(plan); + if (!set) listeners.set(plan, set = new Set()); + set.add(listener); + return () => set.delete(listener); +} + +export function emit(plan: Plan, event: CardEvent): void { + for (let listener of listeners.get(plan) ?? []) { + try { + listener(event); + } catch (err) { + console.error("[questions] a card listener failed:", err); + } + } +} + +export function pending( + plan: Plan, + record: Record, + event: CardEvent, + at = Math.floor(Date.now() / 1_000), + text?: string, +) { + return appendCardAction(plan.pendingCardActions, captureCardAction(record, event, at, text)); +} + +function cardStatus(record: Record): Wire.CardStatus { + if (record.status === "answered") return "decided"; + if (record.status === "cancelled") return "discarded"; + return record.status; +} + +export function meta(plan: Plan, record: Record): Wire.CardMeta { + let prose = record.prose ?? []; + let decided = record.status === "answered"; + let suggested = Store.get(plan.questions, record.id)?.suggested; + return { + status: cardStatus(record), + origin: record.origin, + ...(record.threadId ? { thread: record.threadId } : {}), + ...(decided && record.owner ? { owner: record.owner } : {}), + ...(decided && record.decidedAt !== undefined ? { decidedAt: record.decidedAt } : {}), + ...(record.resolver ? { resolver: record.resolver } : {}), + involved: involved(plan, record), + ...(suggested ? { suggested } : {}), + history: record.history, + optionOrigins: record.optionOrigins, + refining: Jobs.refining(plan.conversationPlanJobs, record.id), + hasProse: prose.length > 0 && !Prose.orphaned(prose), + proseOrphaned: Prose.orphaned(prose), + }; +} + +export function announce(plan: Plan, server: Server, roomId: string, id: string): void { + let record = plan.records.get(id); + if (record) { + broadcast(server, roomId, { kind: "question:meta", ts: 0, id, meta: meta(plan, record) }); + } +} diff --git a/apps/server/src/questions/card-option-relabel.ts b/apps/server/src/questions/card-option-relabel.ts new file mode 100644 index 00000000..b7202f95 --- /dev/null +++ b/apps/server/src/questions/card-option-relabel.ts @@ -0,0 +1,163 @@ +import * as Y from "yjs"; + +import { applyEvent } from "../conversation-plan/events"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { validateSource } from "../conversation-plan/sources"; +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { ConversationPlan } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function relabelConversationOption( + plan: Plan, + server: Server, + roomId: string, + input: { + operationId: string; + threadId: string; + optionId: string; + label: string; + expectedThreadVersion: number; + expectedCardRevision: number; + }, +): Promise<{ eventId: string; revision: number }> { + return Service.exclusive(plan, async () => { + if ( + typeof input.operationId !== "string" || !input.operationId + || input.operationId.length > 180 + ) throw new Error("invalid relabel operation ID"); + let eventId = `option-relabel:${input.operationId}`; + let previous = plan.conversationPlan.events.find(event => event.id === eventId); + if (previous) { + if ( + previous.type !== "option.relabeled" || previous.threadId !== input.threadId + || previous.optionId !== input.optionId || previous.label !== input.label + || previous.observedThreadVersion !== input.expectedThreadVersion + || previous.observedCardRevision !== input.expectedCardRevision + ) { + throw new Error("relabel operation ID collision"); + } + return { eventId, revision: plan.conversationPlan.revision }; + } + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let thread = plan.conversationPlan.threads.find(item => item.id === input.threadId); + if ( + !thread || !thread.questionnaireId || thread.version !== input.expectedThreadVersion + || thread.status === "decided" || thread.status === "discarded" + ) { + throw new Error("conversation decision is no longer open and linked"); + } + let option = thread.contributions.find(item => + item.id === input.optionId && item.kind === "option" + ); + let record = plan.records.get(thread.questionnaireId); + let live = Store.get(plan.questions, thread.questionnaireId); + if ( + !option || option.sources.length === 0 || !record || record.origin !== "conversation" + || record.threadId !== thread.id || record.optionOrigins[input.optionId]?.origin !== "chat" + || !isOpenStatus(record.status) || !live || live.claim + || live.revision !== input.expectedCardRevision + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !record.definition.questions[0]?.options.some(item => item.id === input.optionId) + || !room.hasQuestionnaire(plan.document, record.id, record.definition.questions[0]!.id) + ) { + throw new Error("conversation option is no longer editable"); + } + for ( + let source of [ + ...option.sources, + ...(record.optionOrigins[input.optionId]?.source + ? [record.optionOrigins[input.optionId]!.source!] + : []), + ] + ) { + let message = plan.chat.entries.find(item => item.id === source.messageId); + if (!message) throw new Error("option source message is missing"); + validateSource(source, message); + } + let event: ConversationPlan.Event = { + id: eventId, + type: "option.relabeled", + threadId: thread.id, + observedThreadVersion: input.expectedThreadVersion, + observedCardRevision: input.expectedCardRevision, + origin: "planner", + actor: { kind: "agent" }, + at: Date.now(), + optionId: input.optionId, + label: input.label, + }; + let conversationPlan = applyEvent(plan.conversationPlan, event); + if (!Store.reserveEdit(plan.questions, record.id)) { + throw new Error("conversation option is no longer editable"); + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + stagedQuestions.open.set(record.id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let relabeled = Store.relabelOption( + stagedQuestions, + record.id, + input.optionId, + input.label, + input.expectedCardRevision, + ); + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let mutation = room.projectOptions( + stagedDocument, + record.id, + relabeled.definition.questions[0]!, + ); + if (!mutation) throw new Error("conversation card label was not projected"); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + conversationPlan, + records: new Map(plan.records).set(record.id, { + ...record, + definition: relabeled.definition, + }), + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id: record.id, + definition: relabeled.definition, + revision: relabeled.revision, + }); + broadcast(server, roomId, { + kind: "conversation-plan:changed", + ts: 0, + state: conversationPlan, + }); + return { eventId, revision: conversationPlan.revision }; + } finally { + Store.releaseEdit(plan.questions, record.id); + stagedDocument?.doc.destroy(); + } + }); +} diff --git a/apps/server/src/questions/card-planner-revision.ts b/apps/server/src/questions/card-planner-revision.ts new file mode 100644 index 00000000..528ec3a3 --- /dev/null +++ b/apps/server/src/questions/card-planner-revision.ts @@ -0,0 +1,145 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import { appendCardAction, captureCardAction } from "./card-actions"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce, emit } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function revisePlannerCard( + plan: Plan, + server: Server, + roomId: string, + id: string, + input: { title?: string; addOptions: Array<{ label: string; rationale: string }> }, + locked = false, + beforePublish?: () => void, +): Promise<{ title: string; added: string[] }> { + let body = async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(id); + let originalQuestions = plan.questions; + let live = Store.get(originalQuestions, id); + if ( + !record || !isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !room.hasQuestionnaire(plan.document, id, record.definition.questions[0]!.id) + ) throw new Error("that decision is no longer open in this document"); + if (!Store.reserveEdit(originalQuestions, id)) { + throw new Error("that decision is no longer open in this document"); + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let title = record.definition.questions[0]!.question; + if (input.title !== undefined) { + let retitled = Store.retitle(stagedQuestions, id, input.title); + if (!retitled.ok) throw new Error(retitled.message); + title = retitled.definition.questions[0]!.question; + } + let added: Array<{ id: string; label: string }> = []; + let origins = { ...record.optionOrigins }; + let actions = plan.pendingCardActions; + let at = Math.floor(Date.now() / 1_000); + for (let option of input.addOptions) { + let result = Store.addOption(stagedQuestions, id, ulid(), option.label); + if (!result.ok) throw new Error(`option addition was refused: ${result.reason}`); + added.push({ id: result.option.id, label: result.option.label }); + origins[result.option.id] = { origin: "planner", rationale: option.rationale }; + actions = appendCardAction( + actions, + captureCardAction(record, { + kind: "option-added", + id, + actor: "chopin", + optionId: result.option.id, + label: result.option.label, + origin: "planner", + }, at), + ); + } + let final = Store.get(stagedQuestions, id)!; + if (title === record.definition.questions[0]!.question && added.length === 0) { + return { title, added: [] }; + } + let vector = Y.encodeStateVector(plan.document.doc); + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let question = final.definition.questions[0]!; + let definition = Question.decision(final.definition); + if (title !== record.definition.questions[0]!.question) { + room.projectPrompt(stagedDocument, id, question.id, title); + } + if (added.length > 0) room.projectOptions(stagedDocument, id, question); + let mutation: room.Mutation = { + update: Y.encodeStateAsUpdate(stagedDocument.doc, vector), + source: room.project(stagedDocument), + }; + beforePublish?.(); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records: new Map(plan.records).set(id, { + ...record, + definition, + optionOrigins: origins, + }), + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + pendingCardActions: actions, + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id, + definition, + revision: final.revision, + }); + announce(plan, server, roomId, id); + for (let option of added) { + emit(plan, { + kind: "option-added", + id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: "chopin", + optionId: option.id, + label: option.label, + origin: "planner", + }); + } + return { title, added: added.map(option => option.id) }; + } finally { + Store.releaseEdit(originalQuestions, id); + stagedDocument?.doc.destroy(); + } + }; + return locked ? body() : Service.exclusive(plan, body); +} diff --git a/apps/server/src/questions/card-reopen-1.test.ts b/apps/server/src/questions/card-reopen-1.test.ts new file mode 100644 index 00000000..a3dec822 --- /dev/null +++ b/apps/server/src/questions/card-reopen-1.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from "bun:test"; + +import * as room from "../plan/room"; + +import { openPlan, storedQuestion } from "../testing/plan"; +import * as Questions from "./service"; +import * as Store from "./store"; + +import { decided, definition, member, reopen } from "./card-reopen.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("reopen keeps identity, moves the decision to history, and publishes metadata before asked", async () => { + let context = await decided(); + let original = room.project(context.plan.document); + let actor = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + await reopen(context, actor); + off(); + expect(actor.frames.at(-1)).toMatchObject({ kind: "question:reopen", ok: true }); + let record = context.plan.records.get(context.id)!; + expect(record).toMatchObject({ + status: "reopened", + history: [{ + choices: [context.value.questions[0]!.options[0]!.id], + owner: "ana", + at: 1, + }], + }); + expect(record.answers).toBeUndefined(); + expect(record.choices).toBeUndefined(); + expect(Store.get(context.plan.questions, context.id)?.settle).toBeUndefined(); + expect(Store.get(context.plan.questions, context.id)?.revision).toBe(0); + let projected = room.project(context.plan.document); + expect(projected).toContain('status="reopened"'); + expect(projected).toContain( + ` frame.kind)).toEqual([ + "plan:update", + "question:meta", + "question:asked", + ]); + expect(events).toEqual([{ kind: "reopened", id: context.id, actor: "ben" }]); +}); + +test("a failed reopen commit preserves the answer, tombstone and history; retry commits once", async () => { + let context = await decided(); + let question = context.value.questions[0]!; + context.plan.questions.closed.set(context.id, { + result: { + status: "answered", + answers: [{ + question: question.question, + choices: [question.options[0]!.label], + optionIds: [question.options[0]!.id], + }], + resolver: "ana", + }, + revision: 1, + expires: Date.now() + 60_000, + }); + let before = room.project(context.plan.document); + let stored = await context.storage.collaboration.load(context.channel.id, context.now); + let old = context.plan.records.get(context.id)!; + let closed = context.plan.questions.closed.get(context.id); + let frames = context.broadcasts.length; + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + let actor = await reopen(context); + expect(actor.frames.at(-1)).toMatchObject({ ok: false, reason: "resolving" }); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(context.id)).toEqual(old); + expect(context.plan.questions.closed.get(context.id)).toBe(closed); + expect(Store.get(context.plan.questions, context.id)).toBeUndefined(); + expect(room.project(context.plan.document)).toBe(before); + expect(context.broadcasts).toHaveLength(frames); + expect(events).toEqual([]); + expect((await context.storage.collaboration.load(context.channel.id, context.now))?.sidecar) + .toEqual(stored?.sidecar); + await reopen(context); + expect(context.plan.records.get(context.id)?.history).toHaveLength(1); + expect(context.plan.questions.closed.has(context.id)).toBe(false); + off(); +}); + +test("duplicate reopen and reopen after discard are refused", async () => { + let context = await decided(); + let first = member("ben"); + let second = member("cy"); + await Promise.all([ + reopen(context, first), + reopen(context, second), + ]); + expect([first, second].filter(actor => actor.frames.at(-1)?.ok === true)).toHaveLength(1); + expect(context.plan.records.get(context.id)?.history).toHaveLength(1); + await Questions.discard(context.plan, context.server, "test", first.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id: context.id, + }); + await reopen(context, second); + expect(second.frames.at(-1)).toMatchObject({ ok: false, reason: "not-decided" }); +}); + +test("a queued discard follows the reopened draft announcement", async () => { + let context = await decided(); + let actor = member("ben"); + let discarder = member("cy"); + let original = context.storage.collaboration.commit; + let entered = Promise.withResolvers(); + let release = Promise.withResolvers(); + let commits = 0; + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + if (++commits === 1) { + entered.resolve(); + await release.promise; + } + return original(input); + }; + try { + let reopening = reopen(context, actor); + await entered.promise; + let discarding = Questions.discard(context.plan, context.server, "test", discarder.ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id: context.id, + }); + release.resolve(); + await Promise.all([reopening, discarding]); + } finally { + release.resolve(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + let events = context.broadcasts.map( + frame => [frame.kind, (frame.meta as { status?: string })?.status], + ); + let asked = events.findIndex(([kind]) => kind === "question:asked"); + let discarded = events.findIndex(([kind, status]) => + kind === "question:meta" && status === "discarded" + ); + expect(asked).toBeGreaterThan(-1); + expect(discarded).toBeGreaterThan(asked); + expect(context.plan.records.get(context.id)?.status).toBe("discarded"); +}); + +test("a failed metadata broadcast after commit still announces the draft and emits the event", async () => { + let context = await decided(); + let actor = member(); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + context.breakRelay("question:meta"); + await reopen(context, actor); + off(); + expect(actor.frames.at(-1)).toMatchObject({ ok: true }); + expect(context.broadcasts.some(frame => frame.kind === "question:asked")).toBe(true); + expect(events).toEqual([{ kind: "reopened", id: context.id, actor: "ben" }]); + expect(context.plan.records.get(context.id)?.status).toBe("reopened"); +}); + +test("restore refuses a draft whose definition reorders the record's questions", async () => { + let value = definition(2); + let reversed = { questions: value.questions.toReversed() }; + await expect(openPlan("", { + questions: [{ id: "w", definition: value, status: "open" }], + openQuestions: [{ + id: "w", + definition: reversed, + model: storedQuestion(reversed), + revision: 0, + }], + })).rejects.toThrow(/question records disagree with their drafts/); +}); + +test("reopen refuses to clear an answer with no ID or text to preserve", async () => { + let value = definition(2); + let first = value.questions[0]!; + let context = await decided(value, { [first.id]: "Option 1" }, [first.options[0]!.id]); + let before = room.project(context.plan.document); + let actor = await reopen(context); + expect(actor.frames.at(-1)).toMatchObject({ ok: false, reason: "resolving" }); + expect(context.plan.records.get(context.id)?.status).toBe("answered"); + expect(room.project(context.plan.document)).toBe(before); +}); + +test("an older option summary without saved IDs stays text even when its label matches", async () => { + let value = definition(); + let question = value.questions[0]!; + let context = await decided(value, { [question.id]: question.options[0]!.label }, []); + await reopen(context); + expect(context.plan.records.get(context.id)?.history[0]).toEqual({ + choices: [], + answers: { [question.id]: question.options[0]!.label }, + owner: "ana", + at: 1, + }); + expect(room.project(context.plan.document)).toContain( + ` { + let context = await decided(); + let actor = member(); + let before = room.project(context.plan.document); + let gate = Service.exclusive(context.plan, async () => { + context.plan.execution = { id: "run-1" } as never; + }); + let pending = reopen(context, actor); + await Promise.all([gate, pending]); + expect(actor.frames.at(-1)).toMatchObject({ + kind: "session:error", + message: "implementation is active", + }); + expect(context.plan.records.get(context.id)?.status).toBe("answered"); + expect(room.project(context.plan.document)).toBe(before); + context.plan.execution = undefined; +}); + +test("legacy multi-question custom and ID answers survive reopen, restart, and redecision", async () => { + let value = definition(2); + let first = value.questions[0]!; + let second = value.questions[1]!; + let context = await decided(value, { + [first.id]: "Option 1", + [second.id]: "Use our existing provider", + }, [first.options[0]!.id]); + await reopen(context); + let record = context.plan.records.get(context.id)!; + expect(record.history[0]).toEqual({ + choices: [first.options[0]!.id], + answers: { [second.id]: "Use our existing provider" }, + owner: "ana", + at: 1, + }); + let projected = room.project(context.plan.document); + expect(projected).toContain(` { + let value = Questions.identify({ + questions: [0, 1].map(index => ({ + header: `Set ${index + 1}`, + question: `Which options belong in set ${index + 1}?`, + multiple: true, + options: Array.from({ length: 11 }, (_, position) => ({ + label: `Choice ${index + 1}-${position + 1}`, + description: "", + })), + })), + }); + let ids = value.questions.flatMap(question => question.options.map(option => option.id)); + let answers = Object.fromEntries(value.questions.map(question => [ + question.id, + question.options.map(option => option.label).join(", "), + ])); + let context = await decided(value, answers, ids); + await reopen(context); + expect(context.plan.records.get(context.id)?.history[0]?.choices).toHaveLength(22); + await Service.close(context.plan); + plans.splice(plans.indexOf(context.plan), 1); + let plan = await Service.open(context.channel.id, context.backend, context.server); + plans.push(plan); + let snapshot = Store.snapshot(plan.questions, context.id); + if (!snapshot.open) throw new Error("missing restored draft"); + let model = Question.crdt.Model.fromBinary(new Uint8Array(snapshot.model)) + .fork() as unknown as Question.Model; + for (let question of value.questions) { + for (let option of question.options) { + model.api.val([question.id, "options", option.id]).set(true); + } + } + let patch = model.api.flush(); + let actor = member("cy"); + await Questions.edit(plan, actor.ws, { + kind: "question:edit", + ts: 0, + rid: "edit", + id: context.id, + patch: [...patch.toBinary()], + }); + await Questions.submit(plan, context.server, "test", actor.ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id: context.id, + revision: Store.get(plan.questions, context.id)!.revision, + }); + expect(actor.frames.at(-1)).toMatchObject({ kind: "question:submit", ok: true }); + await Service.close(plan); + plans.splice(plans.indexOf(plan), 1); + let again = await Service.open(context.channel.id, context.backend, context.server); + plans.push(again); + expect(again.records.get(context.id)?.history[0]?.choices).toHaveLength(22); + expect(again.records.get(context.id)?.choices).toHaveLength(22); +}); diff --git a/apps/server/src/questions/card-reopen.test-fixtures.ts b/apps/server/src/questions/card-reopen.test-fixtures.ts new file mode 100644 index 00000000..a2012ca9 --- /dev/null +++ b/apps/server/src/questions/card-reopen.test-fixtures.ts @@ -0,0 +1,110 @@ +import { afterEach } from "bun:test"; + +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import * as Questions from "./service"; + +import type { Socket } from "../wire"; +import type { Plan } from "../plan/service"; +import type { Definition } from "@chopin/question"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 helpers and cleanup. +export let plans: Plan[] = []; + +afterEach(async () => { + for (let plan of plans) await Service.close(plan); + plans = []; +}); + +export function member(handle = "ben") { + let frames: Array> = []; + let ws = { + data: { handle, client: `client-${handle}`, room: "test" }, + send(raw: string) { + frames.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + return { ws, frames }; +} + +export function definition(count = 1): Definition { + return Questions.identify({ + questions: Array.from({ length: count }, (_, index) => ({ + header: `Choice ${index + 1}`, + question: `Which choice ${index + 1}?`, + multiple: false, + options: [{ label: `Option ${index + 1}`, description: "" }], + })), + }); +} + +export function source( + id: string, + value: Definition, + answers: { [question: string]: string }, + chosen: string[], +) { + let selected = new Set(chosen); + return `\n` + + value.questions.map(question => { + let options = question.options.map(option => + `\n"; +} + +export async function decided( + value = definition(), + answers?: { [question: string]: string }, + chosen?: string[], +) { + let id = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; + let text = answers ?? Object.fromEntries(value.questions.map(question => [ + question.id, + question.options[0]!.label, + ])); + let ids = chosen ?? value.questions.map(question => question.options[0]!.id); + let context = await openPlan(source(id, value, text, ids), { + questions: [{ + id, + definition: value, + status: "answered", + answers: text, + choices: ids, + resolver: "ana", + at: 1, + owner: "ana", + decidedAt: 1, + }], + }); + plans.push(context.plan); + return { ...context, id, value }; +} + +export async function reopen( + context: Awaited>, + member = memberFactory(), +) { + await Questions.reopen(context.plan, context.server, "test", member.ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id: context.id, + }); + return member; +} + +export function memberFactory() { + return member(); +} diff --git a/apps/server/src/questions/card-retitle.ts b/apps/server/src/questions/card-retitle.ts new file mode 100644 index 00000000..729b9b7a --- /dev/null +++ b/apps/server/src/questions/card-retitle.ts @@ -0,0 +1,93 @@ +import * as Y from "yjs"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function retitle( + plan: Plan, + server: Server, + roomId: string, + id: string, + title: string, + locked = false, + beforePublish?: () => void, +): Promise<{ ok: true; revision: number } | { ok: false; message: string }> { + let body = async () => { + if (Service.implementationActive(plan)) { + return { ok: false as const, message: "implementation is active" }; + } + let record = plan.records.get(id); + let originalQuestions = plan.questions; + let live = Store.get(originalQuestions, id); + if ( + !record || !isOpenStatus(record.status) || !live || live.claim + || record.definition.questions.length !== 1 + || record.definition.questions[0]?.id !== live.definition.questions[0]?.id + || !room.hasQuestionnaire(plan.document, id, record.definition.questions[0]!.id) + ) return { ok: false as const, message: "This decision is no longer open" }; + if (!Store.reserveEdit(originalQuestions, id)) { + return { ok: false as const, message: "This decision is no longer open" }; + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let result = Store.retitle(stagedQuestions, id, title); + if (!result.ok) return { ok: false as const, message: result.message }; + if (!result.applied) return { ok: true as const, revision: result.revision }; + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let records = new Map(plan.records); + records.set(id, { ...record, definition: result.definition }); + let question = result.definition.questions[0]!; + let mutation = room.projectPrompt(stagedDocument, id, question.id, question.question); + if (!mutation) throw new Error("card prompt did not change the document"); + beforePublish?.(); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + threads: new Map(plan.threads), + outlines: new Map(plan.outlines), + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id, + definition: result.definition, + revision: result.revision, + }); + announce(plan, server, roomId, id); + return { ok: true as const, revision: result.revision }; + } finally { + Store.releaseEdit(originalQuestions, id); + stagedDocument?.doc.destroy(); + } + }; + return locked ? body() : Service.exclusive(plan, body); +} diff --git a/apps/server/src/questions/card-server-options.ts b/apps/server/src/questions/card-server-options.ts new file mode 100644 index 00000000..39f1c59e --- /dev/null +++ b/apps/server/src/questions/card-server-options.ts @@ -0,0 +1,236 @@ +import { ULID, ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; +import { isDeepStrictEqual } from "node:util"; + +import { applyEvent } from "../conversation-plan/events"; +import { OptionCapacityError } from "../conversation-plan/option-capacity"; + +import { isOpenStatus, matchesQuestionSource, questionMentionsOption } from "./records"; +import { atomicOptionQuote } from "./option-match"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { validateSource } from "../conversation-plan/sources"; +import { broadcast } from "../wire"; +import type { Server } from "bun"; +import type { ConversationPlan } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce, emit, pending } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function addServerOption( + plan: Plan, + server: Server, + roomId: string, + id: string, + input: { + optionId?: string; + label: string; + origin: "chat" | "planner"; + rationale?: string; + source?: ConversationPlan.SourceRef; + trigger?: string; + }, + locked = false, + beforePublish?: () => void, +): Promise< + { ok: true; optionId: string } | { + ok: false; + reason: "full" | "duplicate" | "invalid" | "closed"; + } +> { + let body = async () => { + if (Service.implementationActive(plan)) { + return { ok: false as const, reason: "closed" as const }; + } + let record = plan.records.get(id); + let live = Store.get(plan.questions, id); + if (!record || !isOpenStatus(record.status) || !live || live.claim) { + return { ok: false as const, reason: "closed" as const }; + } + if ( + typeof input.label !== "string" || !input.label.trim() + || typeof input.optionId !== "undefined" && !ULID.test(input.optionId) + || input.rationale !== undefined && (typeof input.rationale !== "string" + || !input.rationale.trim() || input.rationale.length > 1_000) + ) return { ok: false as const, reason: "invalid" as const }; + if (input.source !== undefined) { + try { + if ( + input.origin === "chat" && ( + input.source.role !== "option" + || !plan.conversationPlan.events.some(event => + event.id === input.trigger && event.type === "option.added" + && event.threadId === record.threadId + && event.contribution.id === input.optionId + && event.contribution.text.trim().slice(0, Question.limits.MAX_LABEL) + === input.label.trim() + && isDeepStrictEqual(event.source, input.source) + ) + ) + ) return { ok: false as const, reason: "invalid" as const }; + if ( + input.origin === "planner" && ( + input.source.author.kind !== "member" + || input.source.role === "option" + && !atomicOptionQuote(input.source.quote, input.label) + ) + ) return { ok: false as const, reason: "invalid" as const }; + let thread = plan.conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === id + ); + if ( + input.origin === "planner" && ( + input.source.role !== "option" + && (!matchesQuestionSource(input.source, thread) + || !questionMentionsOption(input.source.quote, input.label)) + ) + ) { + return { ok: false as const, reason: "invalid" as const }; + } + let message = plan.chat.entries.find(entry => entry.id === input.source?.messageId); + if (!message) return { ok: false as const, reason: "invalid" as const }; + validateSource(input.source, message); + } catch { + return { ok: false as const, reason: "invalid" as const }; + } + } + let already = record.definition.questions[0]?.options.find(option => + option.id === input.optionId + ); + if (already) { + let provenance = record.optionOrigins[already.id]; + if ( + already.label === input.label.trim() && provenance?.origin === input.origin + && provenance.rationale === input.rationale + && JSON.stringify(provenance.source) === JSON.stringify(input.source) + ) return { ok: true as const, optionId: already.id }; + return { ok: false as const, reason: "invalid" as const }; + } + let originalQuestions = plan.questions; + if (!Store.reserveOption(originalQuestions, id)) { + return { ok: false as const, reason: "closed" as const }; + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let added = Store.addOption(stagedQuestions, id, input.optionId ?? ulid(), input.label); + if (!added.ok) return { ok: false as const, reason: added.reason }; + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let records = new Map(plan.records); + records.set(id, { + ...record, + definition: added.definition, + optionOrigins: { + ...record.optionOrigins, + [added.option.id]: { + origin: input.origin, + ...(input.rationale ? { rationale: input.rationale } : {}), + ...(input.source ? { source: input.source } : {}), + }, + }, + }); + let mutation = room.projectOptions( + stagedDocument, + id, + added.definition.questions[0], + ); + let thread = plan.conversationPlan.threads.find(item => + item.id === record.threadId && item.questionnaireId === id + ); + let optionEvent: ConversationPlan.Event | undefined = input.origin === "planner" && thread + ? { + id: `card:${id}:option:${added.option.id}`, + type: "option.added", + threadId: thread.id, + observedThreadVersion: thread.version, + origin: "planner", + actor: { kind: "agent" }, + at: Math.floor(Date.now() / 1_000), + ...(input.source?.role === "option" ? { source: input.source } : {}), + contribution: { + id: added.option.id, + text: added.option.label, + authoring: "scribe", + targetId: thread.id, + }, + } + : undefined; + let conversationPlan = optionEvent + ? applyEvent(plan.conversationPlan, optionEvent) + : plan.conversationPlan; + beforePublish?.(); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + conversationPlan, + pendingCardActions: optionEvent ? plan.pendingCardActions : pending(plan, record, { + kind: "option-added", + id, + actor: "chopin", + optionId: added.option.id, + label: added.option.label, + origin: input.origin, + }), + }, mutation); + broadcast(server, roomId, { + kind: "question:changed", + ts: 0, + id, + definition: added.definition, + revision: added.revision, + }); + if (optionEvent) { + broadcast(server, roomId, { + kind: "conversation-plan:changed", + ts: 0, + state: conversationPlan, + }); + } + announce(plan, server, roomId, id); + if (input.origin === "planner") { + emit(plan, { + kind: "option-added", + id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: "chopin", + optionId: added.option.id, + label: added.option.label, + origin: "planner", + }); + } + return { ok: true as const, optionId: added.option.id }; + } finally { + Store.releaseOption(originalQuestions, id); + stagedDocument?.doc.destroy(); + } + }; + try { + return await (locked ? body() : Service.exclusive(plan, body)); + } catch (error) { + if (error instanceof OptionCapacityError) return { ok: false, reason: error.reason }; + throw error; + } +} diff --git a/apps/server/src/questions/card-suggestions.ts b/apps/server/src/questions/card-suggestions.ts new file mode 100644 index 00000000..2885d771 --- /dev/null +++ b/apps/server/src/questions/card-suggestions.ts @@ -0,0 +1,57 @@ +import { isOpenStatus } from "./records"; + +import * as Store from "./store"; + +import type { Server } from "bun"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; + +import { announce } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function suggest( + plan: Plan, + server: Server, + roomId: string, + id: string, + suggestion?: { optionId: string; messageIds: string[] }, + locked = false, +): Promise { + let body = async () => { + if (Service.implementationActive(plan)) return false; + let record = plan.records.get(id); + let originalQuestions = plan.questions; + let live = Store.get(originalQuestions, id); + if ( + !record || !isOpenStatus(record.status) || !live || !Store.reserveEdit(originalQuestions, id) + ) { + return false; + } + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(id, { + ...live, + claim: undefined, + editors: new Set(live.editors), + }); + let result = Store.suggest(stagedQuestions, id, suggestion); + if (!result.ok) return false; + if (result.revision === live.revision) return true; + await Service.publishStaged(plan, server, roomId, { + ...plan, + questions: stagedQuestions, + records: new Map(plan.records), + }); + announce(plan, server, roomId, id); + return true; + } finally { + Store.releaseEdit(originalQuestions, id); + } + }; + return locked ? body() : Service.exclusive(plan, body); +} diff --git a/apps/server/src/questions/conversation-card-1.test.ts b/apps/server/src/questions/conversation-card-1.test.ts new file mode 100644 index 00000000..ba728691 --- /dev/null +++ b/apps/server/src/questions/conversation-card-1.test.ts @@ -0,0 +1,201 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as Jobs from "../conversation-plan/jobs"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input, quotedOption } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a pending conversation card without options appends once and survives restart", async () => { + let context = await openPlan("Opening paragraph.\n"); + plans.push(context.plan); + let [id, repeated] = await Promise.all([ + Questions.insertConversationCard(context.plan, context.server, "test", input()), + Questions.insertConversationCard(context.plan, context.server, "test", input()), + ]); + expect(repeated).toBe(id); + expect(context.plan.records.size).toBe(1); + expect(context.plan.records.get(id)).toMatchObject({ + origin: "conversation", + threadId: "thread-a", + status: "open", + editors: [], + }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(Store.get(context.plan.questions, id)!.settle).toBeUndefined(); + let source = room.project(context.plan.document); + expect(source.indexOf("Opening paragraph.")).toBeLessThan(source.indexOf(" { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let record = context.plan.records.get(id)!; + expect(Questions.meta(context.plan, record).refining).toBe(false); + context.plan.conversationPlanJobs = Jobs.enqueue([], { + kind: "refine", + target: id, + trigger: "m1", + }, "2026-09-25T10:00:00.000Z"); + expect(Questions.meta(context.plan, record).refining).toBe(true); + context.plan.conversationPlanJobs = Jobs.start( + context.plan.conversationPlanJobs, + `refine:${id}:m1`, + "2026-09-25T10:00:01.000Z", + ); + expect(Questions.meta(context.plan, record).refining).toBe(true); + context.plan.conversationPlanJobs = Jobs.settle( + context.plan.conversationPlanJobs, + `refine:${id}:m1`, + { status: "done", output: "{}" }, + "2026-09-25T10:00:02.000Z", + ); + expect(Questions.meta(context.plan, record).refining).toBe(false); +}); + +test("a conversation card can be discarded before anyone adds an option", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.discard(context.plan, context.server, "test", ws, { + kind: "question:discard", + ts: 0, + rid: "discard", + id, + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:discard", ok: true }); + expect(context.plan.records.get(id)?.status).toBe("discarded"); + expect(room.project(context.plan.document)).toContain('status="discarded"'); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.records.get(id)?.status).toBe("discarded"); + expect(restored.records.get(id)?.definition.questions[0]?.options).toEqual([]); + expect(room.project(restored.document)).toContain('status="discarded"'); +}); + +test("the first quoted option keeps its ID, persists provenance, and makes the card suggestible", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let before = context.broadcasts.length; + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: OPTION }); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: OPTION }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([{ + id: OPTION, + label: "GitHub Apps", + description: "", + }]); + expect(context.plan.records.get(id)!.optionOrigins[OPTION]).toEqual({ origin: "chat" }); + expect(context.broadcasts.slice(before).filter(frame => frame.kind === "question:changed")) + .toHaveLength(1); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect( + Question.read( + Store.get(context.plan.questions, id)!.model, + Store.get(context.plan.questions, id)!.definition, + )[context.plan.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBeNull(); + expect(Store.get(context.plan.questions, id)!.suggested).toEqual({ + optionId: OPTION, + messageIds: ["m1"], + revision: 2, + }); +}); + +test("a quoted option relabel commits card and event together, and survives restart", async () => { + let { context, id, state } = await quotedOption(); + let revision = Store.get(context.plan.questions, id)!.revision; + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: revision, + }; + let result = await Questions.relabelConversationOption( + context.plan, + context.server, + "test", + request, + ); + expect(result.eventId).toBe("option-relabel:shorten-github"); + expect(context.plan.conversationPlan.threads[0]!.contributions[0]).toMatchObject({ + id: OPTION, + text: "We should use GitHub Apps for auth", + displayLabel: "GitHub Apps", + sources: [{ quote: "We should use GitHub Apps for auth", start: 0, end: 34 }], + }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]).toMatchObject({ + id: OPTION, + label: "GitHub Apps", + }); + expect(context.plan.records.get(id)!.optionOrigins[OPTION]).toEqual({ origin: "chat" }); + expect(room.project(context.plan.document)).toContain('label="GitHub Apps"'); + let broadcastCount = context.broadcasts.length; + expect(await Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .toEqual({ eventId: result.eventId, revision: result.revision }); + expect(context.broadcasts).toHaveLength(broadcastCount); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.conversationPlan.threads[0]!.contributions[0]!.displayLabel).toBe("GitHub Apps"); + expect(restored.records.get(id)!.definition.questions[0]!.options[0]!.label).toBe("GitHub Apps"); +}); diff --git a/apps/server/src/questions/conversation-card-2.test.ts b/apps/server/src/questions/conversation-card-2.test.ts new file mode 100644 index 00000000..423a9b6b --- /dev/null +++ b/apps/server/src/questions/conversation-card-2.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, SECOND, input, quotedOption } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("relabel refuses stale, grouped, duplicate, and invalid-source requests without a mutation", async () => { + let { context, id, state } = await quotedOption(); + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: Store.get(context.plan.questions, id)!.revision, + }; + let original = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + for ( + let input of [ + { ...request, expectedThreadVersion: request.expectedThreadVersion - 1 }, + { ...request, expectedCardRevision: request.expectedCardRevision + 1 }, + { ...request, label: "Use OAuth" }, + { ...request, label: "GitHub Apps or OAuth" }, + { ...request, label: " " }, + ] + ) { + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", input)) + .rejects.toThrow(); + } + let message = context.plan.chat.entries.find(item => item.id === "quoted-option-message")!; + message.text = "Changed after citation"; + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("quote does not match"); + message.text = "We should use GitHub Apps for auth"; + expect(context.plan.conversationPlan).toEqual(state); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]!.label) + .toBe("We should use GitHub Apps for auth"); + expect(Store.get(context.plan.questions, id)!.revision).toBe(request.expectedCardRevision); + expect(room.project(context.plan.document)).toBe(original); + expect(context.broadcasts).toHaveLength(broadcasts); +}); + +test("relabel storage failure leaves both states unchanged and retry succeeds", async () => { + let { context, id, state } = await quotedOption(); + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: Store.get(context.plan.questions, id)!.revision, + }; + let source = room.project(context.plan.document); + let broadcasts = context.broadcasts.length; + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.conversationPlan).toEqual(state); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]!.label) + .toBe("We should use GitHub Apps for auth"); + expect(Store.get(context.plan.questions, id)!.revision).toBe(request.expectedCardRevision); + expect(room.project(context.plan.document)).toBe(source); + expect(context.broadcasts).toHaveLength(broadcasts); + await Questions.relabelConversationOption(context.plan, context.server, "test", request); + expect(context.plan.records.get(id)!.definition.questions[0]!.options[0]!.label) + .toBe("GitHub Apps"); +}); + +test("relabel refuses a claimed implementation and a closed card", async () => { + let { context, id, state } = await quotedOption(); + let request = { + operationId: "shorten-github", + threadId: "thread-a", + optionId: OPTION, + label: "GitHub Apps", + expectedThreadVersion: state.threads[0]!.version, + expectedCardRevision: Store.get(context.plan.questions, id)!.revision, + }; + context.plan.execution = { id: "run-1" } as never; + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("implementation is active"); + context.plan.execution = undefined; + context.plan.records.get(id)!.status = "discarded"; + await expect(Questions.relabelConversationOption(context.plan, context.server, "test", request)) + .rejects.toThrow("no longer editable"); + expect(context.plan.conversationPlan).toEqual(state); +}); + +test("a failed insert leaves no live or durable half-card and succeeds on retry", async () => { + let context = await openPlan(); + plans.push(context.plan); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.insertConversationCard(context.plan, context.server, "test", input())) + .rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.size).toBe(0); + expect(context.plan.questions.open.size).toBe(0); + expect(room.project(context.plan.document)).not.toContain(" { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let events: Questions.CardEvent[] = []; + let off = Questions.listen(context.plan, event => events.push(event)); + let outcome = await Service.exclusive( + context.plan, + () => + Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "planner", + rationale: "Fits the existing repository permissions.", + }, true), + ); + off(); + expect(outcome).toEqual({ ok: true, optionId: OPTION }); + expect(context.plan.records.get(id)!.optionOrigins[OPTION]).toEqual({ + origin: "planner", + rationale: "Fits the existing repository permissions.", + }); + expect(events).toMatchObject([{ kind: "option-added", optionId: OPTION, origin: "planner" }]); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "github apps", + origin: "chat", + }), + ).toEqual({ ok: false, reason: "duplicate" }); +}); + +test("a failed option commit leaves the record, draft, and document unchanged", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + let source = room.project(context.plan.document); + let revision = Store.get(context.plan.questions, id)!.revision; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + })).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(Store.get(context.plan.questions, id)!.definition.questions[0]!.options).toEqual([]); + expect(Store.get(context.plan.questions, id)!.revision).toBe(revision); + expect(room.project(context.plan.document)).toBe(source); + expect(context.broadcasts).toHaveLength(before); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: OPTION }); +}); diff --git a/apps/server/src/questions/conversation-card-3.test.ts b/apps/server/src/questions/conversation-card-3.test.ts new file mode 100644 index 00000000..45f09e50 --- /dev/null +++ b/apps/server/src/questions/conversation-card-3.test.ts @@ -0,0 +1,214 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("server retitle persists the record, draft, and one card prompt under the held lock", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "GitHub Apps" }]), + ); + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }); + let before = context.broadcasts.length; + let result = await Service.exclusive( + context.plan, + () => + Questions.retitle(context.plan, context.server, "test", id, "Which auth system first?", true), + ); + expect(result).toMatchObject({ ok: true, revision: 2 }); + expect(context.plan.records.get(id)?.definition.questions[0]?.question).toBe( + "Which auth system first?", + ); + expect(Store.get(context.plan.questions, id)?.definition.questions[0]?.question).toBe( + "Which auth system first?", + ); + expect(Store.get(context.plan.questions, id)?.suggested?.revision).toBe(2); + expect(room.project(context.plan.document)).toContain('prompt="Which auth system first?"'); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual([ + "plan:update", + "question:changed", + "question:meta", + ]); + let repeatedAt = context.broadcasts.length; + expect( + await Questions.retitle( + context.plan, + context.server, + "test", + id, + " Which auth system first? ", + ), + ).toEqual({ ok: true, revision: 2 }); + expect(context.broadcasts).toHaveLength(repeatedAt); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.records.get(id)?.definition.questions[0]?.question).toBe( + "Which auth system first?", + ); + expect(Store.get(restored.questions, id)?.suggested?.revision).toBe(2); + expect(room.project(restored.document)).toContain('prompt="Which auth system first?"'); +}); + +test("a failed retitle commit does not leak a changed record, draft, document, or broadcast", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + let source = room.project(context.plan.document); + let revision = Store.get(context.plan.questions, id)!.revision; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.retitle( + context.plan, + context.server, + "test", + id, + "Which auth system first?", + )).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(Store.get(context.plan.questions, id)?.definition.questions[0]?.question).toBe( + "What auth system should we use?", + ); + expect(Store.get(context.plan.questions, id)?.revision).toBe(revision); + expect(room.project(context.plan.document)).toBe(source); + expect(context.broadcasts).toHaveLength(before); + expect( + await Questions.retitle( + context.plan, + context.server, + "test", + id, + "Which auth system first?", + ), + ).toMatchObject({ ok: true }); +}); + +test("a failed suggestion commit neither pre-selects nor announces until retry", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([ + { id: OPTION, label: "GitHub Apps" }, + ]), + ); + let original = context.storage.collaboration.commit; + let before = context.broadcasts.length; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + })).rejects.toThrow("storage unavailable"); + } finally { + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + let entry = Store.get(context.plan.questions, id)!; + expect(entry.revision).toBe(0); + expect(entry.suggested).toBeUndefined(); + expect(Question.read(entry.model, entry.definition)[entry.definition.questions[0]!.id]!.choice) + .toBeNull(); + expect(context.broadcasts).toHaveLength(before); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual(["question:meta"]); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + meta: { suggested: { optionId: OPTION, messageIds: ["m1"], revision: 1 } }, + }); + let after = context.broadcasts.length; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect(Store.get(context.plan.questions, id)!.revision).toBe(1); + expect(context.broadcasts).toHaveLength(after); +}); + +test("withdrawal clears an advisory only after commit and leaves the human draft untouched", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "VPS" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m5"], + }), + ).toBe(true); + let before = context.broadcasts.length; + let original = context.storage.collaboration.commit; + context.storage.collaboration.commit = async () => { + throw new Error("storage unavailable"); + }; + try { + await expect(Questions.suggest(context.plan, context.server, "test", id)) + .rejects.toThrow("storage unavailable"); + } finally { + context.storage.collaboration.commit = original; + } + expect(Store.get(context.plan.questions, id)?.suggested?.messageIds).toEqual(["m5"]); + expect(context.broadcasts).toHaveLength(before); + expect(await Questions.suggest(context.plan, context.server, "test", id)).toBe(true); + expect(await Questions.suggest(context.plan, context.server, "test", id)).toBe(true); + let live = Store.get(context.plan.questions, id)!; + expect(live.suggested).toBeUndefined(); + expect(live.revision).toBe(2); + expect(Question.read(live.model, live.definition)[live.definition.questions[0]!.id]!.choice) + .toBeNull(); + expect(context.plan.records.get(id)?.history).toEqual([]); + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual(["question:meta"]); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(Store.get(restored.questions, id)?.suggested).toBeUndefined(); + expect(restored.records.get(id)?.history).toEqual([]); +}); diff --git a/apps/server/src/questions/conversation-card-4.test.ts b/apps/server/src/questions/conversation-card-4.test.ts new file mode 100644 index 00000000..c1e01bfd --- /dev/null +++ b/apps/server/src/questions/conversation-card-4.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from "bun:test"; +import * as Question from "@chopin/question"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, SECOND, input } = fixture; + +// Retained scenarios use the current keyed shared-option protocol. +test("server option growth retains the suggestion at its new card revision", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "GitHub Apps", + origin: "chat", + }), + ).toEqual({ ok: true, optionId: SECOND }); + expect(Store.get(context.plan.questions, id)!.suggested).toEqual({ + optionId: OPTION, + messageIds: ["m1"], + revision: 2, + }); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + meta: { suggested: { optionId: OPTION, revision: 2 } }, + }); +}); + +test("a human-added option clears an advisory suggestion after commit", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.addOption(context.plan, context.server, "test", ws, { + kind: "question:option", + question: context.plan.records.get(id)!.definition.questions[0]!.id, + key: "add-option-1", + ts: 0, + rid: "add", + id, + label: "GitHub Apps", + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:option", ok: true }); + expect(Store.get(context.plan.questions, id)!.suggested).toBeUndefined(); + expect(context.broadcasts.at(-1)).toMatchObject({ kind: "question:meta" }); + expect(context.broadcasts.at(-1)).not.toHaveProperty("meta.suggested"); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(Store.get(restored.questions, id)!.suggested).toBeUndefined(); +}); + +test("a saved suggestion can be explicitly accepted after a durable restart", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(Store.get(restored.questions, id)!.suggested).toEqual({ + optionId: OPTION, + messageIds: ["m1"], + revision: 1, + }); + expect( + Question.read( + Store.get(restored.questions, id)!.model, + Store.get(restored.questions, id)!.definition, + )[restored.records.get(id)!.definition.questions[0]!.id]!.choice, + ).toBeNull(); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.submit(restored, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:submit", ok: true }); + expect(restored.records.get(id)).toMatchObject({ + status: "answered", + choices: [OPTION], + owner: "ana", + }); + expect(room.project(restored.document)).toContain("Auth0"); +}); diff --git a/apps/server/src/questions/conversation-card-5.test.ts b/apps/server/src/questions/conversation-card-5.test.ts new file mode 100644 index 00000000..e3f34666 --- /dev/null +++ b/apps/server/src/questions/conversation-card-5.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test"; + +import * as Questions from "./service"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("rapid Save, Reopen, Save retains each actor and generation without a mirror listener", async () => { + let context = await openPlan("Only paragraph.\n"); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + let replies: Array> = []; + let actor = "ana"; + let ws = { + data: { + get handle() { + return actor; + }, + client: "client", + room: "test", + }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-1", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + let first = context.plan.records.get(id)!; + context.plan.records.set(id, { + ...first, + prose: [{ + ...room.anchorAt(context.plan.document, 0, room.digests(context.plan.document)[0]!), + orphaned: true, + }], + }); + await Service.persist(context.plan); + actor = "ben"; + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect(room.questionnaireIndex(context.plan.document, id)).toBe(1); + expect(context.plan.records.get(id)?.prose?.[0]?.orphaned).toBe(true); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m2"], + }), + ).toBe(true); + actor = "cy"; + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save-2", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + expect(replies.filter(frame => frame.ok).map(frame => frame.kind)).toEqual([ + "question:submit", + "question:reopen", + "question:submit", + ]); + expect(context.plan.pendingCardActions.map(action => [action.id, action.actor])).toEqual([ + [`card:${id}:decided:1`, "ana"], + [`card:${id}:reopened:1`, "ben"], + [`card:${id}:decided:2`, "cy"], + ]); + expect(context.plan.conversationPlanPendingEffects.filter(item => item.kind === "job")) + .toMatchObject([ + { key: `job:prose:${id}:1`, intent: { kind: "prose", trigger: `decided:${id}:1` } }, + { key: `job:prose:${id}:2`, intent: { kind: "prose", trigger: `decided:${id}:2` } }, + ]); + expect(context.plan.pendingCardActions[0]).toMatchObject({ + threadId: "thread-a", + optionIds: [OPTION], + text: "Auth0", + }); + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.pendingCardActions).toEqual(context.plan.pendingCardActions); + expect(restored.conversationPlanPendingEffects).toEqual( + context.plan.conversationPlanPendingEffects, + ); +}); diff --git a/apps/server/src/questions/conversation-card-6.test.ts b/apps/server/src/questions/conversation-card-6.test.ts new file mode 100644 index 00000000..f10dac0e --- /dev/null +++ b/apps/server/src/questions/conversation-card-6.test.ts @@ -0,0 +1,171 @@ +import { expect, spyOn, test } from "bun:test"; + +import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical"; +import * as Y from "yjs"; +import * as Questions from "./service"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, input } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("Reopen follows moved decision prose in one commit and rolls back on storage failure", async () => { + let context = await openPlan("# Title\n\nDecision prose.\n\nOther.\n"); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + let replies: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send(raw: string) { + replies.push(JSON.parse(raw)); + }, + publish() {}, + } as unknown as Socket; + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }); + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + expect(context.plan.records.get(id)?.status).toBe("answered"); + let anchor = room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!); + context.plan.records.set(id, { ...context.plan.records.get(id)!, prose: [anchor] }); + await Service.persist(context.plan); + let vector = Y.encodeStateVector(context.plan.document.doc); + context.plan.document.editor.update(() => { + let blocks = $getRoot().getChildren(); + blocks[3]!.insertAfter(blocks[1]!); + blocks[1]!.insertBefore($createParagraphNode()); + }, { discrete: true }); + await room.settle(); + Questions.rebase(context.plan); + anchor = context.plan.records.get(id)!.prose![0]!; + await Service.exclusive(context.plan, () => + Service.publish( + context.plan, + context.server, + "test", + { + update: Y.encodeStateAsUpdate(context.plan.document.doc, vector), + source: room.project(context.plan.document), + }, + )); + let proseAt = room.digests(context.plan.document).findIndex((_, index) => + room.matchesAnchor(context.plan.document, anchor, index) + ); + expect(proseAt).toBeGreaterThan(room.questionnaireIndex(context.plan.document, id)!); + let proseKey = room.resolveAnchor(context.plan.document, anchor); + let proseType = context.plan.document.binding.collabNodeMap.get(proseKey!)?.getSharedType(); + let proseCaret = Y.createRelativePositionFromTypeIndex(proseType!, 0, -1); + let emptyKey: string | undefined; + context.plan.document.editor.getEditorState().read(() => { + emptyKey = $getRoot().getChildren().find(node => + $isParagraphNode(node) && node.getChildrenSize() === 0 + )?.getKey(); + }); + if (!emptyKey) throw new Error("missing live empty caret paragraph"); + let emptyType = context.plan.document.binding.collabNodeMap.get(emptyKey)?.getSharedType(); + let emptyCaret = Y.createRelativePositionFromTypeIndex(emptyType!, 0, -1); + let beforeSource = room.project(context.plan.document); + let peer = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + beforeSource, + [], + ); + let beforeRecord = structuredClone(context.plan.records.get(id)); + let beforeFrames = context.broadcasts.length; + let original = context.storage.collaboration.commit; + (context.storage.collaboration as { commit: typeof original }).commit = async () => { + throw new Error("storage unavailable"); + }; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "failed-reopen", + id, + }); + } finally { + errors.mockRestore(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(room.project(context.plan.document)).toBe(beforeSource); + expect(context.plan.records.get(id)).toEqual(beforeRecord); + expect(Y.createAbsolutePositionFromRelativePosition(emptyCaret, context.plan.document.doc, false)) + .not.toBeNull(); + expect(context.broadcasts.slice(beforeFrames).filter(frame => frame.kind === "plan:update")) + .toHaveLength(0); + await Questions.reopen(context.plan, context.server, "test", ws, { + kind: "question:reopen", + ts: 0, + rid: "reopen", + id, + }); + expect(replies.at(-1)).toMatchObject({ kind: "question:reopen", ok: true }); + let movedAnchor = context.plan.records.get(id)!.prose![0]!; + let afterProseAt = room.digests(context.plan.document).findIndex((_, index) => + room.matchesAnchor(context.plan.document, movedAnchor, index) + ); + expect(afterProseAt).toBeGreaterThanOrEqual(0); + expect(room.questionnaireIndex(context.plan.document, id)).toBe(afterProseAt + 1); + expect(room.resolveAnchor(context.plan.document, context.plan.records.get(id)!.prose![0]!)) + .toBe(proseKey); + expect(Y.createAbsolutePositionFromRelativePosition(proseCaret, context.plan.document.doc, false)) + .not.toBeNull(); + context.plan.document.editor.getEditorState().read(() => { + expect($getRoot().getChildren().some(node => node.getKey() === emptyKey)).toBe(true); + }); + expect(Y.createAbsolutePositionFromRelativePosition(emptyCaret, context.plan.document.doc, false)) + .not.toBeNull(); + expect(context.plan.records.get(id)).toMatchObject({ + status: "reopened", + history: [{ choices: [OPTION], owner: "ana" }], + }); + expect(room.project(context.plan.document)).toContain(" + frame.kind === "plan:update" + ); + expect(updates).toHaveLength(1); + let update = updates[0]?.update; + if (typeof update !== "string") throw new Error("missing persisted Reopen delta"); + Y.applyUpdate(peer.doc, Buffer.from(update, "base64"), "remote"); + await room.settle(); + expect(Y.createAbsolutePositionFromRelativePosition(proseCaret, peer.doc, false)) + .not.toBeNull(); + let peerCaret = Y.createAbsolutePositionFromRelativePosition(emptyCaret, peer.doc, false); + expect(peerCaret).not.toBeNull(); + peer.editor.getEditorState().read(() => { + expect( + $getRoot().getChildren().some(node => + $isParagraphNode(node) && node.getChildrenSize() === 0 + && peer.binding.collabNodeMap.get(node.getKey())?.getSharedType() === peerCaret?.type + ), + ).toBe(true); + }); + expect(room.project(peer)).toBe(room.project(context.plan.document)); + peer.doc.destroy(); +}); diff --git a/apps/server/src/questions/conversation-card-8.test.ts b/apps/server/src/questions/conversation-card-8.test.ts new file mode 100644 index 00000000..18df8415 --- /dev/null +++ b/apps/server/src/questions/conversation-card-8.test.ts @@ -0,0 +1,207 @@ +import { expect, spyOn, test } from "bun:test"; + +import * as Questions from "./service"; +import * as Store from "./store"; + +import { openPlan } from "../testing/plan"; + +import type { Socket } from "../wire"; +import { createConversationCardFixture } from "./conversation-card.test-fixtures"; +import type { Plan } from "../plan/service"; + +let plans: Plan[]; +let fixture = createConversationCardFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION, SECOND, input, source } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks. +test("a committed Save still wakes its durable prose outbox if its socket closes", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }); + let wakes = 0; + let unlisten = Questions.listen(context.plan, event => { + if (event.kind === "decided") wakes++; + }); + let ws = { + data: { handle: "ana", client: "client-ana", room: "test" }, + send() { + throw new Error("socket closed"); + }, + publish() {}, + } as unknown as Socket; + let errors = spyOn(console, "error").mockImplementation(() => {}); + try { + await Questions.submit(context.plan, context.server, "test", ws, { + kind: "question:submit", + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }); + } finally { + errors.mockRestore(); + unlisten(); + } + expect(context.plan.records.get(id)?.status).toBe("answered"); + expect(context.plan.conversationPlanPendingEffects).toMatchObject([{ + key: `job:prose:${id}:1`, + }]); + expect(wakes).toBe(1); +}); + +test("only the winning concurrent Save captures a prose job claimant", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([{ id: OPTION, label: "Auth0" }]), + ); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(true); + let captured: string[] = []; + let person = (handle: string) => + ({ + data: { handle, client: `client-${handle}`, room: "test" }, + send() {}, + publish() {}, + }) as unknown as Socket; + let ask = { + kind: "question:submit" as const, + ts: 0, + rid: "save", + id, + revision: 1, + suggestedOptionId: OPTION, + }; + await Promise.all([ + Questions.submit( + context.plan, + context.server, + "test", + person("ana"), + ask, + intent => captured.push(`ana:${intent.trigger}`), + ), + Questions.submit( + context.plan, + context.server, + "test", + person("ben"), + ask, + intent => captured.push(`ben:${intent.trigger}`), + ), + ]); + expect(captured).toHaveLength(1); + expect(captured[0]).toContain(`decided:${id}:1`); + expect(captured[0]?.startsWith(context.plan.records.get(id)?.owner ?? "")).toBe(true); +}); + +test("closed cards and an active implementation refuse server-authored changes", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([ + { id: OPTION, label: "GitHub Apps" }, + ]), + ); + context.plan.execution = { id: "run-1" } as never; + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "Auth0", + origin: "chat", + }), + ).toEqual({ ok: false, reason: "closed" }); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(false); + await expect(Questions.insertConversationCard(context.plan, context.server, "test", { + ...input(), + threadId: "thread-b", + })).rejects.toThrow("implementation is active"); + context.plan.execution = undefined; + context.plan.records.set(id, { ...context.plan.records.get(id)!, status: "discarded" }); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: SECOND, + label: "Auth0", + origin: "chat", + }), + ).toEqual({ ok: false, reason: "closed" }); + expect( + await Questions.suggest(context.plan, context.server, "test", id, { + optionId: OPTION, + messageIds: ["m1"], + }), + ).toBe(false); +}); + +test("involved puts the owner first, then card editors and thread participants", async () => { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard(context.plan, context.server, "test", input()); + context.plan.conversationPlan.threads.push({ + id: "thread-a", + question: "What auth system should we use?", + questionSources: [source("opening")], + questionAuthoring: "quoted", + status: "exploring", + contributions: [{ + id: OPTION, + kind: "option", + text: "GitHub Apps", + authoring: "quoted", + sources: [source("option")], + actor: { kind: "classifier" }, + }], + stances: [{ + id: "stance", + participant: "participant", + position: "support", + sources: [source("stance")], + at: 1, + }], + stanceHistory: [], + decisionHistory: [], + candidates: [], + questionnaireId: id, + version: 1, + }); + let record = context.plan.records.get(id)!; + context.plan.records.set(id, { ...record, owner: "owner", editors: ["editor"] }); + Store.get(context.plan.questions, id)!.editors.add("live"); + expect(Questions.involved(context.plan, context.plan.records.get(id)!)).toEqual([ + "owner", + "editor", + "live", + "opening", + "option", + "stance", + "participant", + ]); +}); diff --git a/apps/server/src/questions/conversation-card.test-fixtures.ts b/apps/server/src/questions/conversation-card.test-fixtures.ts new file mode 100644 index 00000000..e7e2ed2c --- /dev/null +++ b/apps/server/src/questions/conversation-card.test-fixtures.ts @@ -0,0 +1,130 @@ +import { afterEach } from "bun:test"; +import * as Questions from "./service"; +import { initialState } from "../conversation-plan/domain"; +import { applyEvent } from "../conversation-plan/events"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import type { ConversationPlan } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; + +function input(options: Array<{ id: string; label: string }> = []) { + return { + threadId: "thread-a", + header: "Authentication", + question: "What auth system should we use?", + options, + }; +} + +function source(handle: string): ConversationPlan.SourceRef { + return { + messageId: `message-${handle}`, + author: { kind: "member", handle }, + quote: "Auth", + start: 0, + end: 4, + role: "question", + }; +} + +// Exact archive helpers/callback; synchronous per-suite lexical binding wrapper. +export function createConversationCardFixture( + readPlans: () => Plan[], + writePlans: (value: Plan[]) => void, +) { + const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + const SECOND = "01K0N4W3B7P27CBAEC7A8C8WEB"; + + let plans: Plan[] = []; + + async function quotedOption() { + let context = await openPlan(); + plans.push(context.plan); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + "test", + input([ + { id: OPTION, label: "We should use GitHub Apps for auth" }, + { id: SECOND, label: "Use OAuth" }, + ]), + ); + let message = { + id: "quoted-option-message", + author: { kind: "member" as const, handle: "ana" }, + text: "We should use GitHub Apps for auth", + ts: 1, + }; + context.plan.chat.entries.push(message); + let state = applyEvent(initialState(), { + id: "opened", + type: "thread.opened", + threadId: "thread-a", + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at: 1, + question: "What auth system should we use?", + }); + state = applyEvent(state, { + id: "quoted", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: { + messageId: message.id, + author: message.author, + quote: message.text, + start: 0, + end: message.text.length, + role: "option", + }, + contribution: { + id: OPTION, + text: message.text, + authoring: "quoted", + targetId: "thread-a", + }, + }); + state = applyEvent(state, { + id: "second", + type: "option.added", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "planner", + actor: { kind: "agent" }, + at: 3, + contribution: { id: SECOND, text: "Use OAuth", authoring: "scribe", targetId: "thread-a" }, + }); + state = applyEvent(state, { + id: "linked", + type: "card.linked", + threadId: "thread-a", + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 4, + questionnaireId: id, + }); + context.plan.conversationPlan = state; + return { context, id, state }; + } + + let cleanup = async () => { + for (let plan of plans) await Service.close(plan); + plans = []; + }; + afterEach(async () => { + plans = readPlans(); + try { + await cleanup(); + } finally { + writePlans(plans); + } + }); + return { OPTION, SECOND, plans, input, source, quotedOption }; +} diff --git a/apps/server/src/questions/decision-anchor-lifecycle.test.ts b/apps/server/src/questions/decision-anchor-lifecycle.test.ts new file mode 100644 index 00000000..0d44f6cb --- /dev/null +++ b/apps/server/src/questions/decision-anchor-lifecycle.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Questions from "./service"; +import { createDecisionProseFixture } from "../agent/decision-prose.test-fixtures"; + +function assertAt( + document: room.Document, + anchor: ReturnType, + index: number, +) { + expect(anchor.orphaned).toBeUndefined(); + expect(room.matchesAnchor(document, anchor, index)).toBe(true); + expect(anchor.digest).toBe(room.digests(document)[index]); +} + +let contexts: ReturnType["contexts"]; +let fixture = createDecisionProseFixture(() => contexts, value => contexts = value); +contexts = fixture.contexts; + +test("two staged decision prose jobs keep separate anchors through replacement and Planner append", async () => { + let { CARD, OTHER_WIDGET, WIDGET, opened, job, proseTool, input } = fixture; + let second = "01K0N4X2M5R8T3VQ7YB6ZC4DEF"; + let context = await opened(`Context.\n\n${WIDGET}\n${OTHER_WIDGET}`); + let original = context.plan.records.get(CARD)!; + context.plan.records.set( + second, + Questions.normalizeRecord({ + ...original, + id: second, + threadId: "t2", + definition: { + questions: original.definition.questions.map(question => ({ + ...question, + id: "01K0N4Y2M5R8T3VQ7YB6ZC4DEF", + options: question.options.map(option => ({ + ...option, + id: "01K0N4Z2M5R8T3VQ7YB6ZC4DEF", + })), + })), + }, + choices: ["01K0N4Z2M5R8T3VQ7YB6ZC4DEF"], + }), + ); + let tool = proseTool(context); + let check = (first: number, secondIndex?: number) => { + assertAt(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, first); + if (secondIndex !== undefined) { + assertAt(context.plan.document, context.plan.records.get(second)!.prose![0]!, secondIndex); + } + }; + job(context); + expect( + JSON.parse(String(await tool.handler(input(context, "Sentry monitoring."), {} as never))).ok, + ).toBe(true); + check(1); + context.plan.chat.job = { + id: `prose:${second}:decided:${second}:1`, + kind: "prose", + target: second, + trigger: `decided:${second}:1`, + status: "running", + attempts: 0, + at: "2026-09-25T10:00:00.000Z", + }; + expect( + JSON.parse( + String( + await tool.handler( + { revision: context.plan.revision, id: second, text: "Slack alerts." }, + {} as never, + ), + ), + ).ok, + ).toBe(true); + check(1, 3); + job(context); + expect( + JSON.parse(String(await tool.handler(input(context, "Grafana monitoring."), {} as never))).ok, + ).toBe(true); + check(1, 3); + let before = room.project(context.plan.document); + let outcome = edit.apply(context.plan, context.plan.revision, [{ + op: "insert", + index: room.digests(context.plan.document).length - 1, + source: "- Browser one\n- Browser two\n- Browser three\n", + }]); + expect(outcome.ok).toBe(true); + Questions.rebase(context.plan, before); + check(1, 3); + await Service.persist(context.plan); + let reopened = await Service.open(context.channel.id, context.backend, context.server); + try { + assertAt(reopened.document, reopened.records.get(CARD)!.prose![0]!, 1); + assertAt(reopened.document, reopened.records.get(second)!.prose![0]!, 3); + } finally { + await Service.close(reopened); + } +}); diff --git a/apps/server/src/questions/option-match.ts b/apps/server/src/questions/option-match.ts new file mode 100644 index 00000000..5dacb534 --- /dev/null +++ b/apps/server/src/questions/option-match.ts @@ -0,0 +1,45 @@ +function concepts(label: string): string { + let words: string[] = label.normalize("NFKC").toLocaleLowerCase().match(/[a-z0-9]+/g) ?? []; + let action = ["use", "using", "choose"].includes(words[0] ?? ""); + if (action) words.shift(); + if (action && words.includes("for")) words = words.slice(0, words.indexOf("for")); + let normalized = words.map(word => + word.length >= 4 && word.endsWith("s") && !word.endsWith("ss") + ? word.slice(0, -1) + : word + ).filter(word => !["a", "an", "the", "and"].includes(word)); + // Browser-native can describe an API family; Native can also be part of a product name. + if ( + normalized.includes("api") + && normalized.filter(word => word !== "browser" && word !== "native").length >= 3 + ) { + normalized = normalized.filter(word => word !== "browser" && word !== "native"); + } + return normalized.join(" "); +} + +export function grouped(label: string): boolean { + let slashParts = label.split(/(?<=[a-z0-9])\s*\/\s*(?=[a-z0-9])/i); + return /\b(?:or|versus|vs\.?)\b/i.test(label) + || /,[^,]+,/.test(label) + || /,[^,]+\band\b/i.test(label) + || slashParts.length >= 3 + || (slashParts.length === 2 && /\s\/|\/\s/.test(label)); +} + +export function sameApproach(left: string, right: string): boolean { + let a = concepts(left); + let b = concepts(right); + if (!a || !b) { + return left.trim().toLocaleLowerCase() === right.trim().toLocaleLowerCase(); + } + return a === b; +} + +export function atomicOptionQuote(quote: string, label: string): boolean { + if (grouped(quote)) return false; + if (sameApproach(quote, label)) return true; + let words = quote.normalize("NFKC").toLocaleLowerCase().match(/[a-z0-9]+/g) ?? []; + return words[0] === "just" && words[1] === "native" && words.length >= 4 + && sameApproach(words.slice(1).join(" "), label); +} diff --git a/apps/server/src/questions/prose-1.test.ts b/apps/server/src/questions/prose-1.test.ts new file mode 100644 index 00000000..c19961a7 --- /dev/null +++ b/apps/server/src/questions/prose-1.test.ts @@ -0,0 +1,211 @@ +import { expect, it } from "bun:test"; +import { $createParagraphNode, $createTextNode, $getRoot, $isParagraphNode } from "lexical"; + +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; + +import * as Prose from "./prose"; + +import type { Plan as RoomPlan } from "../plan/service"; + +import { subject } from "./prose.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +it("keeps the original Yjs block when a person edits it, even beside identical prose", async () => { + let { document, anchor } = await subject( + "# Title\n\nDecision prose.\n\nDecision prose.\n", + ); + let original = room.resolveAnchor(document, anchor); + try { + document.editor.update(() => { + let paragraph = $getRoot().getChildren()[2]; + if (!$isParagraphNode(paragraph)) throw new Error("missing paragraph"); + paragraph.append($createTextNode(" People may add organisations later.")); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBeUndefined(); + expect(room.resolveAnchor(document, carried!)).toBe(original); + expect(carried?.digest).toBe(room.digests(document)[2]); + expect(carried?.digest).not.toBe(room.digests(document)[1]); + } finally { + document.doc.destroy(); + } +}); + +it("follows a moved paragraph when its old Yjs position is gone", async () => { + let { document, anchor } = await subject(); + try { + let plan = { document, revision: 1, outlines: new Map() } as RoomPlan; + edit.apply(plan, 1, [{ op: "move", index: 2, to: 1 }]); + expect(room.resolveAnchor(document, anchor)).toBeUndefined(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBeUndefined(); + expect(room.matchesAnchor(document, carried!, 1)).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("keeps a human-moved paragraph at its new position", async () => { + let { document, anchor } = await subject(); + try { + document.editor.update(() => { + let children = $getRoot().getChildren(); + children[1]!.insertBefore(children[2]!); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBeUndefined(); + expect(room.matchesAnchor(document, carried!, 1)).toBe(true); + expect(carried?.digest).toBe(room.digests(document)[1]); + } finally { + document.doc.destroy(); + } +}); + +it("orphans a deleted paragraph instead of finding a different block", async () => { + let { document, anchor } = await subject(); + try { + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor]); + expect(carried?.orphaned).toBe(true); + expect(Prose.orphaned(carried ? [carried] : [])).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("does not retarget a deleted paragraph to its identical surviving neighbour", async () => { + let { document, anchor } = await subject("# Title\n\nSame.\n\nSame.\n"); + let previous = room.project(document); + try { + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + + let [carried] = Prose.carry(document, [anchor], previous); + expect(room.project(document)).toBe("# Title\n\nSame.\n"); + expect(carried?.orphaned).toBe(true); + expect(room.resolveAnchor(document, carried!)).toBeUndefined(); + } finally { + document.doc.destroy(); + } +}); + +it("recovers a unique paragraph pasted in the next document edit", async () => { + let { document, anchor } = await subject(); + try { + let beforeCut = room.project(document); + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + let [cut] = Prose.carry(document, [anchor], beforeCut); + expect(cut).toMatchObject({ orphaned: true, recoverOnNextEdit: true }); + + let beforePaste = room.project(document); + document.editor.update(() => { + $getRoot().append($createParagraphNode().append($createTextNode("Decision prose."))); + }, { discrete: true }); + await room.settle(); + let [pasted] = Prose.carry(document, [cut!], beforePaste); + expect(pasted?.orphaned).toBeUndefined(); + expect(pasted?.recoverOnNextEdit).toBeUndefined(); + expect(room.matchesAnchor(document, pasted!, 2)).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("does not recover after an unrelated edit or from an originally duplicate block", async () => { + let { document, anchor } = await subject(); + try { + let beforeCut = room.project(document); + document.editor.update(() => $getRoot().getChildren()[2]!.remove(), { discrete: true }); + await room.settle(); + let [cut] = Prose.carry(document, [anchor], beforeCut); + let beforeUnrelated = room.project(document); + document.editor.update(() => { + $getRoot().append($createParagraphNode().append($createTextNode("Unrelated."))); + }, { discrete: true }); + await room.settle(); + let [expired] = Prose.carry(document, [cut!], beforeUnrelated); + expect(expired?.recoverOnNextEdit).toBeUndefined(); + let beforeLater = room.project(document); + document.editor.update(() => { + $getRoot().append($createParagraphNode().append($createTextNode("Decision prose."))); + }, { discrete: true }); + await room.settle(); + let [later] = Prose.carry(document, [expired!], beforeLater); + expect(later?.orphaned).toBe(true); + } finally { + document.doc.destroy(); + } + + let duplicate = await subject("# Title\n\nDecision prose.\n\nDecision prose.\n"); + try { + let beforeCut = room.project(duplicate.document); + duplicate.document.editor.update(() => $getRoot().getChildren()[2]!.remove(), { + discrete: true, + }); + await room.settle(); + let [cut] = Prose.carry(duplicate.document, [duplicate.anchor], beforeCut); + expect(cut?.orphaned).toBe(true); + expect(cut?.recoverOnNextEdit).toBeUndefined(); + } finally { + duplicate.document.doc.destroy(); + } +}); + +it("does not leave recovery open when the current prose is ambiguous", async () => { + let { document, anchor } = await subject(); + try { + let previous = room.project(document); + document.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + for (let index = 0; index < 2; index++) { + $getRoot().append($createParagraphNode().append($createTextNode("Decision prose."))); + } + }, { discrete: true }); + await room.settle(); + let [ambiguous] = Prose.carry(document, [anchor], previous); + expect(ambiguous?.orphaned).toBe(true); + expect(ambiguous?.recoverOnNextEdit).toBeUndefined(); + let beforeDelete = room.project(document); + document.editor.update(() => $getRoot().getChildren()[3]!.remove(), { + discrete: true, + }); + await room.settle(); + let [remaining] = Prose.carry(document, [ambiguous!], beforeDelete); + expect(remaining?.orphaned).toBe(true); + } finally { + document.doc.destroy(); + } +}); + +it("orphans an unresolved position when the digest matches two blocks", async () => { + let { document, anchor } = await subject(); + try { + let rotated = await room.replace( + "# Title\n\nDecision prose.\n\nFirst.\n\nDecision prose.\n", + ); + try { + let [carried] = Prose.carry(rotated, [anchor]); + expect(carried?.orphaned).toBe(true); + expect(room.resolveAnchor(rotated, carried!)).toBeUndefined(); + } finally { + rotated.doc.destroy(); + } + } finally { + document.doc.destroy(); + } +}); diff --git a/apps/server/src/questions/prose-2.test.ts b/apps/server/src/questions/prose-2.test.ts new file mode 100644 index 00000000..87382ec1 --- /dev/null +++ b/apps/server/src/questions/prose-2.test.ts @@ -0,0 +1,179 @@ +import { expect, it } from "bun:test"; +import { $getRoot } from "lexical"; +import * as Y from "yjs"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; + +import * as Prose from "./prose"; +import { normalizeRecord } from "./records"; +import * as Questions from "./service"; +import type { Plan } from "@chopin/protocol"; +import type { Plan as RoomPlan } from "../plan/service"; + +import { cardWithProse, peerFrames, subject } from "./prose.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +it("keeps distinct identical paragraphs across a same-history epoch rebuild", async () => { + let { document, anchor } = await subject("# Title\n\nSame.\n\nSame.\n"); + try { + let rebuilt = await room.rebuild(document); + try { + let [carried] = Prose.carry(rebuilt, [anchor], room.project(document)); + expect(carried?.orphaned).toBeUndefined(); + expect(carried?.epoch).toBe(rebuilt.epoch); + expect(room.matchesAnchor(rebuilt, carried!, 2)).toBe(true); + } finally { + rebuilt.doc.destroy(); + } + } finally { + document.doc.destroy(); + } +}); + +it("does not call absent prose orphaned", () => { + expect(Prose.orphaned(undefined)).toBe(false); + expect(Prose.orphaned([])).toBe(false); + let anchors: Plan.Anchor[] = [{ + epoch: "old", + position: "AA==", + digest: `sha256:${"0".repeat(64)}`, + orphaned: true, + }]; + expect(Prose.orphaned(anchors)).toBe(true); +}); + +it("restores only orphaned prose with a one-edit recovery hint", async () => { + let context = await cardWithProse(); + try { + let record = context.plan.records.get(context.id)!; + let recoverable = { + ...context.anchor, + orphaned: true as const, + recoverOnNextEdit: true as const, + }; + expect(normalizeRecord({ ...record, prose: [recoverable] }).prose).toEqual([recoverable]); + expect(() => + normalizeRecord({ + ...record, + prose: [{ ...context.anchor, recoverOnNextEdit: true }], + }) + ).toThrow(/invalid question record/); + } finally { + await Service.close(context.plan); + } +}); + +it("persists prose and exposes it in open and relationship snapshots", async () => { + let context = await cardWithProse(); + let opened: RoomPlan | undefined = context.plan; + try { + expect(Questions.prose(context.plan)).toEqual([{ + widget: context.id, + anchors: [context.anchor], + orphaned: false, + }]); + expect( + Object.values(Questions.anchors(context.plan)[0]!.questions).every(set => + set.anchors.length === 0 + ), + ).toBe(true); + expect(Questions.meta(context.plan, context.plan.records.get(context.id)!)) + .toMatchObject({ hasProse: true, proseOrphaned: false }); + + let { ws, frames } = peerFrames(); + Service.greet(context.plan, ws, { kind: "plan:open", ts: 0, rid: "open" }); + expect(frames.at(-1)).toMatchObject({ + kind: "plan:open", + prose: [{ widget: context.id, orphaned: false }], + }); + Service.anchors(context.plan, context.server, context.channel.id); + expect(context.broadcasts.at(-2)).toMatchObject({ + kind: "plan:anchors", + prose: [{ widget: context.id, orphaned: false }], + }); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + id: context.id, + meta: { hasProse: true, proseOrphaned: false }, + }); + + await Service.close(opened); + opened = undefined; + let restored = await Service.open(context.channel.id, context.backend, context.server); + opened = restored; + expect(Questions.prose(restored)).toMatchObject([{ + widget: context.id, + orphaned: false, + }]); + expect(room.matchesAnchor(restored.document, restored.records.get(context.id)!.prose![0]!, 1)) + .toBe(true); + } finally { + if (opened) await Service.close(opened); + } +}); + +it("publishes orphaned prose metadata only after a human deletion commits", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + let peer = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + room.project(context.plan.document), + [], + ); + try { + let vector = Y.encodeStateVector(context.plan.document.doc); + peer.editor.update(() => { + $getRoot().getChildren()[2]!.remove(); + }, { discrete: true }); + await room.settle(); + let update = Y.encodeStateAsUpdate(peer.doc, vector); + let original = context.storage.collaboration.commit; + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + await blocked; + return original(input); + }; + let { ws } = peerFrames(); + let before = context.broadcasts.length; + try { + Service.submit(context.plan, ws, { + kind: "plan:update", + ts: 0, + rid: "delete", + id: "delete", + epoch: context.plan.document.epoch, + update: Buffer.from(update).toString("base64"), + }); + await Bun.sleep(20); + expect( + context.broadcasts.slice(before).filter(frame => + frame.kind === "question:meta" || frame.kind === "plan:anchors" + ), + ).toEqual([]); + } finally { + release!(); + await context.plan.flushing; + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.plan.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + expect(room.project(context.plan.document).split("Same.")).toHaveLength(2); + expect(Questions.meta(context.plan, context.plan.records.get(context.id)!)) + .toMatchObject({ hasProse: false, proseOrphaned: true }); + expect(context.broadcasts.slice(before)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "plan:anchors", + prose: [{ widget: context.id, anchors: expect.any(Array), orphaned: true }], + }), + expect.objectContaining({ + kind: "question:meta", + id: context.id, + meta: expect.objectContaining({ hasProse: false, proseOrphaned: true }), + }), + ])); + } finally { + peer.doc.destroy(); + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/questions/prose-3.test.ts b/apps/server/src/questions/prose-3.test.ts new file mode 100644 index 00000000..c2fcb6cd --- /dev/null +++ b/apps/server/src/questions/prose-3.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from "bun:test"; + +import * as Y from "yjs"; +import * as edit from "../plan/edit"; +import * as room from "../plan/room"; +import * as Service from "../plan/service"; + +import * as Questions from "./service"; +import type { Plan } from "@chopin/protocol"; +import type { Plan as RoomPlan } from "../plan/service"; + +import { cardWithProse } from "./prose.test-fixtures"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +it("publishes orphaned metadata after a Planner edit's relationship snapshot", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + try { + let outcome = edit.apply(context.plan, context.plan.revision, [{ op: "delete", index: 2 }]); + if (!outcome.ok || !outcome.mutation) throw new Error("expected a paragraph deletion"); + Questions.rebase(context.plan); + expect(Questions.prose(context.plan)[0]?.orphaned).toBe(true); + expect(room.project(context.plan.document).split("Same.")).toHaveLength(2); + let before = context.broadcasts.length; + let original = context.storage.collaboration.commit; + let release: (() => void) | undefined; + let blocked = new Promise(resolve => release = resolve); + (context.storage.collaboration as { commit: typeof original }).commit = async input => { + await blocked; + return original(input); + }; + try { + let publishing = Service.publish( + context.plan, + context.server, + context.channel.id, + outcome.mutation, + ).then(() => Service.anchors(context.plan, context.server, context.channel.id)); + await Bun.sleep(10); + expect(context.broadcasts).toHaveLength(before); + release!(); + await publishing; + } finally { + release!(); + (context.storage.collaboration as { commit: typeof original }).commit = original; + } + expect(context.broadcasts.slice(before).map(frame => frame.kind)).toEqual([ + "plan:update", + "plan:anchors", + "question:meta", + ]); + expect(context.broadcasts.at(-1)).toMatchObject({ + kind: "question:meta", + id: context.id, + meta: { hasProse: false, proseOrphaned: true }, + }); + } finally { + await Service.close(context.plan); + } +}); + +it("keeps the pre-edit source when a staged document deletes duplicate prose", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + let staged = await room.restore( + context.plan.document.epoch, + Y.encodeStateAsUpdate(context.plan.document.doc), + room.project(context.plan.document), + [], + ); + staged.seq = context.plan.document.seq; + try { + let candidate = { + ...context.plan, + document: staged, + records: new Map(context.plan.records), + } as RoomPlan; + let outcome = edit.apply(candidate, candidate.revision, [{ op: "delete", index: 2 }]); + if (!outcome.ok || !outcome.mutation) throw new Error("expected a paragraph deletion"); + Questions.rebase(candidate); + expect(candidate.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + await Service.publishStaged( + context.plan, + context.server, + context.channel.id, + candidate, + outcome.mutation, + ); + expect(context.plan.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + let persisted = await context.storage.collaboration.load(context.channel.id, context.now); + let sidecar = persisted?.sidecar as { + questions: Array<{ id: string; prose?: Plan.Anchor[] }>; + } | undefined; + expect(sidecar?.questions.find(item => item.id === context.id)?.prose?.[0]?.orphaned) + .toBe(true); + } finally { + staged.doc.destroy(); + await Service.close(context.plan); + } +}); + +it("uses the pre-rewrite source when replacing a document with duplicate prose", async () => { + let context = await cardWithProse("# Title\n\nSame.\n\nSame.\n", 2); + try { + let previous = room.project(context.plan.document); + let next = previous.replace("Same.\n\nSame.\n", "Same.\n"); + let outcome = await Service.rewrite(context.plan, next, (source, revision) => ({ + idempotencyKey: "rewrite-prose", + fingerprint: "rewrite-prose", + fromRevision: context.plan.revision, + client: { name: "test", version: "1" }, + document: { source, revision, title: "Title", url: "https://example.test" }, + })); + expect(outcome.ok).toBe(true); + expect(room.project(context.plan.document).split("Same.")).toHaveLength(2); + expect(context.plan.records.get(context.id)?.prose?.[0]?.orphaned).toBe(true); + } finally { + await Service.close(context.plan); + } +}); + +it("rebases a surviving prose anchor across an epoch change", async () => { + let context = await cardWithProse(); + let old = context.plan.document; + try { + let rebuilt = await room.rebuild(old); + context.plan.document = rebuilt; + Questions.rebase(context.plan); + let prose = Questions.prose(context.plan); + expect(prose[0]?.anchors[0]?.epoch).toBe(rebuilt.epoch); + expect(prose[0]?.orphaned).toBe(false); + expect(room.matchesAnchor(rebuilt, prose[0]!.anchors[0]!, 1)).toBe(true); + } finally { + old.doc.destroy(); + await Service.close(context.plan); + } +}); diff --git a/apps/server/src/questions/prose.test-fixtures.ts b/apps/server/src/questions/prose.test-fixtures.ts new file mode 100644 index 00000000..cae5bd77 --- /dev/null +++ b/apps/server/src/questions/prose.test-fixtures.ts @@ -0,0 +1,50 @@ +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; + +import * as Questions from "./service"; + +import type { Socket } from "../wire"; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export async function subject(source = "# Title\n\nFirst.\n\nDecision prose.\n") { + let document = await room.create(source); + let anchor = room.anchorAt(document, 2, room.digests(document)[2]!); + return { document, anchor }; +} + +export async function cardWithProse(source = "# Title\n\nDecision prose.\n", index = 1) { + let context = await openPlan(source); + let id = await Questions.insertConversationCard( + context.plan, + context.server, + context.channel.id, + { + threadId: "thread-a", + header: "Authentication", + question: "Which authentication approach?", + options: [{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "GitHub Apps" }], + }, + ); + let anchor = room.anchorAt( + context.plan.document, + index, + room.digests(context.plan.document)[index]!, + ); + Questions.setProse(context.plan, id, [anchor]); + await Service.persist(context.plan); + return { ...context, id, anchor }; +} + +export function peerFrames() { + let frames: Array> = []; + let ws = { + data: { handle: "ana", client: "client-ana", room: "test", canEdit: true }, + send(raw: string) { + frames.push(JSON.parse(raw) as Record); + }, + publish() {}, + close() {}, + } as unknown as Socket; + return { ws, frames }; +} diff --git a/apps/server/src/questions/prose.ts b/apps/server/src/questions/prose.ts new file mode 100644 index 00000000..f8b76095 --- /dev/null +++ b/apps/server/src/questions/prose.ts @@ -0,0 +1,60 @@ +/** The paragraph a decided card became, carried without a pending-review state. */ + +import { parse, serialize } from "@chopin/dialect"; +import * as room from "../plan/room"; + +import type { Plan } from "@chopin/protocol"; + +/** + * Prefer the live Yjs block. Durable callers supply the prior source, so a + * lost position may recover by digest only when that digest was unique before + * the edit as well as after it. Otherwise deleting one of two identical + * paragraphs could steal the survivor. Without prior source, retain the + * original two-argument rebasing behavior for direct callers. A unique block + * that disappears has one source-changing edit to reappear; an intervening + * edit from anyone consumes that window and leaves the prose orphaned. + */ +export function carry( + document: room.Document, + anchors: Plan.Anchor[], + previousSource?: string, +): Plan.Anchor[] { + if (anchors.length === 0) return anchors; + let currentSource = previousSource === undefined ? undefined : room.project(document); + let previous = new Map(); + if (previousSource !== undefined) { + for (let node of parse(previousSource).children) { + let hash = room.digest(serialize({ type: "root", children: [node] })); + previous.set(hash, (previous.get(hash) ?? 0) + 1); + } + } + return anchors.map(anchor => { + if (anchor.orphaned) { + if (!anchor.recoverOnNextEdit) return room.rebase(document, [anchor])[0]!; + let { recoverOnNextEdit: _recover, orphaned: _orphaned, ...candidate } = anchor; + if (anchor.epoch !== document.epoch) { + return { ...candidate, epoch: document.epoch, orphaned: true }; + } + if (currentSource === undefined || currentSource === previousSource) return anchor; + return room.rebase(document, [candidate])[0]!; + } + // Rebuilds change the epoch but can retain this exact Yjs history. + let currentEpoch = { ...anchor, epoch: document.epoch }; + let live = room.resolveAnchor(document, currentEpoch); + if (!live && previousSource !== undefined && previous.get(anchor.digest) !== 1) { + return { ...anchor, epoch: document.epoch, orphaned: true }; + } + let rebased = room.rebase(document, [live ? currentEpoch : anchor])[0]!; + return rebased.orphaned && previousSource !== undefined + && previous.get(anchor.digest) === 1 && currentSource !== previousSource + && anchor.epoch === document.epoch + && !room.digests(document).includes(anchor.digest) + ? { ...rebased, recoverOnNextEdit: true } + : rebased; + }); +} + +/** No prose is different from prose whose every block was lost. */ +export function orphaned(anchors: Plan.Anchor[] | undefined): boolean { + return !!anchors?.length && anchors.every(anchor => anchor.orphaned); +} diff --git a/apps/server/src/questions/question-option-source-1.test.ts b/apps/server/src/questions/question-option-source-1.test.ts new file mode 100644 index 00000000..5e57de50 --- /dev/null +++ b/apps/server/src/questions/question-option-source-1.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Questions from "./service"; +import * as Service from "../plan/service"; +import { applyEvent } from "../conversation-plan/events"; +import { initialState } from "../conversation-plan/domain"; +import { openPlan } from "../testing/plan"; +import { createSourceFixture } from "./question-option-source.test-fixtures"; +let plans: Service.Plan[]; +let fixture = createSourceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION_A, OPTION_B, ref, conversationCard } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("question source matching requires the exact saved mention on the card thread", () => { + let source = ref("message-a", "Which authentication provider?"); + let thread = { + id: "thread-a", + questionSources: [source], + } as ConversationPlan.Thread; + expect(Questions.matchesQuestionSource(source, thread)).toBe(true); + expect(Questions.matchesQuestionSource({ ...source, messageId: "message-b" }, thread)) + .toBe(false); + expect(Questions.matchesQuestionSource({ ...source, quote: "Another question?" }, thread)) + .toBe(false); + expect( + Questions.matchesQuestionSource( + { ...source, author: { kind: "member", handle: "lee" } }, + thread, + ), + ) + .toBe(false); + expect(Questions.matchesQuestionSource({ ...source, role: "reason" }, thread)).toBe(false); + expect(Questions.matchesQuestionSource(source, undefined)).toBe(false); +}); + +test("question mentions can source alternatives, persist exactly, and carry no stance", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "Should we use GitHub Apps or OAuth?"; + let { id, questionSource } = await conversationCard(context, "thread-a", question); + for ( + let [optionId, label] of [ + [OPTION_A, "GitHub Apps"], + [OPTION_B, "OAuth"], + ] as const + ) { + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId, + label, + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: true, optionId }); + } + expect(context.plan.records.get(id)!.optionOrigins).toEqual({ + [OPTION_A]: { origin: "planner", source: questionSource }, + [OPTION_B]: { origin: "planner", source: questionSource }, + }); + expect(context.plan.conversationPlan.threads[0]!.stances).toEqual([]); + + await Service.close(context.plan); + plans = []; + let restored = await Service.open(context.channel.id, context.backend, context.server); + plans.push(restored); + expect(restored.records.get(id)!.optionOrigins).toEqual({ + [OPTION_A]: { origin: "planner", source: questionSource }, + [OPTION_B]: { origin: "planner", source: questionSource }, + }); + expect(restored.conversationPlan.threads[0]!.stances).toEqual([]); +}); + +test("unrelated, foreign, mismatched, and unsupported sources cannot add options", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "Should we use GitHub Apps or OAuth?"; + let { id, questionSource } = await conversationCard(context, "thread-a", question); + let foreign = ref("message-thread-b", "Which database should we use?"); + context.plan.chat.entries.push({ + id: foreign.messageId, + author: foreign.author, + text: foreign.quote, + ts: 2, + }); + let foreignThread = applyEvent(initialState(), { + id: "thread-b:opened", + type: "thread.opened", + threadId: "thread-b", + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: foreign, + question: foreign.quote, + }); + context.plan.conversationPlan = { + ...context.plan.conversationPlan, + revision: context.plan.conversationPlan.revision + foreignThread.revision, + events: [...context.plan.conversationPlan.events, ...foreignThread.events], + threads: [...context.plan.conversationPlan.threads, ...foreignThread.threads], + }; + let substringStart = question.indexOf("GitHub Apps"); + let unrelated = { + ...questionSource, + quote: "GitHub Apps", + start: substringStart, + end: substringStart + "GitHub Apps".length, + }; + let mismatchStart = question.indexOf("OAuth?"); + let mismatchedQuote = { + ...questionSource, + quote: "OAuth?", + start: mismatchStart, + end: mismatchStart + "OAuth?".length, + }; + let invalid = [ + unrelated, + foreign, + mismatchedQuote, + { ...questionSource, role: "support" as const }, + ]; + for (let [index, source] of invalid.entries()) { + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: index === 0 ? OPTION_A : OPTION_B.replace(/.$/, String(index)), + label: `Alternative ${index}`, + origin: "planner", + source, + }), + ).toEqual({ ok: false, reason: "invalid" }); + } + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(context.plan.records.get(id)!.optionOrigins).toEqual({}); + expect(context.plan.conversationPlan.threads[0]!.stances).toEqual([]); +}); + +test("a question mention cannot source an unrelated option label", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "Should we use GitHub Apps or OAuth?"; + let { id, questionSource } = await conversationCard(context, "thread-a", question); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Redis", + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: false, reason: "invalid" }); + expect(context.plan.records.get(id)!.definition.questions[0]!.options).toEqual([]); + expect(context.plan.records.get(id)!.optionOrigins).toEqual({}); +}); + +test("the exact D02 hosting question sources both action-prefixed card options", async () => { + let context = await openPlan(); + plans.push(context.plan); + let question = "hosting for the beta: managed app service or our own server?"; + let { id, questionSource } = await conversationCard(context, "thread-hosting", question); + + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Redis", + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: false, reason: "invalid" }); + expect( + await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Host our own server on a managed app service", + origin: "planner", + source: questionSource, + }), + ).toEqual({ ok: false, reason: "invalid" }); + + let managed = await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_A, + label: "Host the beta on a managed app service", + origin: "planner", + source: questionSource, + }); + let ownServer = await Questions.addServerOption(context.plan, context.server, "test", id, { + optionId: OPTION_B, + label: "Host the beta on our own server", + origin: "planner", + source: questionSource, + }); + expect({ managed, ownServer }).toEqual({ + managed: { ok: true, optionId: OPTION_A }, + ownServer: { ok: true, optionId: OPTION_B }, + }); + + expect(context.plan.records.get(id)!.optionOrigins).toEqual({ + [OPTION_A]: { origin: "planner", source: questionSource }, + [OPTION_B]: { origin: "planner", source: questionSource }, + }); + expect(context.plan.conversationPlan.threads[0]!.stances).toEqual([]); +}); diff --git a/apps/server/src/questions/question-option-source-2.test.ts b/apps/server/src/questions/question-option-source-2.test.ts new file mode 100644 index 00000000..74dd05d8 --- /dev/null +++ b/apps/server/src/questions/question-option-source-2.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Questions from "./service"; +import * as Service from "../plan/service"; + +import { openPlan } from "../testing/plan"; +import { createSourceFixture } from "./question-option-source.test-fixtures"; +let plans: Service.Plan[]; +let fixture = createSourceFixture(() => plans, value => { + plans = value; +}); +plans = fixture.plans; +let { OPTION_A, conversationCard } = fixture; + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +test("restore rejects a question source saved only on another card's thread", async () => { + let context = await openPlan(); + plans.push(context.plan); + let first = await conversationCard(context, "thread-a", "Should we use GitHub Apps?"); + let second = await conversationCard(context, "thread-b", "Which database?"); + expect( + await Questions.addServerOption(context.plan, context.server, "test", first.id, { + optionId: OPTION_A, + label: "GitHub Apps", + origin: "planner", + source: first.questionSource, + }), + ).toEqual({ ok: true, optionId: OPTION_A }); + await Service.close(context.plan); + plans = []; + + let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!; + let sidecar = structuredClone(loaded.sidecar ?? loaded.snapshot!.sidecar) as Record< + string, + unknown + >; + let questions = sidecar.questions as Array<{ + id: string; + optionOrigins: Record; + }>; + questions.find(record => record.id === first.id)!.optionOrigins[OPTION_A]!.source = + second.questionSource; + await context.storage.collaboration.commit({ + channelId: context.channel.id, + lease: context.lease, + expectedRevision: loaded.channel.revision, + operationId: "foreign-question-option-source", + epoch: loaded.snapshot!.epoch, + sidecar: sidecar as never, + events: [], + now: context.now, + }); + await expect(Service.open(context.channel.id, context.backend, context.server)).rejects.toThrow( + "hosted channel has option question source outside its card thread", + ); +}); diff --git a/apps/server/src/questions/question-option-source.test-fixtures.ts b/apps/server/src/questions/question-option-source.test-fixtures.ts new file mode 100644 index 00000000..74133dbf --- /dev/null +++ b/apps/server/src/questions/question-option-source.test-fixtures.ts @@ -0,0 +1,106 @@ +import { afterEach } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import * as Questions from "./service"; +import * as Service from "../plan/service"; +import { applyEvent } from "../conversation-plan/events"; +import { initialState } from "../conversation-plan/domain"; +import { openPlan } from "../testing/plan"; + +function ref( + messageId: string, + text: string, + role: ConversationPlan.SourceRole = "question", +): ConversationPlan.SourceRef { + return { + messageId, + author: { kind: "member", handle: "ana" }, + quote: text, + start: 0, + end: text.length, + role, + }; +} + +// Whole archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 callbacks and data; import/fixture wrappers only. +export function createSourceFixture( + readPlans: () => Service.Plan[], + writePlans: (value: Service.Plan[]) => void, +) { + const OPTION_A = "01K0N4W3B7P27CBAEC7A8C8WEA"; + + const OPTION_B = "01K0N4W3B7P27CBAEC7A8C8WEB"; + + let plans: Service.Plan[] = []; + + async function linkQuestion( + context: Awaited>, + id: string, + threadId: string, + questionSource: ConversationPlan.SourceRef, + ) { + let message = { + id: questionSource.messageId, + author: questionSource.author, + text: questionSource.quote, + ts: 1, + }; + context.plan.chat.entries.push(message); + let state = applyEvent(initialState(), { + id: `${threadId}:opened`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: questionSource, + question: questionSource.quote, + }); + state = applyEvent(state, { + id: `${threadId}:linked`, + type: "card.linked", + threadId, + observedThreadVersion: state.threads[0]!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + questionnaireId: id, + }); + let earlier = context.plan.conversationPlan; + context.plan.conversationPlan = { + ...earlier, + revision: earlier.revision + state.revision, + events: [...earlier.events, ...state.events], + threads: [...earlier.threads, ...state.threads], + }; + } + + async function conversationCard( + context: Awaited>, + threadId: string, + question: string, + ) { + let id = await Questions.insertConversationCard(context.plan, context.server, "test", { + threadId, + header: "Authentication", + question, + options: [], + }); + let questionSource = ref(`message-${threadId}`, question); + await linkQuestion(context, id, threadId, questionSource); + return { id, questionSource }; + } + let cleanup = async () => { + for (let plan of plans) await Service.close(plan); + plans = []; + }; + afterEach(async () => { + plans = readPlans(); + try { + await cleanup(); + } finally { + writePlans(plans); + } + }); + return { OPTION_A, OPTION_B, plans, ref, linkQuestion, conversationCard }; +} diff --git a/apps/server/src/questions/service-append-option.ts b/apps/server/src/questions/service-append-option.ts new file mode 100644 index 00000000..da5871fd --- /dev/null +++ b/apps/server/src/questions/service-append-option.ts @@ -0,0 +1,188 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import * as Store from "./store"; +import { announce, emit, pending } from "./card-notifications"; +import { isOpenStatus } from "./records"; +import { OptionCapacityError } from "../conversation-plan/option-capacity"; +import { broadcast, fail, reply } from "../wire"; + +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +/** Append a keyed shared option through the same fenced staging boundary as card actions. */ +export async function appendOption( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + let refuse = (reason: Wire.AddOption.Refusal, message: string): Wire.AddOption.Reply => ({ + kind: "question:option", + ts: 0, + id: msg.id, + ok: false, + reason, + message, + }); + if (Service.implementationActive(plan)) { + return reply( + ws, + msg.rid, + refuse("implementation", "An implementation is running; decisions cannot change"), + ); + } + let outcome: Wire.AddOption.Reply | undefined; + let added: Wire.OptionAdded | undefined; + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) { + outcome = refuse("implementation", "An implementation is running; decisions cannot change"); + return; + } + let record = plan.records.get(msg.id); + let entry = Store.get(plan.questions, msg.id); + if (!record || !entry || !isOpenStatus(record.status)) { + outcome = refuse("resolved", "This question has already been decided"); + return; + } + if (entry.definition.questions.length !== 1) { + outcome = refuse("invalid", "Options cannot be added to this questionnaire"); + return; + } + let definition = Question.decision(entry.definition); + let applied = typeof msg.key === "string" && Object.hasOwn(record.appended ?? {}, msg.key) + ? record.appended?.[msg.key] + : undefined; + let existing = applied + ? definition.questions[0].options.find(option => option.id === applied) + : undefined; + if (existing) { + outcome = { + kind: "question:option", + ts: 0, + id: msg.id, + ok: true, + option: existing, + definition, + repeated: true, + }; + return; + } + if (entry.claim) { + outcome = refuse("resolving", "This question is being decided"); + return; + } + let result = Question.appendOption(definition, { + question: msg.question, + key: msg.key, + label: msg.label, + ...(msg.description === undefined ? {} : { description: msg.description }), + }, ulid()); + if (!result.ok) { + outcome = refuse(result.reason, result.message); + return; + } + let originalQuestions = plan.questions; + if (!Store.reserveOption(originalQuestions, msg.id)) { + outcome = refuse("resolving", "This question is being decided"); + return; + } + let stagedDocument: room.Document | undefined; + try { + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(msg.id, { + ...entry, + claim: undefined, + definition: result.definition, + editors: new Set(entry.editors), + suggested: undefined, + }); + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let records = new Map(plan.records); + records.set(msg.id, { + ...record, + definition: result.definition, + appended: { ...record.appended, [msg.key]: result.option.id }, + optionOrigins: { + ...record.optionOrigins, + [result.option.id]: { origin: "human", by: ws.data.handle }, + }, + }); + let candidate: Plan = { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + pendingCardActions: pending(plan, record, { + kind: "option-added", + id: msg.id, + actor: ws.data.handle, + optionId: result.option.id, + label: result.option.label, + origin: "human", + }), + }; + let mutation = room.appendQuestionOption(stagedDocument, msg.id, msg.question, result.option); + if (!mutation) throw new Error("question projection is missing"); + await Service.publishStaged(plan, server, roomId, candidate, mutation); + outcome = { + kind: "question:option", + ts: 0, + id: msg.id, + ok: true, + option: result.option, + definition: result.definition, + }; + added = { + kind: "question:option-added", + ts: 0, + id: msg.id, + question: msg.question, + option: result.option, + definition: result.definition, + by: ws.data.handle, + }; + } finally { + Store.releaseOption(originalQuestions, msg.id); + stagedDocument?.doc.destroy(); + } + }).catch(err => { + if (!(err instanceof OptionCapacityError)) { + console.error("[questions] could not save the option:", err); + } + outcome = err instanceof OptionCapacityError ? refuse(err.reason, err.message) : undefined; + added = undefined; + }); + if (!outcome) return fail(ws, msg.rid, "could not save the option"); + reply(ws, msg.rid, outcome); + if (!added) return; + broadcast(server, roomId, added); + let record = plan.records.get(msg.id); + if (record?.origin === "conversation") { + announce(plan, server, roomId, msg.id); + emit(plan, { + kind: "option-added", + id: msg.id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + optionId: added.option.id, + label: added.option.label, + origin: "human", + }); + } +} diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts new file mode 100644 index 00000000..ccf396a7 --- /dev/null +++ b/apps/server/src/questions/service-ask.ts @@ -0,0 +1,179 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import * as Anchors from "./anchors"; + +import { applyEvent } from "../conversation-plan/events"; + +import { MAX_EVENTS, MAX_THREADS } from "../conversation-plan/validation"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast } from "../wire"; +import type { Server } from "bun"; + +import type { DecisionDefinition, Definition } from "@chopin/question"; +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { SocketData } from "../wire"; +import type { Ended } from "./store"; +import type { Record } from "./records"; + +import { announce } from "./card-notifications"; +import { type AskPlacement, validatePlacement } from "./service-definition"; + +export async function ask( + plan: Plan, + server: Server, + roomId: string, + definition: Definition, + placement?: AskPlacement, + created?: () => void, +): Promise { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (definition.questions.length === 0) { + Question.reject("A questionnaire needs at least one question"); + } + let asked: Array<{ + id: string; + single: DecisionDefinition; + value: room.QuestionnaireInsertion["value"]; + waiting: Promise; + at: room.QuestionnaireInsertion["at"]; + }> = []; + await Service.exclusive(plan, async () => { + let anchors = placement ? validatePlacement(plan, definition, placement) : undefined; + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let questions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let records = new Map(plan.records); + let conversationPlan = plan.conversationPlan; + asked = definition.questions.map((question, index) => { + let single = Question.decision({ questions: [question] }); + // Widget and question identities are deliberately distinct. + let id = ulid(); + // Classifier capacity must not prevent an ordinary Planner questionnaire. + let available = MAX_EVENTS - conversationPlan.events.length - plan.pendingCardActions.length; + let threadId = conversationPlan.threads.length < MAX_THREADS + && question.options.length + 2 <= available + ? ulid() + : undefined; + let at = Math.floor(Date.now() / 1_000); + if (threadId) { + conversationPlan = applyEvent(conversationPlan, { + id: `ask:${id}:opened`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "planner", + actor: { kind: "agent" }, + at, + question: question.question, + }); + for (let option of question.options) { + let version = conversationPlan.threads.find(thread => thread.id === threadId)!.version; + conversationPlan = applyEvent(conversationPlan, { + id: `ask:${id}:option:${option.id}`, + type: "option.added", + threadId, + observedThreadVersion: version, + origin: "planner", + actor: { kind: "agent" }, + at, + contribution: { + id: option.id, + text: option.label, + authoring: "scribe", + targetId: threadId, + }, + }); + } + conversationPlan = applyEvent(conversationPlan, { + id: `ask:${id}:linked`, + type: "card.linked", + threadId, + observedThreadVersion: conversationPlan.threads.find(thread => + thread.id === threadId + )!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at, + questionnaireId: id, + }); + } + let waiting = Store.ask(questions, id, single, id); + let value = { + id, + ...(threadId ? { thread: threadId } : {}), + questions: [{ + id: question.id, + header: question.header, + prompt: question.question, + multiple: question.multiple, + options: question.options.map(option => ({ + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + }], + }; + let record: Record = { + id, + definition: single, + status: "open", + origin: "planner", + ...(threadId ? { threadId } : {}), + history: [], + optionOrigins: {}, + editors: [], + }; + if (anchors) { + record.anchors = Anchors.set(Anchors.read(record), question.id, anchors[index]!); + } + records.set(id, record); + return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; + }); + 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.insertQuestionnaires( + document, + asked.map(item => ({ + value: item.value, + ...(item.at ? { at: item.at } : {}), + })), + ); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document, + questions, + records, + conversationPlan, + }, mutation); + } finally { + document.doc.destroy(); + } + for (let item of asked) { + announce(plan, server, roomId, item.id); + broadcast(server, roomId, { + kind: "question:asked", + ts: 0, + id: item.id, + definition: item.single, + widget: item.id, + }); + } + created?.(); + }); + + return Promise.all(asked.map(item => item.waiting)); +} diff --git a/apps/server/src/questions/service-cancel.ts b/apps/server/src/questions/service-cancel.ts new file mode 100644 index 00000000..e02d2678 --- /dev/null +++ b/apps/server/src/questions/service-cancel.ts @@ -0,0 +1,140 @@ +import * as Y from "yjs"; +import { ConversationCapacityError } from "../conversation-plan/events"; +import * as room from "../plan/room"; +import * as Store from "./store"; +import { announce, emit, pending } from "./card-notifications"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +export async function cancel( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let claimed = Store.claimCancel(plan.questions, msg.id, ws.data.handle); + if (!claimed.ok) { + return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...claimed }); + } + let mutationError: unknown; + let failure: unknown; + let finish: (() => Store.Ended) | undefined; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (plan.questions.open.get(msg.id) !== claimed.claim.entry) { + throw new Error("questionnaire is no longer open"); + } + 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.Mutation | undefined; + try { + mutation = room.removeQuestionnaire(document, msg.id); + } catch (err) { + mutationError = err; + return; + } + let record = plan.records.get(msg.id); + let records = new Map(plan.records); + if (record) { + records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); + } + let questions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let complete = Store.stage(questions, claimed.claim); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document, + questions, + records, + pendingCardActions: record + ? pending(plan, record, { + kind: "discarded", + id: msg.id, + actor: ws.data.handle, + }) + : plan.pendingCardActions, + }, mutation); + finish = complete; + } finally { + document.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (!finish) Store.rollback(plan.questions, claimed.claim); + if (failure) { + return fail( + ws, + msg.rid, + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ? failure.message + : "could not cancel the questionnaire", + ); + } + if (mutationError) { + console.error("[questions] could not remove the node:", mutationError); + return reply(ws, msg.rid, { + kind: "question:cancel", + ts: 0, + id: msg.id, + ok: false, + reason: "resolving", + }); + } + if (!finish) return fail(ws, msg.rid, "could not cancel the questionnaire"); + finish(); + let record = plan.records.get(msg.id); + for ( + let notify of [ + () => + reply(ws, msg.rid, { + kind: "question:cancel", + ts: 0, + id: msg.id, + ok: true, + resolver: ws.data.handle, + }), + () => + broadcast(server, roomId, { + kind: "question:resolved", + ts: 0, + id: msg.id, + status: "cancelled", + resolver: ws.data.handle, + }), + () => announce(plan, server, roomId, msg.id), + () => + emit(plan, { + kind: "discarded", + id: msg.id, + ...(record?.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + }), + ] + ) { + try { + notify(); + } catch (err) { + console.error("[questions] could not announce a cancelled questionnaire:", err); + } + } +} diff --git a/apps/server/src/questions/service-definition.ts b/apps/server/src/questions/service-definition.ts new file mode 100644 index 00000000..5fc1ef3f --- /dev/null +++ b/apps/server/src/questions/service-definition.ts @@ -0,0 +1,71 @@ +import { ulid } from "@chopin/dialect"; +import * as Question from "@chopin/question"; + +import * as room from "../plan/room"; + +import type { Plan as Wired } from "@chopin/protocol"; +import type { Answer, Definition } from "@chopin/question"; + +import type { Plan } from "../plan/service"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export type AskPlacement = { + revision: number; + blocks: Array>; +}; + +export function identify(raw: unknown): Definition { + let definition = Question.normalize(raw); + return { + questions: definition.questions.map(question => ({ + ...question, + id: ulid(), + options: question.options.map(option => ({ ...option, id: ulid() })), + })), + }; +} + +export function decide( + entry: { status: "answered"; answers: Answer[] } | { status: "cancelled" }, + definition: Definition, +): { [question: string]: string } { + if (entry.status !== "answered") return {}; + let out: { [question: string]: string } = {}; + definition.questions.forEach((question, index) => { + let answer = entry.answers[index]; + if (answer) out[question.id] = Question.summarize(answer); + }); + return out; +} + +export function validatePlacement( + plan: Plan, + definition: Definition, + placement: AskPlacement, +): Wired.Anchor[][] { + if (placement.revision !== plan.revision) { + throw new Error("The plan changed; read it again before asking."); + } + if (placement.blocks.length !== definition.questions.length) { + throw new Error("Give one placement for every question."); + } + + let digests = room.digests(plan.document); + return placement.blocks.map(blocks => { + if (blocks.length === 0) { + if (room.hasProse(plan.document)) { + throw new Error("Relate each question to prose, or write its context first."); + } + return []; + } + + return blocks.map(block => { + let current = digests[block.index]; + if (!current) throw new Error(`no block at index ${block.index}`); + if (current !== block.digest) { + throw new Error(`block ${block.index} has changed; read the plan again`); + } + return room.anchorAt(plan.document, block.index, current); + }); + }); +} diff --git a/apps/server/src/questions/service-discard.ts b/apps/server/src/questions/service-discard.ts new file mode 100644 index 00000000..4d21e2bf --- /dev/null +++ b/apps/server/src/questions/service-discard.ts @@ -0,0 +1,143 @@ +import * as Y from "yjs"; +import { ConversationCapacityError } from "../conversation-plan/events"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +import { announce, emit, pending } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export type DiscardResult = + | { ok: true; resolver: string } + | { ok: false; reason: "resolving" } + | { ok: false; reason: "resolved"; status: "discarded"; resolver: string }; + +export async function discard( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let respond = (body: DiscardResult) => + reply(ws, msg.rid, { kind: "question:discard", ts: 0, id: msg.id, ...body }); + let result: DiscardResult | undefined; + let failure: unknown; + let finish: (() => Store.Ended) | undefined; + let live = false; + let threadId: string | undefined; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(msg.id); + if (!record || record.status === "discarded" || record.status === "cancelled") { + result = { + ok: false, + reason: "resolved", + status: "discarded", + resolver: record?.resolver ?? "system", + }; + return; + } + live = isOpenStatus(record.status); + threadId = record.threadId; + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let complete: (() => Store.Ended) | undefined; + if (live) { + let entry = stagedQuestions.open.get(msg.id); + if (entry) { + stagedQuestions.open.set(msg.id, { + ...entry, + editors: new Set(entry.editors), + }); + } + let claimed = Store.claimCancel(stagedQuestions, msg.id, ws.data.handle); + if (!claimed.ok) { + result = claimed.reason === "resolving" + ? { ok: false, reason: "resolving" } + : { + ok: false, + reason: "resolved", + status: "discarded", + resolver: claimed.resolver, + }; + return; + } + complete = Store.stage(stagedQuestions, claimed.claim); + } + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + try { + let mutation = room.projectCard(stagedDocument, msg.id, { status: "discarded" }); + let records = new Map(plan.records); + records.set(msg.id, { ...record, status: "discarded", resolver: ws.data.handle }); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + pendingCardActions: pending(plan, record, { + kind: "discarded", + id: msg.id, + actor: ws.data.handle, + }), + }, mutation); + finish = complete; + result = { ok: true, resolver: ws.data.handle }; + } finally { + stagedDocument.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (failure) { + if ( + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ) { + return fail(ws, msg.rid, failure.message); + } + console.error("[questions] could not discard the card:", failure); + return respond({ ok: false, reason: "resolving" }); + } + if (!result) return respond({ ok: false, reason: "resolving" }); + if (!result.ok) return respond(result); + finish?.(); + respond(result); + if (live) { + broadcast(server, roomId, { + kind: "question:resolved", + ts: 0, + id: msg.id, + status: "cancelled", + resolver: ws.data.handle, + }); + } + announce(plan, server, roomId, msg.id); + emit(plan, { + kind: "discarded", + id: msg.id, + ...(threadId ? { threadId } : {}), + actor: ws.data.handle, + }); +} diff --git a/apps/server/src/questions/service-draft.ts b/apps/server/src/questions/service-draft.ts new file mode 100644 index 00000000..fe7cc888 --- /dev/null +++ b/apps/server/src/questions/service-draft.ts @@ -0,0 +1,117 @@ +import * as Store from "./store"; + +import { fail, relay, reply, tell } from "../wire"; + +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket } from "../wire"; +import type { Questions } from "./store"; + +import { announce, meta } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export function greet(plan: Plan, ws: Socket): void { + let cards = [...plan.records.values()].map(record => ({ + id: record.id, + meta: meta(plan, record), + })); + if (cards.length > 0) tell(ws, { kind: "question:metas", ts: 0, cards }); + let open = Store.outstanding(plan.questions); + if (open.length === 0) return; + tell(ws, { kind: "question:sync", ts: 0, open }); +} + +export function open(plan: Plan, ws: Socket, msg: Request): void { + reply(ws, msg.rid, { kind: "question:open", ts: 0, ...Store.snapshot(plan.questions, msg.id) }); +} + +export async function edit(plan: Plan, ws: Socket, msg: Request): Promise { + let outcome: Store.Edited | undefined; + let failure: unknown; + await Service.exclusive(plan, async () => { + let originalQuestions = plan.questions; + if (!Store.reserveEdit(originalQuestions, msg.id)) { + outcome = Store.edit(originalQuestions, msg.id, msg.patch); + return; + } + try { + let entry = Store.get(originalQuestions, msg.id)!; + let stagedQuestions: Store.Questions = { + open: new Map(originalQuestions.open), + closed: new Map(originalQuestions.closed), + }; + stagedQuestions.open.set(msg.id, { + ...entry, + claim: undefined, + editors: new Set(entry.editors), + }); + outcome = Store.edit(stagedQuestions, msg.id, msg.patch, ws.data.handle); + if (outcome.open && outcome.accepted && outcome.applied) { + let record = plan.records.get(msg.id); + if (!record) throw new Error("card record is missing"); + let records = new Map(plan.records); + records.set(msg.id, { + ...record, + editors: [...new Set([...record.editors, ...stagedQuestions.open.get(msg.id)!.editors])], + }); + await Service.publishStaged(plan, plan.server, plan.id, { + ...plan, + questions: stagedQuestions, + records, + }); + } + } catch (err) { + failure = err; + } finally { + Store.releaseEdit(originalQuestions, msg.id); + } + }); + if (failure || !outcome) { + return fail(ws, msg.rid, "could not save questionnaire draft"); + } + reply(ws, msg.rid, { kind: "question:edit", ts: 0, id: msg.id, ...outcome }); + // A no-op patch is acknowledged but not relayed: peers have nothing to do + // with it and it did not move the revision. + if (!outcome.open || !outcome.accepted || !outcome.applied) return; + relay(ws, { + kind: "question:edit", + ts: 0, + id: msg.id, + open: true, + accepted: true, + applied: true, + revision: outcome.revision, + patch: msg.patch, + editor: ws.data.handle, + }); + announce(plan, plan.server, plan.id, msg.id); +} + +export function focus(plan: Plan, ws: Socket, msg: Wire.Presence.Input): void { + let person = { + client: ws.data.client, + handle: ws.data.handle, + ...(msg.question ? { question: msg.question } : {}), + ...(msg.field ? { field: msg.field } : {}), + }; + if (!Store.focus(plan.questions, msg.id, person)) return; + relay(ws, { kind: "question:presence", ts: 0, ...person, id: msg.id }); +} + +export function away(plan: Plan, ws: Socket): void { + for (let id of Store.away(plan.questions, ws.data.client)) { + relay(ws, { + kind: "question:presence", + ts: 0, + id, + client: ws.data.client, + handle: ws.data.handle, + }); + } +} + +export function shutdown(questions: Questions): void { + Store.shutdown(questions); +} diff --git a/apps/server/src/questions/service-relationships.ts b/apps/server/src/questions/service-relationships.ts new file mode 100644 index 00000000..92512917 --- /dev/null +++ b/apps/server/src/questions/service-relationships.ts @@ -0,0 +1,128 @@ +import * as Anchors from "./anchors"; + +import * as Prose from "./prose"; +import * as room from "../plan/room"; + +import type { Plan as Wired } from "@chopin/protocol"; + +import type { Plan } from "../plan/service"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export function anchors(plan: Plan): Wired.WidgetAnchors[] { + return [...plan.records.values()].map(record => Anchors.read(record)); +} + +export function setProse(plan: Plan, id: string, anchors: Wired.Anchor[]): void { + let record = plan.records.get(id); + if (!record) throw new Error(`no questionnaire ${id}`); + plan.records.set(id, { ...record, prose: anchors }); +} + +export function prose(plan: Plan): Wired.ProseAnchors[] { + let out: Wired.ProseAnchors[] = []; + for (let record of plan.records.values()) { + if (!record.prose?.length) continue; + out.push({ + widget: record.id, + anchors: record.prose, + orphaned: Prose.orphaned(record.prose), + }); + } + return out; +} + +export function rebase(plan: Plan, previousSource = plan.persistence?.committedSource): void { + for (let [id, record] of plan.records) { + let value = Anchors.read(record); + let questions: { [question: string]: Wired.AnchorSet } = {}; + + for (let [question, set] of Object.entries(value.questions)) { + questions[question] = carry(plan, set); + } + + plan.records.set(id, { + ...record, + anchors: { widget: value.widget, questions }, + ...(record.prose ? { prose: Prose.carry(plan.document, record.prose, previousSource) } : {}), + }); + } +} + +export function carry(plan: Plan, set: Wired.AnchorSet): Wired.AnchorSet { + let anchors = room.rebase(plan.document, set.anchors); + let lost = anchors.some(anchor => anchor.orphaned); + return { + anchors, + pending: set.pending || lost, + ...(lost ? { reason: "orphaned" as const } : set.reason ? { reason: set.reason } : {}), + }; +} + +export function invalidate(plan: Plan, reason: Wired.AnchorReason): void { + for (let [id, record] of plan.records) { + plan.records.set(id, { ...record, anchors: Anchors.invalidate(record, reason) }); + } +} + +export function outstanding(plan: Plan): Anchors.Pending[] { + return [...plan.records.values()].flatMap(record => Anchors.pending(record)); +} + +export function relate( + plan: Plan, + widget: string, + question: string, + blocks: Array<{ index: number; digest: string }>, +): string | undefined { + let record = plan.records.get(widget); + if (!record) return `no questionnaire ${widget}`; + + let current = room.digests(plan.document); + let anchors: Wired.Anchor[] = []; + + for (let block of blocks) { + let hash = current[block.index]; + if (!hash) return `no block at index ${block.index}`; + // The digest is how the agent says which block it meant. If it does not + // match, the plan moved between reading and anchoring, and quietly + // anchoring the block that happens to be there now would be worse than + // saying so. + if (hash !== block.digest) return `block ${block.index} has changed; read the plan again`; + anchors.push(room.anchorAt(plan.document, block.index, hash)); + } + + plan.records.set(widget, { + ...record, + anchors: Anchors.set(Anchors.read(record), question, anchors), + }); + return undefined; +} + +export type Placement = { + widget: string; + blocks: Array<{ index: number; digest: string }>; +}; + +export function place(plan: Plan, updates: Placement[]): room.Mutation | undefined { + let records = [...plan.records.values()]; + let placements: room.QuestionnairePlacement[] = []; + let byWidget = new Map(updates.map(update => [update.widget, update])); + + for (let record of records) { + let update = byWidget.get(record.id); + if (!update || record.definition.questions.length !== 1 || update.blocks.length === 0) continue; + + let home = update.blocks[0]!; + let before = records.slice(0, records.indexOf(record)).findLast(candidate => { + if (candidate.definition.questions.length !== 1) return false; + let question = candidate.definition.questions[0]; + if (!question) return false; + let anchor = Anchors.read(candidate).questions[question.id]?.anchors[0]; + return anchor !== undefined && room.matchesAnchor(plan.document, anchor, home.index); + }); + + placements.push({ id: update.widget, at: home, ...(before ? { after: before.id } : {}) }); + } + + return room.placeQuestionnaires(plan.document, placements); +} diff --git a/apps/server/src/questions/service-reopen.ts b/apps/server/src/questions/service-reopen.ts new file mode 100644 index 00000000..30aade5b --- /dev/null +++ b/apps/server/src/questions/service-reopen.ts @@ -0,0 +1,216 @@ +import { rebase } from "./service-relationships"; +import { parse } from "@chopin/dialect"; +import * as Question from "@chopin/question"; +import * as Y from "yjs"; +import { ConversationCapacityError } from "../conversation-plan/events"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +import type { Record } from "./records"; + +import { announce, emit, pending } from "./card-notifications"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export type ReopenResult = + | { ok: true } + | { ok: false; reason: "not-decided" | "resolving" }; + +export async function reopen( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let respond = (body: { ok: true } | { ok: false; reason: "not-decided" | "resolving" }) => + reply(ws, msg.rid, { kind: "question:reopen", ts: 0, id: msg.id, ...body }); + let result: ReopenResult | undefined; + let failure: unknown; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + let record = plan.records.get(msg.id); + if (!record || record.status !== "answered") { + result = { ok: false, reason: "not-decided" }; + return; + } + let selected = new Set(record.choices ?? []); + let known = new Set( + record.definition.questions.flatMap(question => question.options.map(option => option.id)), + ); + if ( + [...selected].some(id => !known.has(id)) + || selected.size !== (record.choices ?? []).length + ) { + result = { ok: false, reason: "resolving" }; + return; + } + let owner = record.owner ?? record.resolver ?? "unknown"; + let at = record.decidedAt ?? record.at ?? Math.floor(Date.now() / 1_000); + let previous: room.CardChange["previous"] = {}; + let text: { [question: string]: string } = {}; + for (let question of record.definition.questions) { + let ids = question.options.filter(option => selected.has(option.id)).map(option => + option.id + ); + if (ids.length > (question.multiple ? Question.limits.MAX_OPTIONS : 1)) { + result = { ok: false, reason: "resolving" }; + return; + } + if (ids.length) { + previous[question.id] = { + choices: ids, + by: owner, + at: new Date(at * 1_000).toISOString(), + }; + } else { + let answer = record.answers?.[question.id]; + if ( + typeof answer !== "string" || !answer.trim() + || answer.length > Question.limits.MAX_CUSTOM + ) { + result = { ok: false, reason: "resolving" }; + return; + } + text[question.id] = answer; + previous[question.id] = { + choices: [], + value: answer, + by: owner, + at: new Date(at * 1_000).toISOString(), + }; + } + } + let stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + try { + let vector = Y.encodeStateVector(stagedDocument.doc); + let previousSource = room.project(stagedDocument); + let candidate: Plan = { + ...plan, + document: stagedDocument, + records: new Map(plan.records), + }; + // Rebase on the staged document so a moved paragraph is found by its + // current live anchor, without changing the live sidecar before commit. + rebase(candidate); + room.projectCard(stagedDocument, msg.id, { + status: "reopened", + clearAnswers: true, + previous, + }); + let prose = candidate.records.get(msg.id)?.prose?.filter(anchor => !anchor.orphaned) ?? []; + if (prose.length === 1) { + let blocks = parse(room.project(stagedDocument)).children; + let matches = room.digests(stagedDocument).flatMap((_, index) => + room.matchesAnchor(stagedDocument, prose[0]!, index) ? [index] : [] + ); + if (matches.length === 1 && blocks[matches[0]!]?.type === "paragraph") { + let index = matches[0]!; + room.placeQuestionnaires(stagedDocument, [{ + id: msg.id, + at: { index, digest: room.digests(stagedDocument)[index]! }, + renew: true, + }]); + } + } + rebase(candidate, previousSource); + let next: Record = { + ...candidate.records.get(msg.id)!, + status: "reopened", + history: [...record.history, { + choices: [...selected], + ...(Object.keys(text).length ? { answers: text } : {}), + owner, + at, + }], + }; + delete next.answers; + delete next.choices; + delete next.resolver; + delete next.at; + delete next.owner; + delete next.decidedAt; + candidate.records.set(msg.id, next); + let questions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + Store.reopen(questions, msg.id, record.definition, msg.id); + await Service.publishStaged(plan, server, roomId, { + ...candidate, + questions, + pendingCardActions: pending(plan, record, { + kind: "reopened", + id: msg.id, + actor: ws.data.handle, + }), + }, { + update: Y.encodeStateAsUpdate(stagedDocument.doc, vector), + source: room.project(stagedDocument), + }); + result = { ok: true }; + // Keep publication in the room queue: a following discard must not + // overtake this fresh draft's metadata and asked announcement. + for ( + let notify of [ + () => respond({ ok: true }), + () => announce(plan, server, roomId, msg.id), + () => + broadcast(server, roomId, { + kind: "question:asked", + ts: 0, + id: msg.id, + definition: record.definition, + widget: msg.id, + }), + () => + emit(plan, { + kind: "reopened", + id: msg.id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + }), + ] + ) { + try { + notify(); + } catch (err) { + console.error("[questions] could not announce a reopened card:", err); + } + } + } finally { + stagedDocument.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (failure) { + if ( + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ) { + return fail(ws, msg.rid, failure.message); + } + console.error("[questions] could not reopen the card:", failure); + return respond({ ok: false, reason: "resolving" }); + } + if (!result) return respond({ ok: false, reason: "resolving" }); + if (!result.ok) respond(result); +} diff --git a/apps/server/src/questions/service-submit.ts b/apps/server/src/questions/service-submit.ts new file mode 100644 index 00000000..35029e2c --- /dev/null +++ b/apps/server/src/questions/service-submit.ts @@ -0,0 +1,222 @@ +import * as Question from "@chopin/question"; +import * as Y from "yjs"; + +import { activeSettleDeferral } from "../conversation-plan/preference"; +import { ConversationCapacityError } from "../conversation-plan/events"; + +import { isOpenStatus } from "./records"; + +import * as room from "../plan/room"; +import * as Store from "./store"; + +import { broadcast, fail, reply } from "../wire"; +import type { Server } from "bun"; +import type { Question as Wire, Request } from "@chopin/protocol"; + +import * as Service from "../plan/service"; +import type { Plan } from "../plan/service"; +import type { Socket, SocketData } from "../wire"; + +import type { Record } from "./records"; +import { appendProseEffect, proseIntent } from "../conversation-plan/prose-job"; + +import type { CardEvent } from "./card-event"; +import { announce, emit, pending } from "./card-notifications"; +import { decide } from "./service-definition"; + +// Exact archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2 declarations; import/export wrappers only. +export async function submit( + plan: Plan, + server: Server, + roomId: string, + ws: Socket, + msg: Request, + onCommitted?: (intent: NonNullable>) => void, +): Promise { + if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); + let paused = () => { + let record = plan.records.get(msg.id); + let thread = plan.conversationPlan.threads.find(item => item.id === record?.threadId); + return !!thread && !!activeSettleDeferral(thread, plan.conversationPlan.events); + }; + if (paused()) { + return reply(ws, msg.rid, { + kind: "question:submit", + ts: 0, + id: msg.id, + ok: false, + reason: "invalid", + message: "This decision is paused pending verification", + }); + } + let claimed = Store.claimSubmit( + plan.questions, + msg.id, + msg.revision, + ws.data.handle, + msg.suggestedOptionId, + ); + if (!claimed.ok) { + return reply(ws, msg.rid, { kind: "question:submit", ts: 0, id: msg.id, ...claimed }); + } + let invalid: string | undefined; + let failure: unknown; + let finish: (() => Store.Ended) | undefined; + let optionIds: string[] = []; + try { + await Service.exclusive(plan, async () => { + if (Service.implementationActive(plan)) throw new Error("implementation is active"); + if (paused()) { + invalid = "This decision is paused pending verification"; + return; + } + let record = plan.records.get(msg.id); + if (!record || !isOpenStatus(record.status)) { + invalid = "This decision is no longer open"; + return; + } + if (plan.questions.open.get(msg.id) !== claimed.claim.entry) { + invalid = "This decision is no longer open"; + return; + } + let stagedDocument: room.Document | undefined; + try { + stagedDocument = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + stagedDocument.seq = plan.document.seq; + let answers = decide({ status: "answered", answers: claimed.answers }, record.definition); + let chosen: { [question: string]: string[] } = {}; + record.definition.questions.forEach((question, index) => { + let ids = claimed.answers[index]?.optionIds; + if (ids?.length) chosen[question.id] = ids; + }); + optionIds = Object.values(chosen).flat(); + let at = Math.floor(Date.now() / 1_000); + let settled = { by: ws.data.handle, at: new Date(at * 1_000).toISOString() }; + let mutation = room.projectAnswer(stagedDocument, msg.id, answers, settled, chosen); + let records = new Map(plan.records); + let answered: Record = { + ...record, + status: "answered", + answers, + resolver: ws.data.handle, + at, + owner: ws.data.handle, + decidedAt: at, + choices: optionIds, + editors: [...new Set([...record.editors, ...claimed.claim.entry.editors])], + }; + records.set(msg.id, answered); + let intent = proseIntent(answered); + let effects = appendProseEffect( + plan.conversationPlanPendingEffects, + plan.conversationPlanEffects, + answered, + ); + let stagedQuestions: Store.Questions = { + open: new Map(plan.questions.open), + closed: new Map(plan.questions.closed), + }; + let complete = Store.stage(stagedQuestions, claimed.claim); + await Service.publishStaged(plan, server, roomId, { + ...plan, + document: stagedDocument, + questions: stagedQuestions, + records, + conversationPlanPendingEffects: effects, + pendingCardActions: pending( + plan, + record, + { + kind: "decided", + id: msg.id, + actor: ws.data.handle, + optionIds, + }, + at, + claimed.answers.map(Question.summarize).join("; "), + ), + }, mutation); + finish = complete; + if (intent) { + try { + onCommitted?.(intent); + } catch (error) { + console.error("[questions] could not capture saved decision claimant:", error); + } + } + } catch (err) { + if (err instanceof room.QuestionnaireProjectionError) invalid = err.message; + else failure = err; + } finally { + stagedDocument?.doc.destroy(); + } + }); + } catch (err) { + failure = err; + } + if (!finish) { + Store.rollback(plan.questions, claimed.claim); + if (invalid) { + return reply(ws, msg.rid, { + kind: "question:submit", + ts: 0, + id: msg.id, + ok: false, + reason: "invalid", + message: invalid, + }); + } + return fail( + ws, + msg.rid, + failure instanceof ConversationCapacityError + || failure instanceof Error && failure.message === "implementation is active" + ? failure.message + : "could not save the decision", + ); + } + finish(); + let record = plan.records.get(msg.id)!; + let event: CardEvent = { + kind: "decided", + id: msg.id, + ...(record.threadId ? { threadId: record.threadId } : {}), + actor: ws.data.handle, + optionIds, + }; + for ( + let notify of [ + () => + reply(ws, msg.rid, { + kind: "question:submit", + ts: 0, + id: msg.id, + ok: true, + answers: claimed.answers, + resolver: ws.data.handle, + }), + () => + broadcast(server, roomId, { + kind: "question:resolved", + ts: 0, + id: msg.id, + status: "answered", + resolver: ws.data.handle, + answers: claimed.answers, + }), + () => announce(plan, server, roomId, msg.id), + () => emit(plan, event), + ] + ) { + try { + notify(); + } catch (error) { + console.error("[questions] could not announce a saved decision:", error); + } + } +} diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index 52bbbef9..3ab8e4e0 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -12,684 +12,46 @@ * everyone already knows how to render. */ -import { ulid } from "@chopin/dialect"; -import * as Question from "@chopin/question"; - -import * as Anchors from "./anchors"; - -import * as room from "../plan/room"; import * as Store from "./store"; -import { broadcast, fail, relay, reply, tell } from "../wire"; - -import type { Server } from "bun"; -// `Plan` is the room's plan here; the protocol namespace of the same name is -// aliased so the two cannot be confused at a glance. -import type { Plan as Wired, Question as Wire, Request } from "@chopin/protocol"; -import type { Answer, DecisionDefinition, Definition } from "@chopin/question"; -import * as Service from "../plan/service"; - -import type { Plan } from "../plan/service"; -import type { Socket, SocketData } from "../wire"; -import type { Ended, Questions } from "./store"; -export type { Questions } from "./store"; - -export type AskPlacement = { - revision: number; - blocks: Array>; -}; +export type { CardEvent } from "./card-event"; +export { insertConversationCard } from "./card-insertion"; +export { involved } from "./card-involved"; +export { announce, listen, meta } from "./card-notifications"; +export { relabelConversationOption } from "./card-option-relabel"; +export { revisePlannerCard } from "./card-planner-revision"; +export { retitle } from "./card-retitle"; +export { addServerOption } from "./card-server-options"; +export { suggest } from "./card-suggestions"; +export { + isOpenStatus, + matchesQuestionSource, + normalizeRecord, + questionMentionsOption, +} from "./records"; +export type { DecisionEntry, OptionOrigin, Record } from "./records"; +export { appendOption as addOption } from "./service-append-option"; +export { ask } from "./service-ask"; +export { cancel } from "./service-cancel"; +export { identify } from "./service-definition"; +export type { AskPlacement } from "./service-definition"; +export { discard } from "./service-discard"; +export { away, edit, focus, greet, open, shutdown } from "./service-draft"; +export { + anchors, + invalidate, + outstanding, + place, + prose, + rebase, + relate, + setProse, +} from "./service-relationships"; +export type { Placement } from "./service-relationships"; +export { reopen } from "./service-reopen"; +export { submit } from "./service-submit"; +export type { Questions, StoredOpen } from "./store"; export const create = Store.create; export const dump = Store.dump; export const restore = Store.restore; -export type { StoredOpen } from "./store"; - -/** - * Validate a questionnaire and give it durable identity. - * - * The domain assigns positional ids — `q0`, `o1` — which are fine while a - * questionnaire is a single tool result and useless the moment it is a node in - * a document the agent will rewrite around. Identity is minted once, here, - * before anything references it. - */ -export function identify(raw: unknown): Definition { - let definition = Question.normalize(raw); - return { - questions: definition.questions.map(question => ({ - ...question, - id: ulid(), - options: question.options.map(option => ({ ...option, id: ulid() })), - })), - }; -} - -/** A questionnaire as it is stored beside the plan, so it survives a restart. */ -export type Record = { - id: string; - definition: Definition; - status: "open" | "answered" | "cancelled"; - /** Question id to the answer as it reads, for projection into the plan. */ - answers?: { [question: string]: string }; - resolver?: string; - /** When it was settled, Unix seconds. Absent on one settled before we recorded it. */ - at?: number; - /** Where in the prose each of its decisions lives. */ - anchors?: Wired.WidgetAnchors; - /** - * Option-append idempotency keys to the option each created. Bounded by the - * option limit, and durable so a retry after a restart still finds it. - */ - appended?: { [key: string]: string }; -}; - -function decide( - entry: { status: "answered"; answers: Answer[] } | { status: "cancelled" }, - definition: Definition, -): { [question: string]: string } { - if (entry.status !== "answered") return {}; - let out: { [question: string]: string } = {}; - definition.questions.forEach((question, index) => { - let answer = entry.answers[index]; - if (answer) out[question.id] = Question.summarize(answer); - }); - return out; -} - -/** Ask each decision independently; register its record before publishing its node. */ -export async function ask( - plan: Plan, - server: Server, - roomId: string, - definition: Definition, - placement?: AskPlacement, - created?: () => void, -): Promise { - if (Service.implementationActive(plan)) throw new Error("implementation is active"); - if (definition.questions.length === 0) { - Question.reject("A questionnaire needs at least one question"); - } - let asked: Array<{ - id: string; - single: DecisionDefinition; - value: room.QuestionnaireInsertion["value"]; - waiting: Promise; - at: room.QuestionnaireInsertion["at"]; - }> = []; - await Service.exclusive(plan, async () => { - let anchors = placement ? validatePlacement(plan, definition, placement) : undefined; - asked = definition.questions.map((question, index) => { - let single = Question.decision({ questions: [question] }); - // Widget and question identities are deliberately distinct. - let id = ulid(); - let waiting = Store.ask(plan.questions, id, single, id); - let value = { - id, - questions: [{ - id: question.id, - header: question.header, - prompt: question.question, - multiple: question.multiple, - options: question.options.map(option => ({ - id: option.id, - label: option.label, - ...(option.description ? { description: option.description } : {}), - })), - }], - }; - let record: Record = { id, definition: single, status: "open" }; - if (anchors) { - record.anchors = Anchors.set(Anchors.read(record), question.id, anchors[index]!); - } - plan.records.set(id, record); - return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; - }); - - let mutation = room.insertQuestionnaires( - plan.document, - asked.map(item => ({ - value: item.value, - ...(item.at ? { at: item.at } : {}), - })), - ); - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - for (let item of asked) { - broadcast(server, roomId, { - kind: "question:asked", - ts: 0, - id: item.id, - definition: item.single, - widget: item.id, - }); - } - created?.(); - }); - - return Promise.all(asked.map(item => item.waiting)); -} - -/** Validate every placement before registering records or document nodes. */ -function validatePlacement( - plan: Plan, - definition: Definition, - placement: AskPlacement, -): Wired.Anchor[][] { - if (placement.revision !== plan.revision) { - throw new Error("The plan changed; read it again before asking."); - } - if (placement.blocks.length !== definition.questions.length) { - throw new Error("Give one placement for every question."); - } - - let digests = room.digests(plan.document); - return placement.blocks.map(blocks => { - if (blocks.length === 0) { - if (room.hasProse(plan.document)) { - throw new Error("Relate each question to prose, or write its context first."); - } - return []; - } - - return blocks.map(block => { - let current = digests[block.index]; - if (!current) throw new Error(`no block at index ${block.index}`); - if (current !== block.digest) { - throw new Error(`block ${block.index} has changed; read the plan again`); - } - return room.anchorAt(plan.document, block.index, current); - }); - }); -} - -/** Everything still unanswered, for a client that has just joined. */ -export function greet(plan: Plan, ws: Socket): void { - let open = Store.outstanding(plan.questions); - if (open.length === 0) return; - tell(ws, { kind: "question:sync", ts: 0, open }); -} - -export function open(plan: Plan, ws: Socket, msg: Request): void { - reply(ws, msg.rid, { kind: "question:open", ts: 0, ...Store.snapshot(plan.questions, msg.id) }); -} - -export function edit(plan: Plan, ws: Socket, msg: Request): void | Promise { - let outcome = Store.edit(plan.questions, msg.id, msg.patch); - let finish = () => { - reply(ws, msg.rid, { kind: "question:edit", ts: 0, id: msg.id, ...outcome }); - // A no-op patch is acknowledged but not relayed: peers have nothing to do - // with it and it did not move the revision. - if (!outcome.open || !outcome.accepted || !outcome.applied) return; - relay(ws, { - kind: "question:edit", - ts: 0, - id: msg.id, - open: true, - accepted: true, - applied: true, - revision: outcome.revision, - patch: msg.patch, - editor: ws.data.handle, - }); - }; - if (outcome.open && outcome.accepted && outcome.applied) { - return Service.persist(plan).then(finish, () => { - fail(ws, msg.rid, "could not save questionnaire draft"); - }); - } - finish(); -} - -export function focus(plan: Plan, ws: Socket, msg: Wire.Presence.Input): void { - let person = { - client: ws.data.client, - handle: ws.data.handle, - ...(msg.question ? { question: msg.question } : {}), - ...(msg.field ? { field: msg.field } : {}), - }; - if (!Store.focus(plan.questions, msg.id, person)) return; - relay(ws, { kind: "question:presence", ts: 0, ...person, id: msg.id }); -} - -export function away(plan: Plan, ws: Socket): void { - for (let id of Store.away(plan.questions, ws.data.client)) { - relay(ws, { - kind: "question:presence", - ts: 0, - id, - client: ws.data.client, - handle: ws.data.handle, - }); - } -} - -export async function submit( - plan: Plan, - server: Server, - roomId: string, - ws: Socket, - msg: Request, -): Promise { - if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); - let claimed = Store.claimSubmit(plan.questions, msg.id, msg.revision, ws.data.handle); - if (!claimed.ok) { - return reply(ws, msg.rid, { kind: "question:submit", ts: 0, id: msg.id, ...claimed }); - } - - let record = plan.records.get(msg.id); - let answers = record - ? decide({ status: "answered", answers: claimed.answers }, record.definition) - : {}; - - // Stamped once and used in both places, so the record and the plan cannot - // disagree about when the room decided. - let at = Math.floor(Date.now() / 1_000); - let settled = { by: ws.data.handle, at: new Date(at * 1_000).toISOString() }; - let mutationError: unknown; - let finish: (() => Store.Ended) | undefined; - await Service.exclusive(plan, async () => { - let mutation: room.Mutation | undefined; - try { - mutation = room.projectAnswer(plan.document, msg.id, answers, settled); - } catch (err) { - mutationError = err; - return; - } - if (record) { - plan.records.set(msg.id, { - ...record, - status: "answered", - answers, - resolver: ws.data.handle, - at, - }); - } - finish = Store.stage(plan.questions, claimed.claim); - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - }); - if (mutationError) { - // The decision is not final if the plan could not be told about it. - Store.rollback(plan.questions, claimed.claim); - return reply(ws, msg.rid, { - kind: "question:submit", - ts: 0, - id: msg.id, - ok: false, - reason: "invalid", - message: mutationError instanceof Error - ? mutationError.message - : "could not record the answer", - }); - } - finish!(); - - reply(ws, msg.rid, { - kind: "question:submit", - ts: 0, - id: msg.id, - ok: true, - answers: claimed.answers, - resolver: ws.data.handle, - }); - broadcast(server, roomId, { - kind: "question:resolved", - ts: 0, - id: msg.id, - status: "answered", - resolver: ws.data.handle, - answers: claimed.answers, - }); -} - -const ADD_OPTION_REFUSAL: { [reason in "resolved" | "resolving"]: string } = { - resolved: "This question has already been decided", - resolving: "This question is being decided", -}; - -/** - * Append an option to an open question, for everyone. - * - * Ordering follows the rest of this module: the record, the open entry and the - * plan projection change together under the plan's exclusive queue, the fenced - * commit happens, and only then does anyone hear about it. The shared draft is - * not touched, so nothing another member has already chosen can change. - */ -export async function addOption( - plan: Plan, - server: Server, - roomId: string, - ws: Socket, - msg: Request, -): Promise { - let refuse = (reason: Wire.AddOption.Refusal, message: string) => - reply(ws, msg.rid, { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason, - message, - }); - if (Service.implementationActive(plan)) { - return refuse("implementation", "An implementation is running; decisions cannot change"); - } - - let outcome: Wire.AddOption.Reply | undefined; - let added: Wire.OptionAdded | undefined; - await Service.exclusive(plan, async () => { - // An implementation may have claimed the plan while this waited in the - // queue. Refuse before touching the live document: `publish` would throw - // after the Yjs mutation, leaving the room ahead of its durable state. - if (Service.implementationActive(plan)) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "implementation", - message: "An implementation is running; decisions cannot change", - }; - return; - } - let record = plan.records.get(msg.id); - let entry = Store.get(plan.questions, msg.id); - if (!record || !entry || record.status !== "open") { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "resolved", - message: ADD_OPTION_REFUSAL.resolved, - }; - return; - } - let definition = Question.decision(entry.definition); - let applied = typeof msg.key === "string" && Object.hasOwn(record.appended ?? {}, msg.key) - ? record.appended![msg.key] - : undefined; - let existing = applied - ? definition.questions[0].options.find(option => option.id === applied) - : undefined; - if (existing) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: true, - option: existing, - definition, - repeated: true, - }; - return; - } - if (entry.claim) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "resolving", - message: ADD_OPTION_REFUSAL.resolving, - }; - return; - } - - let result = Question.appendOption(definition, { - question: msg.question, - key: msg.key, - label: msg.label, - ...(msg.description === undefined ? {} : { description: msg.description }), - }, ulid()); - if (!result.ok) { - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: result.reason, - message: result.message, - }; - return; - } - - let previous = { record, definition: entry.definition }; - let mutation: room.Mutation | undefined; - try { - mutation = room.appendQuestionOption(plan.document, msg.id, msg.question, result.option); - } catch (err) { - console.error("[questions] could not add the option to the plan:", err); - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: false, - reason: "invalid", - message: "Could not add the option", - }; - return; - } - plan.records.set(msg.id, { - ...record, - definition: result.definition, - appended: { ...record.appended, [msg.key]: result.option.id }, - }); - Store.redefine(plan.questions, msg.id, result.definition); - try { - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - } catch (err) { - plan.records.set(msg.id, previous.record); - Store.restoreDefinition(plan.questions, msg.id, previous.definition); - throw err; - } - outcome = { - kind: "question:option", - ts: 0, - id: msg.id, - ok: true, - option: result.option, - definition: result.definition, - }; - added = { - kind: "question:option-added", - ts: 0, - id: msg.id, - question: msg.question, - option: result.option, - definition: result.definition, - by: ws.data.handle, - }; - }).catch(err => { - console.error("[questions] could not save the option:", err); - outcome = undefined; - added = undefined; - }); - - if (!outcome) return fail(ws, msg.rid, "could not save the option"); - reply(ws, msg.rid, outcome); - if (added) broadcast(server, roomId, added); -} - -/** - * Decline to answer. - * - * Terminal, and the node leaves the plan: a questionnaire cannot say it was - * cancelled, so left in place it would read as one still waiting. The record - * survives as history. - */ -export async function cancel( - plan: Plan, - server: Server, - roomId: string, - ws: Socket, - msg: Request, -): Promise { - if (Service.implementationActive(plan)) return fail(ws, msg.rid, "implementation is active"); - let claimed = Store.claimCancel(plan.questions, msg.id, ws.data.handle); - if (!claimed.ok) { - return reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...claimed }); - } - let mutationError: unknown; - let finish: (() => Store.Ended) | undefined; - await Service.exclusive(plan, async () => { - let mutation: room.Mutation | undefined; - try { - mutation = room.removeQuestionnaire(plan.document, msg.id); - } catch (err) { - mutationError = err; - return; - } - let record = plan.records.get(msg.id); - if (record) { - plan.records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); - } - finish = Store.stage(plan.questions, claimed.claim); - if (mutation) await Service.publish(plan, server, roomId, mutation); - else await Service.persistExclusive(plan); - }); - if (mutationError) { - // The questionnaire stays open and answerable, which is a state every - // client already renders. Saying "resolving" is honest: the attempt is - // over, and trying again is the right move. - console.error("[questions] could not remove the node:", mutationError); - Store.rollback(plan.questions, claimed.claim); - return reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: false, - reason: "resolving", - }); - } - finish!(); - - reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: true, - resolver: ws.data.handle, - }); - broadcast(server, roomId, { - kind: "question:resolved", - ts: 0, - id: msg.id, - status: "cancelled", - resolver: ws.data.handle, - }); -} - -export function shutdown(questions: Questions): void { - Store.shutdown(questions); -} - -// -- relationships --------------------------------------------------------- - -/** Every questionnaire's relationships, as an authoritative snapshot. */ -export function anchors(plan: Plan): Wired.WidgetAnchors[] { - return [...plan.records.values()].map(record => Anchors.read(record)); -} - -/** - * Bring every relationship forward onto the document as it is now. - * - * Called after anything that moves prose around, which is most things. An - * anchor whose block survived is re-expressed against it; one whose block was - * rewritten is orphaned, and the agent is told it owes a review. - */ -export function rebase(plan: Plan): void { - for (let [id, record] of plan.records) { - let value = Anchors.read(record); - let questions: { [question: string]: Wired.AnchorSet } = {}; - - for (let [question, set] of Object.entries(value.questions)) { - questions[question] = carry(plan, set); - } - - plan.records.set(id, { ...record, anchors: { widget: value.widget, questions } }); - } -} - -function carry(plan: Plan, set: Wired.AnchorSet): Wired.AnchorSet { - let anchors = room.rebase(plan.document, set.anchors); - let lost = anchors.some(anchor => anchor.orphaned); - return { - anchors, - pending: set.pending || lost, - ...(lost ? { reason: "orphaned" as const } : set.reason ? { reason: set.reason } : {}), - }; -} - -/** Mark answered results as needing review, after the plan changed beneath them. */ -export function invalidate(plan: Plan, reason: Wired.AnchorReason): void { - for (let [id, record] of plan.records) { - plan.records.set(id, { ...record, anchors: Anchors.invalidate(record, reason) }); - } -} - -/** What the agent still owes a review on. */ -export function outstanding(plan: Plan): Anchors.Pending[] { - return [...plan.records.values()].flatMap(record => Anchors.pending(record)); -} - -/** Replace where a decision lives with the blocks the agent reviewed it against. */ -export function relate( - plan: Plan, - widget: string, - question: string, - blocks: Array<{ index: number; digest: string }>, -): string | undefined { - let record = plan.records.get(widget); - if (!record) return `no questionnaire ${widget}`; - - let current = room.digests(plan.document); - let anchors: Wired.Anchor[] = []; - - for (let block of blocks) { - let hash = current[block.index]; - if (!hash) return `no block at index ${block.index}`; - // The digest is how the agent says which block it meant. If it does not - // match, the plan moved between reading and anchoring, and quietly - // anchoring the block that happens to be there now would be worse than - // saying so. - if (hash !== block.digest) return `block ${block.index} has changed; read the plan again`; - anchors.push(room.anchorAt(plan.document, block.index, hash)); - } - - plan.records.set(widget, { - ...record, - anchors: Anchors.set(Anchors.read(record), question, anchors), - }); - return undefined; -} - -export type Placement = { - widget: string; - blocks: Array<{ index: number; digest: string }>; -}; - -/** Move cards after their first related block, preserving ask order per block. */ -export function place(plan: Plan, updates: Placement[]): room.Mutation | undefined { - let records = [...plan.records.values()]; - let placements: room.QuestionnairePlacement[] = []; - let byWidget = new Map(updates.map(update => [update.widget, update])); - - for (let record of records) { - let update = byWidget.get(record.id); - if (!update || record.definition.questions.length !== 1 || update.blocks.length === 0) continue; - - let home = update.blocks[0]!; - let before = records.slice(0, records.indexOf(record)).findLast(candidate => { - if (candidate.definition.questions.length !== 1) return false; - let question = candidate.definition.questions[0]; - if (!question) return false; - let anchor = Anchors.read(candidate).questions[question.id]?.anchors[0]; - return anchor !== undefined && room.matchesAnchor(plan.document, anchor, home.index); - }); - - placements.push({ id: update.widget, at: home, ...(before ? { after: before.id } : {}) }); - } - - return room.placeQuestionnaires(plan.document, placements); -} diff --git a/apps/server/src/questions/write-prose.test.ts b/apps/server/src/questions/write-prose.test.ts new file mode 100644 index 00000000..97867ad1 --- /dev/null +++ b/apps/server/src/questions/write-prose.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { $createParagraphNode, $getRoot } from "lexical"; + +import * as room from "../plan/room"; +import * as Service from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { normalizeRecord } from "./service"; +import { proseOperation, validateProse } from "./write-prose"; + +const CARD = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; +const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; +const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; +const WIDGET = ` + + + +`; + +let opened: Service.Plan[] = []; +afterEach(async () => { + for (let plan of opened) await Service.close(plan); + opened = []; +}); + +async function decided(source = `Context.\n\n${WIDGET}`, fields: { + status?: "answered" | "open"; + origin?: "conversation" | "planner"; + proseAt?: number; + orphaned?: boolean; +} = {}) { + let { plan } = await openPlan(source); + opened.push(plan); + let hashes = room.digests(plan.document); + let prose = fields.proseAt !== undefined + ? [room.anchorAt(plan.document, fields.proseAt, hashes[fields.proseAt]!)] + : fields.orphaned + ? [{ ...room.anchorAt(plan.document, 0, hashes[0]!), orphaned: true }] + : undefined; + plan.records.set( + CARD, + normalizeRecord({ + id: CARD, + definition: { + questions: [{ + id: QUESTION, + header: "Auth", + question: "Which authentication approach?", + multiple: false, + options: [{ id: OPTION, label: "GitHub Apps", description: "" }], + }], + }, + status: fields.status ?? "answered", + origin: fields.origin ?? "conversation", + threadId: "t1", + owner: "mina", + decidedAt: 1_758_645_000, + choices: [OPTION], + ...(prose ? { prose } : {}), + }), + ); + return plan; +} + +describe("validateProse", () => { + test("accepts one ordinary paragraph with inline formatting", () => { + expect(validateProse(" We chose **GitHub Apps** with `org` access. ")).toEqual({ + ok: true, + text: "We chose **GitHub Apps** with `org` access.", + }); + }); + + test("refuses empty, oversized, multiblock, and non-prose constructs", () => { + for ( + let value of [ + "", + "x".repeat(601), + "One.\n\nTwo.", + "# Heading", + "- List", + "Hi", + "Text ", + "![image](x)", + ] + ) { + expect(validateProse(value).ok).toBe(false); + } + }); +}); + +describe("proseOperation", () => { + test("inserts before the first decision in canonical coordinates", async () => { + let plan = await decided(WIDGET); + expect(room.questionnaireIndex(plan.document, CARD)).toBe(0); + expect(proseOperation(plan, CARD, "We chose GitHub Apps.")).toEqual({ + ok: true, + mode: "insert", + index: 0, + steps: [ + [{ op: "insert", index: 0, source: "We chose GitHub Apps.\n" }], + [{ op: "move", index: 0, to: 1 }], + ], + }); + }); + + test("ignores leading and inter-card caret paragraphs when addressing cards", async () => { + let plan = await decided(); + plan.document.editor.update(() => { + let children = $getRoot().getChildren(); + children[0]!.insertBefore($createParagraphNode()); + children.at(-1)!.insertBefore($createParagraphNode()); + }, { discrete: true }); + await room.settle(); + expect(room.digests(plan.document)).toHaveLength(2); + expect(room.questionnaireIndex(plan.document, CARD)).toBe(1); + expect(proseOperation(plan, CARD, "Decision prose.")).toEqual({ + ok: true, + mode: "insert", + index: 1, + steps: [[{ op: "insert", index: 0, source: "Decision prose.\n" }]], + }); + }); + + test("replaces only the paragraph named by a live anchor beside identical text", async () => { + let plan = await decided(`Same.\n\nSame.\n\n${WIDGET}`, { proseAt: 1 }); + expect(proseOperation(plan, CARD, "New prose.")).toEqual({ + ok: true, + mode: "replace", + index: 1, + steps: [[{ op: "replace", index: 1, source: "New prose.\n" }]], + }); + }); + + test("inserts when prose is orphaned, but refuses an unresolved live anchor", async () => { + let orphan = await decided(undefined, { orphaned: true }); + expect(proseOperation(orphan, CARD, "New prose.")).toMatchObject({ + ok: true, + mode: "insert", + }); + let live = await decided(`Old prose.\n\n${WIDGET}`, { proseAt: 0 }); + live.records.get(CARD)!.prose![0]!.epoch = "different-epoch"; + expect(proseOperation(live, CARD, "New prose.").ok).toBe(false); + }); + + test("refuses open and Planner-owned cards", async () => { + let open = await decided(undefined, { status: "open" }); + let planner = await decided(undefined, { origin: "planner" }); + expect(proseOperation(open, CARD, "Text.").ok).toBe(false); + expect(proseOperation(planner, CARD, "Text.").ok).toBe(false); + }); +}); diff --git a/apps/server/src/questions/write-prose.ts b/apps/server/src/questions/write-prose.ts new file mode 100644 index 00000000..8892ccde --- /dev/null +++ b/apps/server/src/questions/write-prose.ts @@ -0,0 +1,91 @@ +/** Validate and place a decided card's single paragraph in canonical document coordinates. */ + +import { parse } from "@chopin/dialect"; + +import * as room from "../plan/room"; + +import type * as edit from "../plan/edit"; +import type { Plan } from "../plan/service"; + +export const MAX_PROSE = 600; + +export type ProsePlan = + | { ok: true; steps: edit.Operation[][]; index: number; mode: "insert" | "replace" } + | { ok: false; message: string }; + +function inline(node: { type: string; children?: unknown[] }): boolean { + if (node.type === "text" || node.type === "inlineCode" || node.type === "break") return true; + if (!["strong", "emphasis", "delete", "link"].includes(node.type)) return false; + return Array.isArray(node.children) + && node.children.every(child => inline(child as { type: string; children?: unknown[] })); +} + +export function validateProse( + raw: unknown, +): { ok: true; text: string } | { ok: false; message: string } { + if (typeof raw !== "string") return { ok: false, message: "text must be a string" }; + let text = raw.trim(); + if (!text) return { ok: false, message: "text must not be empty" }; + if (text.length > MAX_PROSE) { + return { ok: false, message: `text is at most ${MAX_PROSE} characters` }; + } + try { + let blocks = parse(`${text}\n`).children; + if (blocks.length !== 1 || blocks[0]?.type !== "paragraph") { + return { ok: false, message: "text must be exactly one ordinary paragraph" }; + } + if (!blocks[0].children.every(child => inline(child))) { + return { ok: false, message: "text must be exactly one ordinary paragraph" }; + } + } catch { + return { ok: false, message: "text must be exactly one ordinary paragraph" }; + } + return { ok: true, text }; +} + +export function proseOperation(plan: Plan, id: string, text: string): ProsePlan { + let record = plan.records.get(id); + if (!record) return { ok: false, message: `no decision ${id}` }; + if (record.origin !== "conversation") { + return { ok: false, message: "only conversation decisions are written up" }; + } + if (record.status !== "answered" || !record.owner || !record.threadId) { + return { ok: false, message: "this decision is not decided by a person" }; + } + let card = room.questionnaireIndex(plan.document, id); + if (card === undefined) { + return { ok: false, message: "the decision card is not in the document" }; + } + let source = `${text}\n`; + let anchors = record.prose?.filter(anchor => !anchor.orphaned) ?? []; + if (anchors.length > 1) return { ok: false, message: "decision prose is ambiguous" }; + if (anchors.length === 1) { + let anchor = anchors[0]!; + let matches = room.digests(plan.document).flatMap((_, index) => + room.matchesAnchor(plan.document, anchor, index) ? [index] : [] + ); + if (matches.length !== 1) { + return { ok: false, message: "decision prose no longer has a unique live block" }; + } + let index = matches[0]!; + let blocks = parse(room.project(plan.document)).children; + if (blocks[index]?.type !== "paragraph") { + return { ok: false, message: "decision prose does not name a paragraph" }; + } + return { ok: true, mode: "replace", index, steps: [[{ op: "replace", index, source }]] }; + } + if (card > 0) { + return { + ok: true, + mode: "insert", + index: card, + steps: [[{ op: "insert", index: card - 1, source }]], + }; + } + return { + ok: true, + mode: "insert", + index: 0, + steps: [[{ op: "insert", index: 0, source }], [{ op: "move", index: 0, to: 1 }]], + }; +} diff --git a/apps/server/src/storage/postgres/conversation-restart.test.ts b/apps/server/src/storage/postgres/conversation-restart.test.ts new file mode 100644 index 00000000..a0fb6e86 --- /dev/null +++ b/apps/server/src/storage/postgres/conversation-restart.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { PostgresStorage } from "./adapter"; + +let database = process.env.TEST_DATABASE_URL; +let root = resolve(import.meta.dir, "../../../../.."); +let reconstruction = ` +import { PostgresStorage } from "./apps/server/src/storage/postgres/adapter"; +import * as Plan from "./apps/server/src/plan/service"; +import { applyEvent } from "./apps/server/src/conversation-plan/events"; +let storage = new PostgresStorage(process.env.TEST_DATABASE_URL); +let lease = await storage.leases.acquire("chopin:writer", crypto.randomUUID(), 30000); +if (!lease) throw new Error("writer lease unavailable"); +try { +let server = { publish() { return 0; } }; +let plan = await Plan.open(process.env.CHANNEL_ID, { + storage, lease: () => lease, fatal(error) { throw error; } +}, server); +if (process.env.RESTART_PHASE === "write") { + plan.conversationPlan = applyEvent(plan.conversationPlan, { + id: "opened", type: "thread.opened", threadId: "pilot", + observedThreadVersion: 0, origin: "planner", + actor: { kind: "agent" }, at: 1000, question: "Ship a pilot?" + }); + plan.conversationPlanJobs = [{ + id: "heading:document:m1", kind: "heading", target: "document", trigger: "m1", + status: "running", attempts: 0, at: "2026-10-02T10:00:00.000Z" + }]; + plan.conversationPlanEffects = ["job:heading:document"]; + await Plan.persist(plan); +} +console.log(JSON.stringify({ + source: Plan.source(plan), revision: plan.revision, seq: plan.document.seq, + state: plan.conversationPlan, jobs: plan.conversationPlanJobs, + receipts: plan.conversationPlanEffects, storageRevision: plan.persistence.revision +})); +} finally { + await storage.leases.release(lease); + await storage.close(); +} +// Exit without a room close/checkpoint; the next process must use committed storage. +process.exit(0); +`; + +if (database) { + describe("PostgreSQL conversation reconstruction", () => { + test( + "fresh processes retain accepted state and durably interrupt a running job once", + async () => { + let storage = new PostgresStorage(database); + await storage.migrate(); + let now = new Date(); + let userId = crypto.randomUUID(); + let channelId = crypto.randomUUID(); + try { + await storage.users.put({ id: userId, login: "ana", avatarUrl: "", now }); + let initial = await import("../../plan/service").then(module => + module.initial("# Pilot\n\nAlready accepted document output.") + ); + await storage.channels.create({ + id: channelId, + repositoryId: crypto.randomUUID(), + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Restart", + createdBy: userId, + now, + initial, + }); + let run = async (phase: string) => { + let child = Bun.spawn(["bun", "--eval", reconstruction], { + cwd: root, + env: { ...process.env, CHANNEL_ID: channelId, RESTART_PHASE: phase }, + stdout: "pipe", + stderr: "pipe", + }); + let deadline = setTimeout(() => child.kill(), 15_000); + let [output, errors, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]).finally(() => clearTimeout(deadline)); + expect(errors).toBe(""); + expect(code).toBe(0); + return JSON.parse(output); + }; + let written = await run("write"); + expect(written.jobs[0].status).toBe("running"); + let first = await run("read"); + expect(first.jobs).toMatchObject([{ + status: "failed", + reason: "interrupted", + attempts: 0, + }]); + expect(first.source).toBe(written.source); + expect(first.state).toEqual(written.state); + expect(first.receipts).toEqual(["job:heading:document"]); + expect([first.revision, first.seq]).toEqual([written.revision, written.seq]); + let stored = await storage.collaboration.load(channelId, new Date()); + expect(stored?.sidecar ?? stored?.snapshot?.sidecar).toMatchObject({ + conversationPlanJobs: [{ status: "failed", reason: "interrupted" }], + }); + let second = await run("read"); + expect(second).toEqual(first); + } finally { + await storage.close(); + } + }, + 30_000, + ); + }); +} else { + test.skip("PostgreSQL conversation reconstruction needs TEST_DATABASE_URL", () => {}); +} diff --git a/e2e/research-child-documents.e2e.ts b/e2e/research-child-documents.e2e.ts index 02550b5f..de53b589 100644 --- a/e2e/research-child-documents.e2e.ts +++ b/e2e/research-child-documents.e2e.ts @@ -1,4 +1,9 @@ import { countChildChannels, readSource, seedChildChannel } from "./database"; +import { + markResearchPublished, + seedPendingInlineResearchRequest, + updateResearchJobState, +} from "./research-recovery-database"; import { content, expect, test } from "./room"; import type { Chat, Research } from "../packages/protocol/index"; @@ -22,6 +27,7 @@ type ScriptedRequest = { child?: Research.ReadyChild; error?: string; id: string; + jobId: string; question: string; sources: Research.Source[]; stage: Research.RequestStage; @@ -106,8 +112,15 @@ async function scriptResearch( let suffix = path.slice(prefix.length); if (request.method() === "POST" && suffix === "") { let body = request.postDataJSON() as { question: string; requestId: string }; + let { jobId, workspaceId } = await seedPendingInlineResearchRequest( + databasePort, + room, + body.question, + body.requestId, + ); let created: ScriptedRequest = { - id: body.requestId, + id: workspaceId, + jobId, question: body.question, sources: [], stage: "queued", @@ -127,6 +140,7 @@ async function scriptResearch( } if (request.method() === "POST" && match?.[2] === "retry") { retries.push(current.id); + await updateResearchJobState(databasePort, room, current.jobId, "pending"); current.stage = "queued"; current.error = undefined; current.sources = []; @@ -136,6 +150,7 @@ async function scriptResearch( } if (request.method() === "POST" && match?.[2] === "cancel") { cancellations.push(current.id); + await updateResearchJobState(databasePort, room, current.jobId, "cancelled"); current.stage = "cancelled"; current.error = undefined; await route.fulfill({ json: requestView(room, current) }); @@ -161,12 +176,16 @@ async function scriptResearch( reads, requests, retries, - advance( + async advance( question: string, stage: Research.RequestStage, overrides: Partial = {}, ) { - Object.assign(byQuestion(question), overrides, { stage }); + let request = byQuestion(question); + if (stage === "failed") { + await updateResearchJobState(databasePort, room, request.jobId, "failed"); + } + Object.assign(request, overrides, { stage }); }, invalidate(question: string) { let request = byQuestion(question); @@ -187,6 +206,8 @@ async function scriptResearch( title, CHILD_SOURCE, ); + await markResearchPublished(databasePort, room, request.id, child.id); + await updateResearchJobState(databasePort, room, request.jobId, "completed"); request.stage = "ready"; request.error = undefined; request.child = { @@ -297,16 +318,16 @@ test("inline research publishes one ordinary child and opens it", async ({ baseU expect(await countChildChannels(databasePort, room)).toBe(0); await expect(opened).toHaveURL(url => !url.pathname.includes("/children/")); - research.advance(brief, "searching"); + await research.advance(brief, "searching"); await expect(card.getByText("Searching sources", { exact: true })).toBeVisible(); - research.advance(brief, "analyzing", { + await research.advance(brief, "analyzing", { sources: [{ title: "Primary public source", url: "https://example.com/source" }], }); await expect(card.getByText("Reading sources", { exact: true })).toBeVisible(); // Progress sits under the card, beside it in the tracked wrapper. await expect(card.locator("xpath=..")).toContainText("1 source found"); await expect(card).not.toContainText("A complete report grounded in the discovered sources."); - research.advance(brief, "writing"); + await research.advance(brief, "writing"); await expect(card.getByText("Writing report", { exact: true })).toBeVisible(); await expect.poll(childHrefs).toEqual([]); expect(await countChildChannels(databasePort, room)).toBe(0); @@ -346,7 +367,11 @@ test("failed research retries by identity while cancelled research never publish let failedBrief = "Retry this exact failed research brief."; let failedCard = await startInlineResearch(opened, failedBrief); await expect(failedCard.getByText("Waiting to start", { exact: true })).toBeVisible(); - research.advance(failedBrief, "failed", { + let failedId = + [...research.requests.values()].find(request => request.question === failedBrief)!.id; + // The card can be visible before its initial document placement is durable. + await expect.poll(async () => readSource(databasePort, room)).toContain(failedId); + await research.advance(failedBrief, "failed", { error: "Research could not be completed safely.", }); await expect(failedCard.getByText("Research failed", { exact: true })).toBeVisible(); @@ -368,12 +393,10 @@ test("failed research retries by identity while cancelled research never publish await expect(cancelledCard.getByText("Cancelled", { exact: true })).toBeVisible(); expect(research.cancellations).toEqual([cancelledId]); - let failedId = - [...research.requests.values()].find(request => request.question === failedBrief)!.id; await failedCard.getByRole("button", { name: "Retry research" }).click(); await expect(failedCard.getByText("Waiting to start", { exact: true })).toBeVisible(); expect(research.retries).toEqual([failedId]); - research.advance(failedBrief, "writing", { + await research.advance(failedBrief, "writing", { sources: [{ title: "Recovery source", url: "https://example.com/recovery" }], }); await expect(failedCard.getByText("Writing report", { exact: true })).toBeVisible(); @@ -385,7 +408,7 @@ test("failed research retries by identity while cancelled research never publish expect(source.match(/ = { + 8788: "postgresql://chopin:chopin@127.0.0.1:5433/chopin?sslmode=disable", + 8789: "postgresql://chopin:chopin@127.0.0.1:5434/chopin?sslmode=disable", +}; + +function url(port: number): string { + let index = port === 8789 ? 1 : 0; + return process.env[`E2E_DATABASE_URL_${index}`] || DEFAULT_DATABASES[port]!; +} + +async function sql(port: number, action: (database: SQL) => Promise): Promise { + let database = new SQL(url(port)); + try { + return await action(database); + } finally { + await database.close(); + } +} + +export async function seedPendingInlineResearchRequest( + port: number, + channelId: string, + question: string, + requestId: string, + createdBy = "U_e2e", + plannerOriginMessageId?: string, +): Promise<{ jobId: string; workspaceId: string }> { + let workspaceId = ulid(); + let turnId = ulid(); + let messageId = ulid(); + let jobId = ulid(); + let targetKey = `research-evidence:workspace:${workspaceId}:turn:${turnId}:evidence`; + let now = new Date(); + let availableAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); + await sql(port, async database => { + await database.begin(async transaction => { + await transaction` + INSERT INTO background_job_channels (channel_id, revision) + VALUES (${channelId}, 1) + ON CONFLICT (channel_id) DO UPDATE + SET revision = background_job_channels.revision + 1 + `; + await transaction` + INSERT INTO background_job_targets (channel_id, target_key, generation) + VALUES (${channelId}, ${targetKey}, 1) + `; + await transaction` + INSERT INTO background_jobs ( + id, channel_id, type, version, origin, target_key, target_generation, + idempotency_key, fingerprint, input, state, revision, attempts, failures, + claim_generation, available_at, created_at, updated_at + ) VALUES ( + ${jobId}, ${channelId}, 'research-evidence', 1, 'user', ${targetKey}, 1, + ${`research-evidence:${turnId}`}, ${`fingerprint-${jobId}`}, + ${JSON.stringify({ workspaceId, turnId, query: question })}::jsonb, + 'pending', 1, 0, 0, 0, ${availableAt}, ${now}, ${now} + ) + `; + await transaction` + INSERT INTO research_workspaces ( + id, channel_id, title, proposed_question, confirmed_query, origin, + origin_message_id, inline_reference, + created_by, confirmed_by, revision, next_turn_ordinal, next_message_sequence, + idempotency_key, fingerprint, created_at, updated_at + ) VALUES ( + ${workspaceId}, ${channelId}, ${question}, ${question}, ${question}, + ${plannerOriginMessageId ? "planner" : "inline"}, + ${plannerOriginMessageId ?? null}, ${plannerOriginMessageId ? "placed" : null}, + ${createdBy}, ${createdBy}, 0, 2, 2, ${`e2e-inline-${workspaceId}`}, + ${`fingerprint-${workspaceId}`}, ${now}, ${now} + ) + `; + await transaction` + INSERT INTO research_turns ( + id, workspace_id, ordinal, kind, request_id, fingerprint, question, + requested_by, evidence_job_id, created_at, updated_at + ) VALUES ( + ${turnId}, ${workspaceId}, 1, 'initial', ${requestId}, + ${`fingerprint-${turnId}`}, ${question}, ${createdBy}, ${jobId}, ${now}, ${now} + ) + `; + await transaction` + INSERT INTO research_messages ( + id, workspace_id, sequence, turn_id, author_kind, user_id, user_handle, + text, created_at + ) VALUES ( + ${messageId}, ${workspaceId}, 1, ${turnId}, 'member', ${createdBy}, 'e2e', + ${question}, ${now} + ) + `; + }); + }); + return { jobId, workspaceId }; +} + +export async function updateResearchJobState( + port: number, + channelId: string, + jobId: string, + state: "pending" | "completed" | "failed" | "cancelled", +): Promise { + await sql(port, async database => { + let [updated] = await database<{ id: string }[]>` + UPDATE background_jobs + SET state = ${state}, + revision = revision + 1, + reason = NULL, + available_at = ${new Date(Date.now() + 24 * 60 * 60 * 1000)}, + updated_at = ${new Date()} + WHERE id = ${jobId} AND channel_id = ${channelId} + RETURNING id + `; + if (!updated) throw new Error(`missing scripted research job ${jobId}`); + }); +} + +export async function markResearchPublished( + port: number, + channelId: string, + workspaceId: string, + childChannelId: string, +): Promise { + await sql(port, async database => { + let [updated] = await database<{ id: string }[]>` + UPDATE research_workspaces + SET published_channel_id = ${childChannelId}, + updated_at = ${new Date()} + WHERE id = ${workspaceId} AND channel_id = ${channelId} + RETURNING id + `; + if (!updated) throw new Error(`missing scripted research workspace ${workspaceId}`); + }); +} diff --git a/e2e/responsive-decisions.e2e.ts b/e2e/responsive-decisions.e2e.ts index caae2ba6..abd77075 100644 --- a/e2e/responsive-decisions.e2e.ts +++ b/e2e/responsive-decisions.e2e.ts @@ -1,4 +1,4 @@ -import { authenticate, expect, test } from "./room"; +import { authenticate, expect, roomPath, test } from "./room"; import { storedQuestion } from "../apps/server/src/testing/plan"; import { expectInsideViewport, expectNoHorizontalOverflow } from "./responsive"; import { installVisualViewport, setVisualViewport } from "./visual-viewport"; @@ -61,6 +61,15 @@ for (let viewport of [{ width: 320, height: 568 }, { width: 390, height: 844 }]) test(`long independent decisions preserve drafts and focus at ${viewport.width}x${viewport.height}`, async ({ baseURL, browser, room, seed }) => { await seed(LONG_QUESTIONNAIRES, { revision: 1, + questions: LONG_QUESTIONS.map((question, index) => ({ + id: LONG_WIDGETS[index]!, + definition: { questions: [question] }, + status: "open", + origin: "planner", + history: [], + optionOrigins: {}, + editors: [], + })), openQuestions: LONG_QUESTIONS.map((question, index) => { let definition = { questions: [question] }; let id = LONG_WIDGETS[index]!; @@ -86,7 +95,7 @@ for (let viewport of [{ width: 320, height: 568 }, { width: 390, height: 844 }]) }); let page = await context.newPage(); await authenticate(page, "ana", baseURL!); - await page.goto(`/channels/${room}`); + await page.goto(roomPath(room)); let card = questionnaire(page).filter({ hasText: LONG_QUESTIONS[0]!.header }); await expect(card).toBeVisible(); await expect(card.getByRole("textbox", { name: "New option" })).toHaveCount(0); diff --git a/e2e/sidecar.e2e.ts b/e2e/sidecar.e2e.ts index 06fd1116..0e64e8db 100644 --- a/e2e/sidecar.e2e.ts +++ b/e2e/sidecar.e2e.ts @@ -432,7 +432,9 @@ test("automatic Decisions changes reconcile after compact Chat closes", async ({ .getByRole("button", { name: "Document", exact: true }) .click(); await expect(documentEditor(bo)).toBeVisible(); - await documentEditor(bo).fill("Collaborative prose arrived."); + await documentEditor(bo).focus(); + await bo.keyboard.press("ControlOrMeta+Home"); + await bo.keyboard.type("Collaborative prose arrived."); await expect(documentEditor(ana)).toContainText( "Collaborative prose arrived.", ); @@ -442,6 +444,8 @@ test("automatic Decisions changes reconcile after compact Chat closes", async ({ await ana.getByRole("heading", { name: "Chat", exact: true }).press("Escape"); await expect(ana.locator('[data-document-view="plan"]')).toBeVisible(); + await expect(nav.getByRole("button", { name: "Decisions, 2 unanswered", exact: true })) + .toBeVisible(); await expect(nav.getByRole("button", { name: "Document" })).toHaveAttribute( "aria-current", "page", diff --git a/packages/protocol/chat.d.ts b/packages/protocol/chat.d.ts index 4ae29cd8..4991c2ec 100644 --- a/packages/protocol/chat.d.ts +++ b/packages/protocol/chat.d.ts @@ -1,4 +1,4 @@ -import type { Frame, Request } from "./index"; +import type { ConversationPlan, Frame, Request } from "./index"; type KIND = Frame & { kind: K }; @@ -72,6 +72,31 @@ export declare namespace Chat { author: Author; text: string; ts: number; + /** A system notice about a decision card; text remains its fallback. */ + decision?: + | { + questionnaireId: string; + kind: "prompt"; + generation: number; + label?: string; + /** Stable source identity for a refreshed advisory; absent on legacy prompts. */ + sourceMessageIds?: string[]; + suggestedOptionId?: string; + } + | { + questionnaireId: string; + kind: "scoped-choice"; + threadId: string; + proposalId: string; + cardId: string; + optionId: string; + label: string; + scope: "spike"; + generation: number; + triggerEventId: string; + sources: ConversationPlan.SourceRef[]; + } + | { questionnaireId: string; kind: "activity"; label?: string; generation?: never }; /** True while the agent is still writing this one. */ streaming?: boolean; tools?: Activity[]; diff --git a/packages/protocol/index.d.ts b/packages/protocol/index.d.ts index d31c0b51..783ba667 100644 --- a/packages/protocol/index.d.ts +++ b/packages/protocol/index.d.ts @@ -122,6 +122,7 @@ export type Incoming = | Session.Incoming | import("./chat").Chat.Incoming | import("./comment").Comment.Incoming + | import("./conversation-plan").ConversationPlan.Incoming | import("./job").Job.Incoming | import("./plan").Plan.Incoming | import("./question").Question.Incoming; @@ -131,6 +132,7 @@ export type Outgoing = | Session.Outgoing | import("./chat").Chat.Outgoing | import("./comment").Comment.Outgoing + | import("./conversation-plan").ConversationPlan.Outgoing | import("./job").Job.Outgoing | import("./plan").Plan.Outgoing | import("./question").Question.Outgoing diff --git a/packages/protocol/plan.d.ts b/packages/protocol/plan.d.ts index d8dfd2ec..439f2591 100644 --- a/packages/protocol/plan.d.ts +++ b/packages/protocol/plan.d.ts @@ -46,6 +46,15 @@ export declare namespace Plan { digest: string; /** Kept rather than guessed at when neither position nor digest resolves. */ orphaned?: true; + /** A unique disappeared prose block may recover on the next document edit only. */ + recoverOnNextEdit?: true; + }; + + /** The editable paragraph a decided card became, kept even when its block is lost. */ + export type ProseAnchors = { + widget: string; + anchors: Anchor[]; + orphaned: boolean; }; /** Why a relationship is not currently trustworthy. */ @@ -133,6 +142,7 @@ export declare namespace Plan { epoch: string; widgets: WidgetAnchors[]; threads: ThreadAnchors[]; + prose?: ProseAnchors[]; }; /** @@ -231,6 +241,7 @@ export declare namespace Plan { anchors: WidgetAnchors[]; /** Which prose each comment thread marks, on the same terms. */ threads: ThreadAnchors[]; + prose?: ProseAnchors[]; limits: Limits; }; } diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index a85489c9..ba1a8bc4 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -1,4 +1,5 @@ import type { Frame, Request } from "./index"; +import type { ConversationPlan } from "./conversation-plan"; type KIND = Frame & { kind: K }; @@ -21,16 +22,23 @@ export declare namespace Question { | Request | Request | Request + | Request + | Request | Request | Presence.Input; export type Outgoing = | Sync | Asked + | Meta + | Metas | Open.Reply | Edit.Reply + | Changed | Submit.Reply | Cancel.Reply + | Discard.Reply + | Reopen.Reply | AddOption.Reply | OptionAdded | Presence.Output @@ -54,6 +62,49 @@ export declare namespace Question { questions: Item[]; }; + export type CardStatus = "open" | "decided" | "reopened" | "discarded"; + + export type OptionOrigin = { + origin: "chat" | "planner" | "human"; + rationale?: string; + by?: string; + /** Exact saved option quote or a question mention from this card's thread. */ + source?: ConversationPlan.SourceRef; + }; + + export type DecisionEntry = { + choices: string[]; + answers?: { [questionId: string]: string }; + owner: string; + at: number; + }; + + export type CardMeta = { + status: CardStatus; + origin: "planner" | "conversation"; + thread?: string; + owner?: string; + decidedAt?: number; + /** The member who most recently resolved this card. */ + resolver?: string; + /** Owner first, then other people in this decision. At most eight. */ + involved: string[]; + /** Advisory choice and the card revision at which it became visible. */ + suggested?: { optionId: string; messageIds: string[]; revision: number }; + history: DecisionEntry[]; + optionOrigins: { [optionId: string]: OptionOrigin }; + /** A refine job is pending or running for this card. */ + refining: boolean; + hasProse: boolean; + proseOrphaned: boolean; + }; + + export type Meta = KIND<"question:meta"> & { id: string; meta: CardMeta }; + + export type Metas = KIND<"question:metas"> & { + cards: Array<{ id: string; meta: CardMeta }>; + }; + /** One independently persisted decision card. */ export type DecisionDefinition = { questions: [Item]; @@ -77,9 +128,7 @@ export declare namespace Question { /** * A decided answer. * - * Carries the question text and the chosen labels rather than identifiers, - * so it still reads as prose to an agent, and still means something in a - * transcript after the definition it came from is gone. + * Carries readable labels for the agent and option ids for durable decisions. */ export type Answer = { question: string; @@ -161,6 +210,12 @@ export declare namespace Question { ); } + export type Changed = KIND<"question:changed"> & { + id: string; + definition: DecisionDefinition; + revision: number; + }; + export namespace Presence { export type Input = KIND<"question:presence"> & Focus & { id: string }; export type Output = Input & { client: string; handle: string }; @@ -171,6 +226,8 @@ export declare namespace Question { id: string; /** The draft revision being submitted, for optimistic concurrency. */ revision: number; + /** Accept this visible advisory choice only if it is still current. */ + suggestedOptionId?: string; }; export type Reply = @@ -210,6 +267,35 @@ export declare namespace Question { ); } + export namespace Discard { + /** A member sets a card aside, keeping its decision history in the document. */ + export type Ask = KIND<"question:discard"> & { id: string }; + + export type Reply = + & KIND<"question:discard"> + & { id: string } + & ( + | { ok: true; resolver: string } + | { ok: false; reason: "resolving" } + | { + ok: false; + reason: "resolved"; + status: "discarded"; + resolver: string; + } + ); + } + + export namespace Reopen { + export type Ask = KIND<"question:reopen"> & { id: string }; + export type Reply = + & KIND<"question:reopen"> + & { id: string } + & ( + | { ok: true } + | { ok: false; reason: "not-decided" | "resolving" } + ); + } export namespace AddOption { /** * Append one option to an open question, for everyone.