From 999af4667e19e0802e7b9fdaf07aad8d44ef124e Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 20:40:54 -0700 Subject: [PATCH 01/12] Run the Planner as a full Atomic session in local mode Opt in with ATOMIC_PLANNER=full only under local authentication and the Atomic harness. Verify each Planner checkout's origin before enabling Atomic coding tools, builtins, and operator resources. Unregistered worker sessions and Planner sessions without a verified checkout retain the isolated boundary. Bind Chopin's Decisions to Atomic HostInput rather than intercepting tools. Map questionnaires and dialogs, preserve raw answers, label workflow requests, and withdraw pending cards on abort or timeout after durable persistence. Document the operator-level shell and filesystem trust change. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: bun test passed: 1706 pass, 2 PostgreSQL skips, 0 fail across 193 files Assistant-verification: bun test apps/server/src/harness passed: 141 tests, including full Atomic SDK tools, resources, cwd, HostInput and isolated fallback Assistant-verification: bun run types passed: all workspaces and E2E TypeScript checks Assistant-verification: bun run fix passed: formatting inspected; no errors and one existing lint warning Assistant-verification: bun run ci passed: formatting, lint, tokens, typography and design checks; no new design findings Assistant-verification: isolation mutation checks passed: builtins on caused 16 failures, skill discovery caused 2, and plain-turn result tool caused 9; all mutations reverted byte-for-byte Assistant-verification: relative documentation link check passed: 38 links and anchors resolved with exact casing Assistant-verification: qlty smells passed: no duplication findings; complexity diagnostics match existing adapter closure patterns and are recorded in implementation notes User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- AGENTS.md | 6 +- apps/server/src/agent/planner.ts | 12 +- apps/server/src/chat/service.ts | 13 +- apps/server/src/config.test.ts | 56 +++ apps/server/src/config.ts | 33 +- .../src/harness/atomic.contract.test.ts | 9 +- apps/server/src/harness/atomic/adapter.ts | 85 ++-- .../src/harness/atomic/checkout.test.ts | 40 ++ apps/server/src/harness/atomic/checkout.ts | 37 ++ apps/server/src/harness/atomic/full.test.ts | 188 +++++++++ apps/server/src/harness/atomic/full.ts | 17 + .../src/harness/atomic/human-input.test.ts | 369 ++++++++++++++++++ apps/server/src/harness/atomic/human-input.ts | 121 ++++++ apps/server/src/harness/harnesses.ts | 11 +- apps/server/src/harness/pi/model-stub.ts | 4 + apps/server/src/harness/session.test.ts | 53 +++ apps/server/src/harness/session.ts | 31 +- apps/server/src/questions/service-ask.ts | 24 +- apps/server/src/questions/service-cancel.ts | 97 +++-- .../src/questions/service-definition.ts | 4 +- apps/server/src/questions/service.ts | 2 +- docs/authentication.md | 7 + docs/hosted-agent.md | 76 +++- docs/self-hosting.md | 104 +++-- .../dialect/src/nodes/questionnaire-mdx.ts | 28 +- packages/dialect/src/validate.ts | 8 +- packages/protocol/question.d.ts | 2 + packages/question/src/answer.ts | 5 +- packages/question/src/draft.ts | 6 +- packages/question/src/question.test.ts | 13 + .../question/src/react/question-view.test.ts | 15 + packages/question/src/schema.ts | 51 ++- 32 files changed, 1369 insertions(+), 158 deletions(-) create mode 100644 apps/server/src/harness/atomic/checkout.test.ts create mode 100644 apps/server/src/harness/atomic/checkout.ts create mode 100644 apps/server/src/harness/atomic/full.test.ts create mode 100644 apps/server/src/harness/atomic/full.ts create mode 100644 apps/server/src/harness/atomic/human-input.test.ts create mode 100644 apps/server/src/harness/atomic/human-input.ts diff --git a/AGENTS.md b/AGENTS.md index ae5beefbd..2d9c9c25b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,8 +114,10 @@ external implementation runs are durable. model-backed research request supplies its GitHub App token and Copilot entitlement. The database stores only a token-free owner reference and durable context. -- **Planner tools are repository-fixed.** The hosted runtime has no shell, - checkout, host filesystem, skills, plugins, or arbitrary GitHub access. +- **Planner tools are repository-fixed by default.** The hosted runtime has no shell, + checkout, host filesystem, skills, plugins, or arbitrary GitHub access. Local-only + `ATOMIC_PLANNER=full` with a verified checkout deliberately enables the operator's + Atomic tools and resources, including shell and filesystem access. - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. - **Persistence should precede publication.** Do not acknowledge or broadcast a diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index b0702b440..db9ba6f97 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -230,8 +230,16 @@ Questionnaires are created by \`ask\`, never by hand, and their answers are owne elsewhere — leave them alone when you rewrite around them. To take one out of the plan, use the \`detach_question\` operation rather than deleting the block.`; -export function plannerInstructions(repository: string, bootstrap?: string): string { - let access = `Read before you propose. The selected repository is ${repository}. Use +export function plannerInstructions( + repository: string, + bootstrap?: string, + checkout?: string, +): string { + let access = checkout + ? `The selected repository is ${repository}. Your working directory is the verified local checkout ${checkout}. +You have the operator's Atomic coding tools, workflows and resources, with shell and filesystem access as the local user. +Atomic human input, including workflow questions, goes to Chopin Decisions. Chopin's document tools remain fixed to this document.` + : `Read before you propose. The selected repository is ${repository}. Use \`read_repository_file\`, \`list_repository_tree\`, \`search_repository\` and \`repository_history\` for its code, and \`list_pull_requests\` and \`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository. diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index e20192984..c4762ec43 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -425,6 +425,7 @@ export type Room = { commitRoomMessage?: (entry: Wire.Entry) => Promise; roomMessagePublished?: () => void; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; + checkout?: string; ownerAvailable?: () => Promise; jobs?: JobService; references?: ReferenceService; @@ -932,11 +933,15 @@ async function repositorySession( let result = await open(binding, { room: documentRoom(context), repository, - instructions: plannerInstructions( - `${repository.owner}/${repository.name}`, - bootstrap, - ), + instructions: checkout => + plannerInstructions( + `${repository.owner}/${repository.name}`, + bootstrap, + checkout, + ), model: context.config.model, + atomicPlanner: context.config.atomicPlanner, + checkout: context.checkout, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); opened = result.value; diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index ade6b346a..35596151e 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { delimiter } from "node:path"; import { describe as description, load } from "./config"; @@ -26,6 +27,9 @@ function configured(overrides: Record = {}) { HARNESS: undefined, HARNESS_AUTH: undefined, AUTH_MODE: undefined, + ATOMIC_PLANNER: undefined, + ATOMIC_PLANNER_CHECKOUTS: undefined, + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: undefined, SERVER_HOST: undefined, PORT: undefined, MODEL: undefined, @@ -131,6 +135,58 @@ describe("configuration", () => { }); }); + it("allows full Atomic Planner only in local atomic mode, with either auth mode", () => { + let full = { + ATOMIC_PLANNER: "full", + AUTH_MODE: "local", + HARNESS: "atomic", + MODEL: "stub/model", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", + }; + for (let auth of ["auto", "ai-gateway"]) { + expect(configured({ ...full, HARNESS_AUTH: auth }).atomicPlanner).toEqual({ + checkouts: [], + }); + } + expect(configured().atomicPlanner).toBeUndefined(); + for (let mode of ["isolated", "FULL", " "]) { + expect(() => configured({ ...full, ATOMIC_PLANNER: mode })).toThrow("ATOMIC_PLANNER"); + } + for (let auth of [undefined, "hosted"]) { + expect(() => configured({ ...full, AUTH_MODE: auth })).toThrow("AUTH_MODE=local"); + } + for (let harness of [undefined, "pi", "copilot-sdk"]) { + expect(() => configured({ ...full, HARNESS: harness })).toThrow("HARNESS=atomic"); + } + }); + + it("validates checkout paths and an optional per-request input timeout", () => { + let full = { + ATOMIC_PLANNER: "full", + AUTH_MODE: "local", + HARNESS: "atomic", + MODEL: "stub/model", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", + }; + expect( + configured({ + ...full, + ATOMIC_PLANNER_CHECKOUTS: ["/tmp/one", "/tmp/two", "/tmp/one"].join(delimiter), + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "1234", + }).atomicPlanner, + ).toEqual({ checkouts: ["/tmp/one", "/tmp/two", "/tmp/one"], inputTimeoutMs: 1234 }); + for (let paths of ["relative", ["/tmp/one", "relative"].join(delimiter)]) { + expect(() => configured({ ...full, ATOMIC_PLANNER_CHECKOUTS: paths })).toThrow("absolute"); + } + for (let timeout of ["0", "-1", "1.5", "oops", "10ms", "2147483648"]) { + expect(() => configured({ ...full, ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout })).toThrow( + "ATOMIC_PLANNER_INPUT_TIMEOUT_MS", + ); + } + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b082dd4c0..6a8603e24 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,7 @@ * than a puzzling behaviour three screens later. */ +import { delimiter, isAbsolute } from "node:path"; import { loadAuth } from "./auth/config"; import type { AuthConfig } from "./auth/config"; @@ -18,6 +19,7 @@ export type Config = { model: string; harness: string; harnessAuth: string | undefined; + atomicPlanner?: { checkouts: string[]; inputTimeoutMs?: number }; /** * Whether to run the agent at all. * @@ -63,11 +65,39 @@ function model(harness: string): string { } return DEFAULT_MODEL; } -export function harnessSelection(): Pick { +export function harnessSelection(): Pick< + Config, + "host" | "harness" | "harnessAuth" | "atomicPlanner" +> { + let full = process.env.ATOMIC_PLANNER; + if (full && full !== "full") throw new Error("ATOMIC_PLANNER must be full or unset"); + if (full && (process.env.AUTH_MODE !== "local" || process.env.HARNESS !== "atomic")) { + throw new Error("ATOMIC_PLANNER=full requires AUTH_MODE=local and HARNESS=atomic"); + } + let checkouts = process.env.ATOMIC_PLANNER_CHECKOUTS?.split(delimiter) ?? []; + if (checkouts.some(path => !isAbsolute(path))) { + throw new Error( + "ATOMIC_PLANNER_CHECKOUTS must contain absolute paths separated by the platform path delimiter", + ); + } + let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS; + let inputTimeoutMs = rawTimeout === undefined ? undefined : Number(rawTimeout); + if ( + inputTimeoutMs !== undefined + && (!Number.isSafeInteger(inputTimeoutMs) || inputTimeoutMs < 1 + || inputTimeoutMs > 2_147_483_647) + ) { + throw new Error("ATOMIC_PLANNER_INPUT_TIMEOUT_MS must be an integer between 1 and 2147483647"); + } return { host: process.env.SERVER_HOST || "127.0.0.1", harness: process.env.HARNESS || "copilot-sdk", harnessAuth: process.env.HARNESS_AUTH || undefined, + ...(full + ? { + atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, + } + : {}), }; } @@ -157,6 +187,7 @@ export function describe(config: Config): string { config.devClient ? `client: vite (${config.devClient})` : "client: built", config.agent ? `agent: ${config.model} (on demand)` : "agent: off", `harness: ${config.harness}`, + ...(config.atomicPlanner ? ["Atomic Planner: full (local shell and filesystem access)"] : []), config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", config.conversationPlan diff --git a/apps/server/src/harness/atomic.contract.test.ts b/apps/server/src/harness/atomic.contract.test.ts index 9a87841cf..ea7514edb 100644 --- a/apps/server/src/harness/atomic.contract.test.ts +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -405,7 +405,14 @@ describe("atomic host isolation and model resolution", () => { let project = join(root, "project"); let previous = { home: process.env.HOME, cwd: process.cwd() }; try { - for (let base of [join(home, ".atomic", "agent"), join(home, ".pi", "agent"), project]) { + for ( + let base of [ + join(home, ".atomic", "agent"), + join(home, ".pi", "agent"), + join(home, ".agents"), + project, + ] + ) { for (let name of ["extensions", "skills/marker", "prompts"]) { await mkdir(join(base, name), { recursive: true }); } diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index f74712f5c..9ea63be7c 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -1,13 +1,9 @@ /** * Chopin's Atomic `HarnessV1` adapter. * - * Embeds Atomic in-process through its headless SDK. Each harness session owns - * one Atomic `AgentSession` with every shipped builtin package off, no Atomic - * coding tools, an inert resource loader, and in-memory session, settings, and - * credential stores. The model is offered only the host tools `HarnessAgent` - * passes for the turn, plus a terminating result tool on structured turns. The - * tool set is checked before every prompt and before every model request, and a - * mismatch fails the turn. + * Sessions are isolated by default: only Chopin host tools, no host resources, + * and a checked per-turn tool boundary. A registered local full Planner uses + * its verified checkout and Atomic resources instead, with Chopin as HostInput. */ import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; @@ -17,13 +13,15 @@ import { DefaultResourceLoader, FileAuthStorageBackend, getAgentConfigPaths, + getAgentDir, ModelRuntime, SessionManager, SettingsManager, } from "@bastani/atomic"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fullPlanner } from "./full"; import type { HarnessV1, @@ -92,6 +90,7 @@ export type AtomicSettings = { model?: string; /** Providers registered by code rather than discovered from the host. */ providers?: Record; + fullPlanner?: boolean; }; export class ToolBoundaryError extends Error { @@ -107,6 +106,7 @@ export type TurnPolicy = { structured: boolean; systemPrompt: string; result?: { value: unknown }; + full?: boolean; }; /** @@ -132,11 +132,16 @@ export function atomicTurnExtension(policy: TurnPolicy): ExtensionFactory { }; }, }); - atomic.on("before_agent_start", () => { - atomic.setActiveTools(activeNames(policy)); - return { systemPrompt: policy.systemPrompt }; + atomic.on("before_agent_start", event => { + if (!policy.full) atomic.setActiveTools(activeNames(policy)); + return { + systemPrompt: policy.full + ? `${event.systemPrompt}\n\n${policy.systemPrompt}` + : policy.systemPrompt, + }; }); atomic.on("tool_call", event => { + if (policy.full && event.toolName !== ATOMIC_RESULT_TOOL_NAME) return; if (activeNames(policy).includes(event.toolName)) return; return { block: true, reason: `${event.toolName} is not available in this turn.` }; }); @@ -314,9 +319,11 @@ export function createAtomicAdapter( throw unsupported("resuming an in-memory session"); } let directory: string | undefined; + let full = settings.fullPlanner ? fullPlanner(startOptions.sessionId) : undefined; let sessionManager: SessionManager | undefined; let settingsManager = SettingsManager.inMemory(SETTINGS); let policy: TurnPolicy = { + full: !!full, hostNames: [], structured: false, systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, @@ -367,7 +374,7 @@ export function createAtomicAdapter( if (!run) return; if (event.type === "turn_start") { let offered = session.agent.state.tools.map(tool => tool.name); - if (!sameNames(offered, activeNames(policy))) { + if (!full && !sameNames(offered, activeNames(policy))) { run.failure ??= new ToolBoundaryError( `Atomic offered unexpected tools: ${offered.join(", ") || "none"}.`, ); @@ -427,37 +434,52 @@ export function createAtomicAdapter( policy.hostNames = hostNames; policy.structured = false; if (!live) { - directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); - sessionManager ??= SessionManager.inMemory(directory); + if (!full) directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); + let cwd = full?.cwd ?? directory!; + let agentDir = full ? getAgentDir() : directory!; + if (full) { + let file = join(agentDir, "settings.json"); + let saved = await readFile(file, "utf8").catch(error => { + if (error.code === "ENOENT") return "{}"; + throw error; + }); + settingsManager = SettingsManager.inMemory({ ...JSON.parse(saved), ...SETTINGS }); + } + sessionManager ??= SessionManager.inMemory(cwd); let loader = new DefaultResourceLoader({ - cwd: directory, - agentDir: directory, + cwd, + agentDir, settingsManager, - noExtensions: true, - noSkills: true, - noPromptTemplates: true, - noThemes: true, - noContextFiles: true, - systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, - appendSystemPrompt: [], + ...(full ? {} : { + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPrompt: ATOMIC_DEFAULT_SYSTEM_PROMPT, + appendSystemPrompt: [], + }), extensionFactories: [atomicTurnExtension(policy)], }); await loader.reload(); let created = await createAgentSession({ - cwd: directory, - agentDir: directory, + cwd, + agentDir, modelRuntime: models, model, fallbackModels: [], sessionManager, settingsManager, resourceLoader: loader, - builtins: BUILTINS_OFF, - tools: [...hostNames, ATOMIC_RESULT_TOOL_NAME], + builtins: full ? undefined : BUILTINS_OFF, + tools: full ? undefined : [...hostNames, ATOMIC_RESULT_TOOL_NAME], + extensionBindings: full ? { humanInput: full.humanInput } : undefined, customTools: turn.tools.map(hostTool), }); let session = created.session; - let leak = hostLeak(session, created.extensionsResult.extensions.length, hostNames); + let leak = full + ? undefined + : hostLeak(session, created.extensionsResult.extensions.length, hostNames); if (leak || closed) { try { await session.dispose(); @@ -525,9 +547,10 @@ export function createAtomicAdapter( let text = promptText(turn.prompt); let agent = await prepare(turn); let structured = turn.responseFormat?.type === "json"; - let expected = structured - ? [...policy.hostNames, ATOMIC_RESULT_TOOL_NAME] + let names = full + ? agent.getActiveToolNames().filter(name => name !== ATOMIC_RESULT_TOOL_NAME) : policy.hostNames; + let expected = structured ? [...names, ATOMIC_RESULT_TOOL_NAME] : names; agent.setActiveToolsByName(expected); if (!sameNames(agent.getActiveToolNames(), expected)) { throw new ToolBoundaryError( @@ -561,7 +584,7 @@ export function createAtomicAdapter( try { if (!run.stopped) { run.emit({ type: "stream-start", modelId: agent.model?.id }); - await agent.prompt(text, { expandPromptTemplates: false }); + await agent.prompt(text, { expandPromptTemplates: !!full }); } if (run.failure) throw run.failure; if (run.stopped) return; diff --git a/apps/server/src/harness/atomic/checkout.test.ts b/apps/server/src/harness/atomic/checkout.test.ts new file mode 100644 index 000000000..8c90c900f --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.test.ts @@ -0,0 +1,40 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { verifiedCheckout } from "./checkout"; + +let root = await mkdtemp(join(tmpdir(), "chopin-checkouts-")); +afterAll(() => rm(root, { recursive: true, force: true })); +let repository = { owner: "Octo-Org", name: "Score" }; + +async function checkout(name: string, origin: string) { + let path = join(root, name); + await mkdir(path); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + let process = Bun.spawn(["git", "-C", path, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await process.exited).toBe(0); + } + return path; +} + +test("verifies origin owner/name for URL, SSH and alias checkouts, preserving candidate priority", async () => { + let paths = []; + for ( + let [index, origin] of [ + "https://github.com/octo-org/score.git", + "ssh://git@github.com/Octo-Org/Score", + "git@github.com:octo-org/score.git", + "workgit:octo-org/score.git", + ].entries() + ) { + let path = await checkout(String(index), origin); + paths.push(path); + expect(await verifiedCheckout(repository, [path])).toBe(await realpath(path)); + } + expect(await verifiedCheckout(repository, paths, paths[3])).toBe(paths[3]); + let mismatch = await checkout("mismatch", "https://github.com/other/score.git"); + expect(await verifiedCheckout(repository, [mismatch, paths[1]!], mismatch)).toBe(paths[1]); + expect(await verifiedCheckout(repository, [mismatch, root, join(root, "missing")])) + .toBeUndefined(); +}); diff --git a/apps/server/src/harness/atomic/checkout.ts b/apps/server/src/harness/atomic/checkout.ts new file mode 100644 index 000000000..f8589a630 --- /dev/null +++ b/apps/server/src/harness/atomic/checkout.ts @@ -0,0 +1,37 @@ +import { realpath } from "node:fs/promises"; + +/** Origin host names may be SSH aliases; the repository coordinates must match. */ +export async function verifiedCheckout( + repository: { owner: string; name: string }, + checkouts: string[], + preferred?: string, +): Promise { + for (let candidate of preferred === undefined ? checkouts : [preferred, ...checkouts]) { + try { + let cwd = await realpath(candidate); + let git = async (...args: string[]) => { + let process = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + let output = await new Response(process.stdout).text(); + if (await process.exited !== 0) throw new Error("not a checkout"); + return output.trim(); + }; + if (await git("rev-parse", "--is-inside-work-tree") !== "true") continue; + let origin = await git("remote", "get-url", "origin"); + let path = origin.includes("://") + ? new URL(origin).pathname.replace(/^\//, "") + : /^[^/:]+:(.+)$/.exec(origin)?.[1]; + if ( + path?.replace(/\.git$/, "").toLowerCase() + === `${repository.owner}/${repository.name}`.toLowerCase() + ) { + return cwd; + } + } catch { + // A missing or unrelated checkout retains the isolated Planner boundary. + } + } + return undefined; +} diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts new file mode 100644 index 000000000..9330beee7 --- /dev/null +++ b/apps/server/src/harness/atomic/full.test.ts @@ -0,0 +1,188 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; +import { jsonSchema, tool } from "ai"; +import { + ATOMIC_DEFAULT_SYSTEM_PROMPT, + ATOMIC_RESULT_TOOL_NAME, + createAtomicAdapter, +} from "./adapter"; +import { registerFullPlanner } from "./full"; +import { startStubModelServer } from "../pi/model-stub"; +import type { HostInput, QuestionParams } from "@bastani/atomic"; + +let params: QuestionParams = { + questions: [{ + header: "Choice", + question: "Which option?", + options: [ + { label: "First", description: "One" }, + { label: "Second", description: "Two" }, + ], + }], +}; +let stub = startStubModelServer((prompt, prior) => + prior + ? { kind: "text", text: "Answered." } + : prompt === "question" + ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(params) } + : prompt === "cwd" + ? { kind: "tool", name: "bash", arguments: JSON.stringify({ command: "pwd" }) } + : { kind: "text", text: "Ready." } +); +afterAll(stub.stop); + +async function run(full: boolean, registered: boolean, prompt = "plain") { + let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); + let agentDir = join(root, "agent"); + let cwd = join(root, "checkout"); + let previous = process.env.ATOMIC_CODING_AGENT_DIR; + let received: QuestionParams[] = []; + let harness = createAtomicAdapter({ + auth: "ai-gateway", + fullPlanner: full, + model: "stub/model", + providers: { + stub: { + baseUrl: stub.baseUrl, + apiKey: "stub", + api: "openai-completions", + models: [{ + id: "model", + name: "Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + }], + }, + }, + }); + let sandbox = await createJustBashNetworkSandboxSession(); + let release: (() => void) | undefined; + try { + for ( + let path of [ + cwd, + join(agentDir, "skills", "marker"), + join(agentDir, "extensions"), + join(agentDir, "prompts"), + ] + ) await mkdir(path, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "OPERATOR-CONTEXT-MARKER"); + await writeFile( + join(agentDir, "skills", "marker", "SKILL.md"), + "---\nname: marker\ndescription: OPERATOR-SKILL-MARKER\n---\nTest skill.\n", + ); + await writeFile(join(agentDir, "prompts", "marker.md"), "OPERATOR-PROMPT-MARKER"); + await writeFile( + join(agentDir, "extensions", "marker.ts"), + `export default api => api.registerTool({name: "operator_tool", label: "Operator", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"marker"}], details:{}}; }});`, + ); + let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + process.env.ATOMIC_CODING_AGENT_DIR = agentDir; + let humanInput: HostInput = { + confirm: async () => false, + select: async () => undefined, + input: async () => undefined, + editor: async () => undefined, + questionnaire: async (value, options) => { + received.push(value); + expect(options.requestId).toBeString(); + return { + answers: [{ + questionIndex: 0, + question: value.questions[0]!.question, + kind: "option", + answer: "Second", + }], + cancelled: false, + }; + }, + }; + let sessionId = crypto.randomUUID(); + if (registered) release = registerFullPlanner(sessionId, { cwd, humanInput }); + let agent = new HarnessAgent({ + harness, + instructions: "CHOPIN-INSTRUCTIONS-MARKER", + permissionMode: "allow-reads", + tools: { + host_tool: tool({ + inputSchema: jsonSchema({ type: "object" }), + execute: async () => "host", + }), + }, + activeTools: ["host_tool"], + }); + let session = await agent.createSession({ sessionId, sandboxSession: sandbox }); + stub.requests.length = 0; + try { + let result = await agent.stream({ session, prompt }); + await result.consumeStream(); + await result.text; + return { cwd, received, requests: [...stub.requests] }; + } finally { + await session.destroy(); + } + } finally { + release?.(); + await harness.shutdown(); + await sandbox.destroy(); + if (previous === undefined) delete process.env.ATOMIC_CODING_AGENT_DIR; + else process.env.ATOMIC_CODING_AGENT_DIR = previous; + await rm(root, { recursive: true, force: true }); + } +} + +test("verified full Planner sessions load operator resources and expose Atomic tools beside host tools", async () => { + let result = await run(true, true); + let request = result.requests[0]!; + for ( + let name of [ + "read", + "bash", + "edit", + "write", + "ask_user_question", + "workflow", + "subagent", + "intercom", + "web_search", + "operator_tool", + "host_tool", + ] + ) expect(request.toolNames).toContain(name); + expect(request.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME); + for ( + let marker of [ + "OPERATOR-CONTEXT-MARKER", + "OPERATOR-SKILL-MARKER", + "CHOPIN-INSTRUCTIONS-MARKER", + result.cwd, + ] + ) expect(request.system).toContain(marker); + expect(request.system).not.toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); +}); + +test("full sessions use the checkout cwd and route ask_user_question through HostInput", async () => { + let cwd = await run(true, true, "cwd"); + expect(cwd.requests.at(-1)!.toolResults.join("\n")).toContain(cwd.cwd); + let question = await run(true, true, "question"); + expect(question.received).toEqual([params]); + expect(question.requests.at(-1)!.toolResults.join("\n")).toContain("Second"); + let prompt = await run(true, true, "/marker"); + expect(prompt.requests[0]!.prompt).toBe("OPERATOR-PROMPT-MARKER"); +}); + +test("workers without a verified registration and default-mode sessions stay isolated", async () => { + for (let [full, registered] of [[true, false], [false, true]]) { + let result = await run(full!, registered!); + expect(result.requests[0]!.toolNames).toEqual(["host_tool"]); + expect(result.requests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); + } +}); diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts new file mode 100644 index 000000000..458a84cb8 --- /dev/null +++ b/apps/server/src/harness/atomic/full.ts @@ -0,0 +1,17 @@ +import type { HostInput } from "@bastani/atomic"; + +export type FullPlanner = { cwd: string; humanInput: HostInput }; +let planners = new Map(); + +/** Only a verified Planner session is registered; background workers are not. */ +export function registerFullPlanner(sessionId: string, planner: FullPlanner): () => void { + if (planners.has(sessionId)) throw new Error("Atomic Planner session is already registered"); + planners.set(sessionId, planner); + return () => { + if (planners.get(sessionId) === planner) planners.delete(sessionId); + }; +} + +export function fullPlanner(sessionId: string): FullPlanner | undefined { + return planners.get(sessionId); +} diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts new file mode 100644 index 000000000..6903c397f --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -0,0 +1,369 @@ +import { afterEach, 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 Plan from "../../plan/service"; +import * as Room from "../../plan/room"; +import { MemoryStorage } from "../../storage/memory/adapter"; +import * as Edit from "../../plan/edit"; +import { createHumanInput } from "./human-input"; +import type { Server } from "bun"; +import type { HostInputOptions, QuestionParams } from "@bastani/atomic"; +import type { Socket, SocketData } from "../../wire"; +import type { DocumentRoom } from "../../agent/tools"; + +let cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function fixture(timeoutMs?: number) { + let storage = new MemoryStorage(); + let now = new Date(); + await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_test", + repositoryOwner: "org", + repositoryName: "repo", + title: "Questions", + createdBy: "user", + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let frames: any[] = []; + let server = { + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Server; + let plan = await Plan.open(channel.id, { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }, server); + let room = { + id: channel.id, + plan, + server, + anchors() {}, + } as DocumentRoom; + let ws = { + data: { handle: "reader", room: channel.id, client: "client" }, + send(frame: string) { + frames.push(JSON.parse(frame)); + }, + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Socket; + cleanups.push(() => Plan.close(plan)); + let controller = new AbortController(); + let options: HostInputOptions = { + requestId: "request", + sessionId: "session", + signal: controller.signal, + }; + let input = createHumanInput(room, timeoutMs); + async function cards(count: number) { + await wait(() => Store.outstanding(plan.questions).length === count); + return Store.outstanding(plan.questions); + } + async function answer(id: string, value: number[] | string) { + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question closed"); + let definition = Store.get(plan.questions, id)!.definition; + let model = Question.restore(opened.model, definition); + let question = definition.questions[0]; + if (typeof value === "string") { + model.api.val([question.id, "mode"]).set("custom"); + if (value) model.api.str([question.id, "custom"]).ins(0, value); + } else if (question.multiple) { + for (let index of value) { + model.api.val([question.id, "options", question.options[index]!.id]).set(true); + } + } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + let patch = model.api.flush(); + if (patch) { + await Questions.edit(plan, ws, { + kind: "question:edit", + rid: "edit", + ts: 0, + id, + patch: [...patch.toBinary()], + }); + } + let current = Store.snapshot(plan.questions, id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(plan, server, channel.id, ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id, + revision: current.revision, + }); + } + return { input, plan, storage, frames, controller, options, cards, answer, ws, server, room }; +} + +async function wait(ready: () => boolean) { + for (let i = 0; i < 200; i++) { + if (ready()) return; + await Bun.sleep(5); + } + throw new Error("question did not arrive"); +} + +let params: QuestionParams = { + questions: [ + { + header: "Choice", + question: " Which? ", + options: [ + { label: " A ", description: " first ", preview: "preview A" }, + { label: "B", description: "second" }, + ], + }, + { + header: "Multiple", + question: "Select several", + multiSelect: true, + options: [ + { label: "C", description: "" }, + { label: "D", description: "" }, + ], + }, + { + header: "Custom", + question: "Or free text?", + options: [ + { label: "E", description: "" }, + { label: "F", description: "" }, + ], + }, + ], +}; + +test("HostInput appends one ordered questionnaire batch and returns typed answers verbatim", async () => { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Existing document\n\nLast paragraph.\n", + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + expect(cards.map(card => card.definition.questions[0].header)).toEqual([ + "Choice", + "Multiple", + "Custom", + ]); + expect(cards[0]!.definition.questions[0].options[0]).toMatchObject({ + label: " A ", + description: " first ", + }); + let source = Plan.source(f.plan); + expect(source.indexOf("Last paragraph.")).toBeLessThan(source.indexOf(" { + let f = await fixture(); + for (let [value, approved] of [[[0], true], [[1], false], ["Yes", false]] as const) { + let result = f.input.confirm("Proceed?", "This changes files.", f.options); + let [card] = await f.cards(1); + expect(card!.definition.questions[0].question).toBe("Proceed?\n\nThis changes files."); + await f.answer(card!.id, typeof value === "string" ? value : [...value]); + expect(await result).toBe(approved); + } + let selected = f.input.select("Pick", [" second ", "first", "first"], f.options); + let [card] = await f.cards(1); + expect(card!.definition.questions[0].options.map(option => option.label)).toEqual([ + " second ", + "first", + "first", + ]); + await f.answer(card!.id, [0]); + expect(await selected).toBe(" second "); + let custom = f.input.select("Pick", ["yes", "no"], f.options); + await f.answer((await f.cards(1))[0]!.id, "yes"); + expect(await custom).toBeUndefined(); +}); + +test("input and editor are editable free-text cards that round-trip through the document", async () => { + let f = await fixture(); + for (let method of ["input", "editor"] as const) { + let response = f.input[method]("Title", " initial or hint\n", f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]; + expect(question.options).toEqual([]); + expect(question.question).toBe("Title\n\n initial or hint\n"); + let source = Plan.source(f.plan); + let roundTrip = await Room.create(source); + expect(Room.project(roundTrip)).toContain( + `header="${method === "input" ? "Input" : "Editor"}"`, + ); + expect(Room.project(roundTrip)).not.toContain(" { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [1]); + for (let card of cards.slice(1)) { + await Questions.cancel(f.plan, f.server, f.room.id, f.ws, { + kind: "question:cancel", + ts: 0, + rid: "cancel", + id: card.id, + }); + } + expect(await response).toEqual({ + cancelled: true, + answers: [ + { questionIndex: 0, question: " Which? ", kind: "option", answer: "B" }, + ], + }); +}); + +test("abort withdraws only its request's open cards, persists cancellation, and ignores late submission", async () => { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [1]); + f.controller.abort(); + expect(await response).toEqual({ answers: [], cancelled: true }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.get(cards[0]!.id)?.status).toBe("answered"); + for (let card of cards.slice(1)) { + expect(f.plan.records.get(card.id)).toMatchObject({ status: "cancelled", resolver: "chopin" }); + expect(Plan.source(f.plan)).not.toContain(card.id); + expect(f.frames).toContainEqual( + expect.objectContaining({ + kind: "question:resolved", + id: card.id, + status: "cancelled", + resolver: "chopin", + }), + ); + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + ts: 0, + rid: "late", + id: card.id, + revision: 0, + }); + expect(f.plan.records.get(card.id)?.status).toBe("cancelled"); + } + let saved = await Plan.readStored((await f.storage.collaboration.load(f.room.id, new Date()))!); + expect(saved.source).not.toContain(cards[1]!.id); +}); + +test("timeout withdraws unanswered dialogs and returns no answer for every HostInput method", async () => { + let f = await fixture(30); + for ( + let invoke of [ + () => f.input.questionnaire(params, f.options), + () => f.input.confirm("Confirm", "Proceed?", f.options), + () => f.input.select("Select", ["A", "B"], f.options), + () => f.input.input("Input", undefined, f.options), + () => f.input.editor("Editor", undefined, f.options), + ] + ) { + let result = await invoke(); + expect( + result === undefined || result === false + || (typeof result === "object" && result.cancelled && result.answers.length === 0), + ).toBe(true); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + } + expect([...f.plan.records.values()].every(record => record.status === "cancelled")).toBe(true); +}); + +test("workflow identity labels each card while returned question text remains verbatim", async () => { + let f = await fixture(); + let response = f.input.questionnaire(params, { + ...f.options, + workflowRunId: "run-123", + workflowStageId: "review", + }); + let cards = await f.cards(3); + for (let card of cards) { + expect(card.definition.questions[0].question).toContain("Workflow run: run-123; stage: review"); + await f.answer(card.id, [0]); + } + expect((await response).answers.map(answer => answer.question)).toEqual( + params.questions.map(question => question.question), + ); +}); + +test("abort before presentation creates no card and cancellation waits for durable storage", async () => { + let f = await fixture(); + let cancelled = new AbortController(); + cancelled.abort(); + expect(await f.input.confirm("Confirm", "Proceed?", { ...f.options, signal: cancelled.signal })) + .toBe(false); + expect(f.plan.records.size).toBe(0); + let settled = false; + let result = f.input.confirm("Confirm", "Proceed?", f.options).then(value => { + settled = true; + return value; + }); + await f.cards(1); + let gate = Promise.withResolvers(); + let original = f.storage.collaboration.commit; + f.storage.collaboration.commit = async input => { + await gate.promise; + return original(input); + }; + f.controller.abort(); + await Bun.sleep(15); + expect(settled).toBe(false); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toHaveLength(0); + gate.resolve(); + expect(await result).toBe(false); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toHaveLength(1); +}); + +test("blank dialog titles and supplied empty choices remain valid verbatim inputs", async () => { + let f = await fixture(); + let result = f.input.select("", ["", " "], f.options); + let [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await result).toBe(""); + let source = Plan.source(f.plan); + let restored = await Room.create(source); + expect(Room.project(restored)).toContain('label=""'); + expect(Room.project(restored)).toContain('value=""'); + restored.doc.destroy(); +}); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts new file mode 100644 index 000000000..a3812a0c9 --- /dev/null +++ b/apps/server/src/harness/atomic/human-input.ts @@ -0,0 +1,121 @@ +import * as Questions from "../../questions/service"; + +import type { + HostInput, + HostInputOptions, + QuestionAnswer, + QuestionnaireResult, + QuestionParams, +} from "@bastani/atomic"; +import type { DocumentRoom } from "../../agent/tools"; + +/** Atomic owns input requests; Chopin owns their shared decision records. */ +export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostInput { + async function questionnaire( + params: QuestionParams, + options: HostInputOptions, + ): Promise { + if (options.signal.aborted) return { answers: [], cancelled: true }; + let workflow = [ + options.workflowRunId === undefined ? undefined : `Workflow run: ${options.workflowRunId}`, + options.workflowStageId === undefined ? undefined : `stage: ${options.workflowStageId}`, + ].filter(value => value !== undefined).join("; "); + let definition = Questions.identify({ + questions: params.questions.map(question => ({ + header: question.header, + question: workflow ? `${question.question}\n\n${workflow}` : question.question, + options: question.options.map(option => ({ + label: option.label, + description: option.description, + })), + multiple: question.multiSelect ?? false, + })), + }, { verbatim: true }); + let deadline = new AbortController(); + let signal = AbortSignal.any([options.signal, deadline.signal]); + let timer = timeoutMs === undefined ? undefined : setTimeout(() => deadline.abort(), timeoutMs); + let ended; + try { + ended = await Questions.ask( + room.plan, + room.server, + room.id, + definition, + undefined, + room.anchors, + signal, + ); + } finally { + clearTimeout(timer); + } + if (signal.aborted) return { answers: [], cancelled: true }; + let answers: QuestionAnswer[] = []; + ended.forEach((outcome, questionIndex) => { + if (outcome.status !== "answered") return; + let answer = outcome.answers[0]!; + let question = params.questions[questionIndex]!; + let identity = { questionIndex, question: question.question }; + if (answer.custom !== undefined) { + answers.push({ ...identity, kind: "custom", answer: answer.custom }); + } else if (question.multiSelect) { + answers.push({ ...identity, kind: "multi", answer: null, selected: answer.choices ?? [] }); + } else { + let label = answer.choices![0]!; + let preview = question.options.find(option => option.label === label)?.preview; + answers.push({ + ...identity, + kind: "option", + answer: label, + ...(preview === undefined ? {} : { preview }), + }); + } + }); + return { answers, cancelled: ended.some(outcome => outcome.status === "cancelled") }; + } + async function single( + header: string, + question: string, + choices: string[], + options: HostInputOptions, + ) { + let result = await questionnaire({ + questions: [{ + header, + question, + options: choices.map(label => ({ label, description: "" })), + }], + }, options); + return result.cancelled ? undefined : result.answers[0]; + } + return { + questionnaire, + async confirm(title, message, options) { + let answer = await single("Confirm", `${title}\n\n${message}`, ["Yes", "No"], options); + return answer?.kind === "option" && answer.answer === "Yes"; + }, + async select(title, choices, options) { + let answer = await single("Select", title, choices, options); + return answer?.kind === "option" && answer.answer !== null && choices.includes(answer.answer) + ? answer.answer + : undefined; + }, + async input(title, placeholder, options) { + let answer = await single( + "Input", + [title, placeholder].filter(value => value !== undefined).join("\n\n"), + [], + options, + ); + return answer?.kind === "custom" ? answer.answer ?? undefined : undefined; + }, + async editor(title, initial, options) { + let answer = await single( + "Editor", + [title, initial].filter(value => value !== undefined).join("\n\n"), + [], + options, + ); + return answer?.kind === "custom" ? answer.answer ?? undefined : undefined; + }, + }; +} diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index 408d6f647..54ca0c19f 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -4,6 +4,7 @@ import { createPiAdapter } from "./pi/adapter"; import type { HarnessV1 } from "@ai-sdk/harness"; import type { AtomicAuthMode } from "./atomic/adapter"; +import type { Config } from "../config"; const credentials = new Map string | undefined; @@ -17,10 +18,13 @@ function createPiHarness(settings: { auth?: string }): HarnessV1 & { shutdown(): } function createAtomicHarness( - settings: { auth?: string }, + settings: { auth?: string; fullPlanner?: boolean }, ): HarnessV1 & { shutdown(): Promise } { // Like Pi, Atomic takes neither a GitHub credential nor a credit limit. - return createAtomicAdapter({ auth: settings.auth as AtomicAuthMode }); + return createAtomicAdapter({ + auth: settings.auth as AtomicAuthMode, + fullPlanner: settings.fullPlanner, + }); } export const harnesses = { @@ -33,6 +37,7 @@ export const harnesses = { credentials: (id: string) => string | undefined; limits: (id: string) => { maxAiCredits: number } | undefined; auth?: string; + fullPlanner?: boolean; }) => HarnessV1 >; @@ -62,6 +67,7 @@ export function harnessFor(config: { harness: string; harnessAuth?: string; host?: string; + atomicPlanner?: Config["atomicPlanner"]; }): HarnessV1 { if (!Object.hasOwn(harnesses, config.harness)) { throw new Error(`Unknown harness: ${config.harness}`); @@ -93,6 +99,7 @@ export function harnessFor(config: { return maxAiCredits === undefined ? undefined : { maxAiCredits }; }, auth, + fullPlanner: !!config.atomicPlanner, }); } diff --git a/apps/server/src/harness/pi/model-stub.ts b/apps/server/src/harness/pi/model-stub.ts index 869992d96..21a257057 100644 --- a/apps/server/src/harness/pi/model-stub.ts +++ b/apps/server/src/harness/pi/model-stub.ts @@ -43,6 +43,7 @@ export type StubRequest = { system: string; toolNames: string[]; hasPriorToolResult: boolean; + toolResults: string[]; }; export function startStubModelServer( @@ -73,6 +74,9 @@ export function startStubModelServer( system, hasPriorToolResult, toolNames: (body.tools ?? []).map(entry => entry.function?.name ?? ""), + toolResults: body.messages.filter(message => message.role === "tool").map(message => + extractText(message.content) + ), }); let turn = respond(prompt, hasPriorToolResult); let id = crypto.randomUUID(); diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index d36e67091..35b6e3533 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; import { openPlannerSession } from "./session"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fullPlanner } from "./atomic/full"; +import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { PlannerSessionDependencies } from "./session"; @@ -110,4 +115,52 @@ describe("openPlannerSession", () => { expect(result).toMatchObject({ ok: false, error: { kind: "Timeout" } }); expect(destroyed).toEqual({ sandbox: 1, session: 0, unregistered: 1 }); }); + it("registers full Planner input only for verified checkouts and releases it with the session", async () => { + let cwd = await mkdtemp(join(tmpdir(), "chopin-planner-session-")); + try { + for ( + let args of [["init", "--quiet"], ["remote", "add", "origin", "workgit:owner/repo.git"]] + ) { + expect( + await Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" }).exited, + ).toBe(0); + } + for (let checkouts of [[cwd], []]) { + let { owner, channel, deps } = fixture(); + let sessionId = ""; + let instructions = ""; + deps.agent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + }, + } as never; + let opened = await openPlannerSession(owner, { + ...channel, + atomicPlanner: { checkouts }, + instructions: checkout => plannerInstructions("owner/repo", "BOOTSTRAP", checkout), + }, deps); + expect(opened.ok).toBe(true); + if (!opened.ok) throw new Error("Planner unavailable"); + await opened.value.stream("prompt", new AbortController().signal); + expect(instructions).toContain("BOOTSTRAP"); + if (checkouts.length) { + expect(fullPlanner(sessionId)?.cwd).toBe(cwd); + expect(fullPlanner(sessionId)?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(cwd); + expect(instructions).not.toContain("You have no shell"); + } else { + expect(fullPlanner(sessionId)).toBeUndefined(); + expect(instructions).toContain("You have no shell"); + } + await opened.value.destroy(); + expect(fullPlanner(sessionId)).toBeUndefined(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index e5e1346b1..572d3d7e4 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -3,11 +3,15 @@ import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; import { headingPlannerAgent, plannerAgent, prosePlannerAgent, refinePlannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; +import { verifiedCheckout } from "./atomic/checkout"; +import { registerFullPlanner } from "./atomic/full"; +import { createHumanInput } from "./atomic/human-input"; import type { HarnessAgent } from "@ai-sdk/harness/agent"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { HostedRepository } from "../agent/repository"; import type { DocumentRoom } from "../agent/tools"; +import type { Config } from "../config"; export type OpenError = | GitHubToolsError @@ -19,11 +23,13 @@ type Sandbox = Awaited>; type PlannerAgent = typeof plannerAgent; -type PlannerChannel = { +export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string; + instructions: string | ((checkout?: string) => string); model?: string; + atomicPlanner?: Config["atomicPlanner"]; + checkout?: string; }; export type PlannerSession = { @@ -83,12 +89,30 @@ export async function openPlannerSession( return { ok: false, error: { kind: "Unavailable", cause } }; } let unregister: (() => void) | undefined; + let unregisterFull: (() => void) | undefined; let session: Awaited> | undefined; let timeout: ReturnType | undefined; try { if (owner.signal.aborted) throw new Error("Planner owner unavailable"); let sessionId = crypto.randomUUID(); unregister = (deps.registerCredential ?? registerCredential)(sessionId, owner.currentToken); + // Background jobs keep the isolated boundary; only a Planner turn may run as a full session. + let cwd = channel.atomicPlanner && !job + ? await verifiedCheckout( + channel.repository, + channel.atomicPlanner.checkouts, + channel.checkout, + ) + : undefined; + if (cwd) { + unregisterFull = registerFullPlanner(sessionId, { + cwd, + humanInput: createHumanInput(channel.room, channel.atomicPlanner?.inputTimeoutMs), + }); + } + let instructions = typeof channel.instructions === "function" + ? channel.instructions(cwd) + : channel.instructions; let agent = deps.agent ?? (job?.kind === "heading" ? deps.headingAgent ?? headingPlannerAgent : job?.kind === "refine" || job?.kind === "suggest" @@ -126,6 +150,7 @@ export async function openPlannerSession( abortSignal, options: { ...channel, + instructions, owner, githubTools: tools.value, }, @@ -139,6 +164,7 @@ export async function openPlannerSession( await sandbox.destroy(); } finally { release(); + unregisterFull?.(); } } })(), @@ -156,6 +182,7 @@ export async function openPlannerSession( console.error("[agent] Planner sandbox cleanup failed:", cleanupError); } unregister?.(); + unregisterFull?.(); let message = cause instanceof Error ? cause.message : String(cause); let kind: "Timeout" | "ShuttingDown" | "HarnessCapabilityUnsupported" | "Unavailable" = message.includes("timed out") diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts index ccf396a70..34ad2d8fc 100644 --- a/apps/server/src/questions/service-ask.ts +++ b/apps/server/src/questions/service-ask.ts @@ -22,6 +22,7 @@ import type { Ended } from "./store"; import type { Record } from "./records"; import { announce } from "./card-notifications"; +import { withdraw } from "./service-cancel"; import { type AskPlacement, validatePlacement } from "./service-definition"; export async function ask( @@ -31,7 +32,9 @@ export async function ask( definition: Definition, placement?: AskPlacement, created?: () => void, + signal?: AbortSignal, ): Promise { + if (signal?.aborted) return []; if (Service.implementationActive(plan)) throw new Error("implementation is active"); if (definition.questions.length === 0) { Question.reject("A questionnaire needs at least one question"); @@ -58,7 +61,8 @@ export async function ask( 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 + // Host dialogs keep their raw text, which conversation events cannot carry. + let threadId = !question.verbatim && conversationPlan.threads.length < MAX_THREADS && question.options.length + 2 <= available ? ulid() : undefined; @@ -175,5 +179,21 @@ export async function ask( created?.(); }); - return Promise.all(asked.map(item => item.waiting)); + let failed = Promise.withResolvers(); + let withdrawing: Promise | undefined; + let abort = () => { + withdrawing ??= Promise.all( + asked.map(item => withdraw(plan, server, roomId, item.id, "chopin")), + ); + void withdrawing.catch(failed.reject); + }; + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + try { + let ended = await Promise.race([Promise.all(asked.map(item => item.waiting)), failed.promise]); + await withdrawing; + return ended; + } finally { + signal?.removeEventListener("abort", abort); + } } diff --git a/apps/server/src/questions/service-cancel.ts b/apps/server/src/questions/service-cancel.ts index e02d2678c..9858be397 100644 --- a/apps/server/src/questions/service-cancel.ts +++ b/apps/server/src/questions/service-cancel.ts @@ -20,17 +20,53 @@ export async function cancel( 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 result: Awaited>; + let acknowledged = false; + try { + result = await withdraw(plan, server, roomId, msg.id, ws.data.handle, () => { + acknowledged = true; + reply(ws, msg.rid, { + kind: "question:cancel", + ts: 0, + id: msg.id, + ok: true, + resolver: ws.data.handle, + }); + }); + } catch (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 (!acknowledged) reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...result }); +} + +/** + * Withdraw input on behalf of a member or the Planner, after its durable commit. + * `acknowledge` runs first once the withdrawal is committed, before it is announced. + */ +export async function withdraw( + plan: Plan, + server: Server, + roomId: string, + id: string, + resolver: string, + acknowledge?: () => void, +): Promise { + let claimed = Store.claimCancel(plan.questions, id, resolver); + if (!claimed.ok) return 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) { + if (plan.questions.open.get(id) !== claimed.claim.entry) { throw new Error("questionnaire is no longer open"); } let document = await room.restore( @@ -43,15 +79,15 @@ export async function cancel( try { let mutation: room.Mutation | undefined; try { - mutation = room.removeQuestionnaire(document, msg.id); + mutation = room.removeQuestionnaire(document, id); } catch (err) { mutationError = err; return; } - let record = plan.records.get(msg.id); + let record = plan.records.get(id); let records = new Map(plan.records); if (record) { - records.set(msg.id, { ...record, status: "cancelled", resolver: ws.data.handle }); + records.set(id, { ...record, status: "cancelled", resolver }); } let questions: Store.Questions = { open: new Map(plan.questions.open), @@ -66,8 +102,8 @@ export async function cancel( pendingCardActions: record ? pending(plan, record, { kind: "discarded", - id: msg.id, - actor: ws.data.handle, + id, + actor: resolver, }) : plan.pendingCardActions, }, mutation); @@ -80,54 +116,32 @@ export async function cancel( 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 (failure) throw failure; 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", - }); + return { ok: false, reason: "resolving" }; } - if (!finish) return fail(ws, msg.rid, "could not cancel the questionnaire"); + if (!finish) throw new Error("could not cancel the questionnaire"); finish(); - let record = plan.records.get(msg.id); + let record = plan.records.get(id); for ( let notify of [ - () => - reply(ws, msg.rid, { - kind: "question:cancel", - ts: 0, - id: msg.id, - ok: true, - resolver: ws.data.handle, - }), + ...(acknowledge ? [acknowledge] : []), () => broadcast(server, roomId, { kind: "question:resolved", ts: 0, - id: msg.id, + id, status: "cancelled", - resolver: ws.data.handle, + resolver, }), - () => announce(plan, server, roomId, msg.id), + () => announce(plan, server, roomId, id), () => emit(plan, { kind: "discarded", - id: msg.id, + id, ...(record?.threadId ? { threadId: record.threadId } : {}), - actor: ws.data.handle, + actor: resolver, }), ] ) { @@ -137,4 +151,5 @@ export async function cancel( console.error("[questions] could not announce a cancelled questionnaire:", err); } } + return { ok: true, resolver }; } diff --git a/apps/server/src/questions/service-definition.ts b/apps/server/src/questions/service-definition.ts index 5fc1ef3f6..439dd37dc 100644 --- a/apps/server/src/questions/service-definition.ts +++ b/apps/server/src/questions/service-definition.ts @@ -14,8 +14,8 @@ export type AskPlacement = { blocks: Array>; }; -export function identify(raw: unknown): Definition { - let definition = Question.normalize(raw); +export function identify(raw: unknown, options?: { verbatim?: boolean }): Definition { + let definition = Question.normalize(raw, options); return { questions: definition.questions.map(question => ({ ...question, diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index 3ab8e4e03..a11102554 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -32,7 +32,7 @@ export { 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 { cancel, withdraw } from "./service-cancel"; export { identify } from "./service-definition"; export type { AskPlacement } from "./service-definition"; export { discard } from "./service-discard"; diff --git a/docs/authentication.md b/docs/authentication.md index ec24bed55..afd14dabd 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -202,6 +202,13 @@ Each browser has its own binding; configured instance admission lists still apply to every authorized account. Loopback binding does not make this a mode for internet-facing or exposed multi-user deployments. +`ATOMIC_PLANNER=full` additionally enables local Atomic tools and resources for +Planner sessions with a verified repository checkout. It requires `HARNESS=atomic` +and grants shell and filesystem access as the operator; loopback is not a filesystem +sandbox. Both Atomic auth modes (`auto` and `ai-gateway`) are allowed. Keep this a +trusted single-operator instance, with no public proxy or tunnel. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode). + ## Instance admission `GITHUB_ALLOWED_USERS` and `GITHUB_ALLOWED_ORGANIZATIONS` are optional, diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index fae44c3ce..9902716a8 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -3,8 +3,8 @@ Chopin's Copilot-backed document agent is currently named Planner. It can inspect one selected GitHub repository, co-author the shared document, ask the participants structured questions, and anchor decisions to prose. For documents -used as plans, it can also draft an implementation graph. It does not implement -code or change GitHub. +used as plans, it can also draft an implementation graph. By default it does not +implement code or change GitHub; the local full Atomic mode below changes its tool boundary. The product role is document co-authoring. The current prompt and tool vocabulary remain optimized for planning and may structure another document type as a plan; @@ -15,8 +15,8 @@ from a code-owned map, defaulting to `copilot-sdk`, a host-process adapter over `@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, and `atomic`, which embeds Atomic's headless SDK (`@bastani/atomic`) in the server process, are further reviewed adapters. Both require an explicit `HARNESS_AUTH`. The -harness owns the agent loop and model; Chopin owns every tool the Planner can -call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` +harness owns the agent loop and model; by default Chopin owns every tool the Planner +can call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` selection and adapter trust. ## Ownership @@ -44,7 +44,7 @@ ownership generation. ## Runtime isolation -Every harness receives only Chopin's host-executed tools: document, question, +By default every harness receives only Chopin's host-executed tools: document, question, relationship, implementation-graph, repository, and GitHub tools, each bound to the channel's repository through `toolsContext`. No harness built-in is active for the Planner. The default `copilot-sdk` adapter additionally runs the shared @@ -53,7 +53,7 @@ owner's token when created and has no client-level service token or logged-in-user fallback. That detail is specific to the Copilot SDK adapter, not a property every harness in `HARNESS` shares. -The Planner has no: +The isolated Planner has no: - checkout, shell, or host filesystem; - skills, plugins, or configuration discovery; @@ -65,8 +65,8 @@ Under `HARNESS=pi`, Chopin patches `@ai-sdk/harness-pi` 1.0.128 so Pi does not load `AGENTS.md` or `CLAUDE.md` context files from the host filesystem. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness). -Under `HARNESS=atomic`, each harness session owns one in-process Atomic -`AgentSession`. It is built with every shipped Atomic package disabled +Under `HARNESS=atomic` without a verified full-mode checkout, each harness session +owns one in-process Atomic `AgentSession`. It is built with every shipped Atomic package disabled (workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding tools, and its resource loader discovers no extensions, skills, prompt templates, themes, or context files. Its working and configuration directory is @@ -97,15 +97,69 @@ REST tools construct owner and repository coordinates on the server, bound response sizes and line ranges, reject path escape, and post-filter code search by GitHub repository node ID. -The Planner does not see a user's local checkout, current branch, working tree, +The isolated Planner does not see a user's local checkout, current branch, working tree, or uncommitted changes. A coding agent must compare the repository context returned by Chopin with its checkout before claiming work. The server validates the shape of creation provenance but does not resolve its branch and commit against GitHub or independently inspect the coding agent's checkout. +## Full Atomic Planner in local mode + +`ATOMIC_PLANNER=full` is an explicit trust change, accepted only with +`AUTH_MODE=local` and `HARNESS=atomic`. Other combinations and unrecognized +nonempty values fail startup. Both `HARNESS_AUTH=auto` and `ai-gateway` work. +This gives the Planner shell and filesystem access **as the local operator**, +not a sandbox confined to a repository. Use it only on a trusted single-operator, +loopback-only instance; never expose that instance through a proxy or tunnel. + +Set `ATOMIC_PLANNER_CHECKOUTS` to absolute checkout paths separated by the +platform path delimiter (`:` on Unix, `;` on Windows). Before each Planner +session, Chopin checks candidate paths with Git and compares `origin`'s +owner/repository to the channel repository, case-insensitively. HTTPS, +`ssh://`, and scp-style remotes are accepted. Remote host spellings are ignored +because SSH aliases are common; this verifies repository coordinates, not the +authenticity of a remote host. A per-invocation candidate takes precedence over +the configured list. No verified checkout means the same isolated session as +before, with no Atomic resources or HostInput binding. + +With a verified checkout, it becomes the session's working directory. Atomic's +workflows, subagents, MCP, web access, Intercom, and default coding tools run +alongside Chopin's document tools. The normal Atomic agent directory (including +`ATOMIC_CODING_AGENT_DIR` or the legacy `PI_CODING_AGENT_DIR` override) supplies +extensions, skills, prompt templates, context files, and a read-only copy of its +settings. Chopin appends its Planner instructions to Atomic's assembled prompt. +Session, settings, and model credentials remain in memory; Atomic's own enabled +tools, extensions, MCP servers, and workflow storage can perform local writes. +Background summary and research workers still use isolated sessions. + +Chopin implements Atomic's `HostInput`, bound through +`extensionBindings.humanInput`; it does not intercept tools. `ask_user_question`, +extension dialogs, and workflow-stage input become ordinary shared Decisions: + +- Questionnaires retain question and option order, multi-selection, and custom + text; answers return their Atomic question indices and answer kinds, including + a selected option's preview. +- Confirm uses Yes/No; only choosing Yes approves. Select returns only a supplied + choice. Input and editor use free-text cards; hints and initial editor text + appear verbatim in the prompt. Explicitly submitted empty text is an answer, + not cancellation. +- Workflow cards show `Workflow run: ; stage: ` below their question. + A request is appended as one adjacent batch at the end of the document. +- An abort withdraws still-open cards, removes their document nodes, and records + cancellation by `@chopin` in Decisions. Member cancellations retain any answers + already given in that batch. Late submissions cannot approve withdrawn input. +- `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` optionally limits each request (a positive + integer in milliseconds, at most 2147483647). Unset waits indefinitely. Expiry + withdraws pending cards and returns no answer; confirm returns false. + +Chopin's question size limits still apply: oversized dialogs fail rather than +silently truncating text. Atomic keeps durable workflow approvals pending on +withdrawal; Chopin does not automatically resume workflows or replay interrupted +turns after a restart. A new Atomic session must explicitly resume a saved run. + ## Permission checks -Before each custom or MCP tool executes, callbacks recheck: +Before each Chopin host tool or its repository-bound GitHub MCP tool executes, callbacks recheck: - current instance admission; - the owner process session and its user; @@ -117,6 +171,8 @@ Before each custom or MCP tool executes, callbacks recheck: Permission is decided before execution. A refusal therefore produces no normal tool start or completion event; the Chat service renders permission denials explicitly so the boundary remains visible. +These checks do not mediate full mode's Atomic coding tools, operator extensions, +or operator-configured MCP servers; those use the operator's local authority. A harness session is bound to one credential revision. Before an eight-hour GitHub App token refresh, Chopin aborts and discards every Planner session diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 1285d07ad..05fd69a51 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -117,10 +117,9 @@ covers this against the real Pi agent loop; run it before bumping `HARNESS=atomic` runs Atomic 0.9.25 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use -RPC mode, or substitute Atomic for Pi's runtime. Each harness session owns one -in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see -[Hosted agent](hosted-agent.md#runtime-isolation) for the isolation it -enforces. +RPC mode, or substitute Atomic for Pi's runtime. By default each harness session owns +one in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see +[Hosted agent](hosted-agent.md#runtime-isolation) for isolation and its local full-mode exception. `HARNESS=atomic` requires an explicit `HARNESS_AUTH`, either `auto` or `ai-gateway`. Unset, `direct`, and every other value are refused at startup. @@ -165,14 +164,15 @@ Atomic caveats: enables its shipped packages, including a mandatory Intercom, and its coding tools. It discovers host resources, gives turns without instructions a coding-agent system prompt, and saves tool results over 50,000 characters to a - temporary file. The adapter overrides each default and fails closed on the + temporary file. The isolated adapter overrides each default and fails closed on the ones it can observe, but a new Atomic release can add a default the suite does - not check. + not check. Local full mode deliberately enables those capabilities. - Sessions live only in memory. `doStop` returns a state the adapter refuses to resume, so a session never survives a restart. Chopin does not resume harness sessions. -- Compaction, suspending a turn, and skills are unsupported. Atomic's automatic - retries remain on. +- Harness-level compaction, suspending a turn, and supplied harness skills are + unsupported. Full-mode resource discovery does load Atomic skills. Atomic's + automatic retries remain on. - Under `auto`, refreshing an OAuth token in memory can rotate the refresh token stored by the host CLI, which may then ask the operator to sign in again. `auto` also runs `!command` API-key entries in `auth.json` to resolve them. @@ -242,32 +242,35 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| ------------------------------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | -| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | -| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | -| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | -| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on`; never send it to the browser. | +| Variable | Default | Meaning | +| --------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | +| `ATOMIC_PLANNER` | unset | Set exactly `full` to allow full Atomic Planner sessions. Requires `AUTH_MODE=local` and `HARNESS=atomic`; every other nonempty value or combination fails startup. Both Atomic auth modes are supported. | +| `ATOMIC_PLANNER_CHECKOUTS` | unset | Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. | +| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | +| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | +| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | +| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on`; never send it to the browser. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. @@ -435,6 +438,39 @@ App authorization, because device-issued refresh tokens can be refreshed without the client secret but GitHub's revocation endpoint requires it. Remove the App from **Settings > Applications** on GitHub to revoke it there. +### Opt into a full Atomic Planner + +Add these settings to the local-mode source deployment above, with an explicit +Atomic `MODEL` and either `HARNESS_AUTH=auto` or `HARNESS_AUTH=ai-gateway`: + +```bash +export HARNESS=atomic +export HARNESS_AUTH=auto +export MODEL="github-copilot/gpt-6-luna" +export ATOMIC_PLANNER=full +export ATOMIC_PLANNER_CHECKOUTS="/absolute/path/to/checkout" +# Optional: withdraw a request after ten minutes without a complete answer. +export ATOMIC_PLANNER_INPUT_TIMEOUT_MS=600000 +``` + +This is not the hosted tool boundary: verified Planner sessions enable Atomic's +shell, filesystem, builtins, and the operator's normal extensions, skills, +templates, and context files. The process runs with the operator's local +privileges. Local mode's loopback restriction and single-operator trust are +essential; never expose it publicly or forward it to untrusted users. + +Before a session starts, Chopin verifies that its cwd is a Git work tree whose +`origin` owner/repository matches the channel, ignoring case and remote host +spelling (so SSH host aliases work). A mismatching or unavailable checkout +falls back to the default isolated behavior, not to the server's cwd. +The check does not establish remote-host authenticity or confine shell access. + +Atomic input appears as shared Decisions instead of terminal dialogs, including +workflow run/stage labels. Unanchored batches append at the document's end. +Cancellation or timeout withdraws pending cards and never approves an action. +See [Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) +for mapping, resource loading, persistence, and workflow restart boundaries. + ## First-start smoke test Startup validates configuration, database connectivity, migration history, the diff --git a/packages/dialect/src/nodes/questionnaire-mdx.ts b/packages/dialect/src/nodes/questionnaire-mdx.ts index 43d44720a..141e8910e 100644 --- a/packages/dialect/src/nodes/questionnaire-mdx.ts +++ b/packages/dialect/src/nodes/questionnaire-mdx.ts @@ -95,16 +95,27 @@ export function toElement(value: Questionnaire): MdxJsxFlowElement { children: value.questions.map(question => ({ type: "mdxJsxFlowElement", name: "Question", - attributes: identity(question.id, { - header: question.header, - prompt: question.prompt, - multiple: String(question.multiple), - }), + attributes: [ + ...identity(question.id), + { type: "mdxJsxAttribute" as const, name: "header", value: question.header }, + { type: "mdxJsxAttribute" as const, name: "prompt", value: question.prompt }, + { type: "mdxJsxAttribute" as const, name: "multiple", value: String(question.multiple) }, + ], children: [ ...question.options.map(option => ({ type: "mdxJsxFlowElement" as const, name: "Option", - attributes: identity(option.id, { label: option.label, description: option.description }), + attributes: [ + ...identity(option.id), + { type: "mdxJsxAttribute" as const, name: "label", value: option.label }, + ...(option.description + ? [{ + type: "mdxJsxAttribute" as const, + name: "description", + value: option.description, + }] + : []), + ], children: [], })), // A projection of sidecar state: addressed through its Question, @@ -112,7 +123,10 @@ export function toElement(value: Questionnaire): MdxJsxFlowElement { ...(question.answer === undefined ? [] : [{ type: "mdxJsxFlowElement" as const, name: "Answer", - attributes: attributes({ value: question.answer, choices: question.choices?.join(" ") }), + attributes: [ + { type: "mdxJsxAttribute" as const, name: "value", value: question.answer }, + ...attributes({ choices: question.choices?.join(" ") }), + ], children: [], }]), ...(question.previous diff --git a/packages/dialect/src/validate.ts b/packages/dialect/src/validate.ts index 236fd6373..ba503105c 100644 --- a/packages/dialect/src/validate.ts +++ b/packages/dialect/src/validate.ts @@ -346,7 +346,7 @@ class Validator { break; case "text": - if (!value.trim()) { + if (!value.trim() && !["Question", "Option", "Answer"].includes(spec.name)) { this.add("empty-attribute", `\`${spec.name}.${name}\` cannot be empty`, path, node); } else if (value.length > attribute.max) { this.add( @@ -407,7 +407,11 @@ class Validator { stringAttribute(parent, "status") ?? "", ) && stringAttribute(node, "multiple") === "false"; - if (found === 0 && !pendingQuestion) { + // A free-text question, from a card with no thread or status, has no options + // until its answer is projected. + let freeTextQuestion = spec.name === "Question" && parent?.name === "Questionnaire" + && !stringAttribute(parent, "thread") && stringAttribute(parent, "status") === undefined; + if (found === 0 && !pendingQuestion && !freeTextQuestion) { this.add( "missing-children", `\`${spec.name}\` requires at least one ${allowed.join(" or ")}`, diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index ba1a8bc4c..aa7a8116e 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -56,6 +56,8 @@ export declare namespace Question { question: string; options: Option[]; multiple: boolean; + /** Host dialogs preserve raw text, including an explicitly submitted empty answer. */ + verbatim?: true; }; export type Definition = { diff --git a/packages/question/src/answer.ts b/packages/question/src/answer.ts index 20a2c12a2..37392fd1d 100644 --- a/packages/question/src/answer.ts +++ b/packages/question/src/answer.ts @@ -37,7 +37,10 @@ export function derive(definition: Definition, drafts: Drafts): Outcome { } if (draft!.mode === "custom") { - answers.push({ question: question.question, custom: draft!.custom.trim() }); + answers.push({ + question: question.question, + custom: question.verbatim ? draft!.custom : draft!.custom.trim(), + }); continue; } diff --git a/packages/question/src/draft.ts b/packages/question/src/draft.ts index 943111576..fe25a1df6 100644 --- a/packages/question/src/draft.ts +++ b/packages/question/src/draft.ts @@ -39,7 +39,9 @@ export function create(definition: Definition): Model { } questions[question.id] = crdt.schema.obj({ - mode: crdt.schema.val(crdt.schema.con("choices")), + mode: crdt.schema.val( + crdt.schema.con(question.verbatim && !question.options.length ? "custom" : "choices"), + ), choice: crdt.schema.val(crdt.schema.con(null)), options: crdt.schema.obj(options), // A CRDT string, so two people typing a custom answer merge rather @@ -160,7 +162,7 @@ export function read(model: Model, definition: Definition): Drafts { /** Whether a question has enough of an answer to submit. */ export function answered(question: Item, draft: Draft | undefined): boolean { if (!draft) return false; - if (draft.mode === "custom") return !!draft.custom.trim(); + if (draft.mode === "custom") return question.verbatim || !!draft.custom.trim(); if (question.multiple) return question.options.some(option => draft.options[option.id]); return question.options.some(option => option.id === draft.choice); } diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index f31d9855f..e3fdef5f3 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -41,6 +41,19 @@ describe("normalize", () => { expect(definition.questions[0]!.header).toBe("Rollout"); expect(definition.questions[0]!.options[1]!.description).toBe(""); }); + it("supports verbatim host dialogs without choices, including empty text replies", () => { + let definition = normalize(tool({ header: " Title ", options: [] }), { verbatim: true }); + expect(definition.questions[0]!.header).toBe(" Title "); + let drafts = read(create(definition), definition); + expect(drafts.q0!.mode).toBe("custom"); + for (let custom of ["", " ", " answer\n"]) { + drafts.q0!.custom = custom; + expect(derive(definition, drafts)).toEqual({ + ok: true, + answers: [{ question: "How should we deploy?", custom }], + }); + } + }); it("rejects unknown fields instead of ignoring them", () => { expect(() => normalize({ questions: [], extra: 1 })).toThrow(QuestionError); diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index 0587907f5..1252ac1e2 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -3,6 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { currentQuestion, QuestionView } from "./question-view"; +import { create, normalize, read } from "../index"; test("a replacement definition falls back before rendering when its active question disappears", () => { let storage = { @@ -225,3 +226,17 @@ test("Next waits for an answer to the current question, but not in a read-only v "Next", )).not.toContain("disabled"); }); + +test("a free-text host dialog renders an editable textarea without inventing a choice", () => { + let definition = normalize({ + questions: [{ header: "Input", question: "Write the brief", options: [], multiple: false }], + }, { verbatim: true }); + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: read(create(definition), definition), + onChange() {}, + })); + expect(markup).toContain(", keys: string[], name: string): vo } } -function text(value: unknown, name: string, max: number, optional = false): string { +function text( + value: unknown, + name: string, + max: number, + optional = false, + verbatim = false, +): string { if (typeof value !== "string") fail(`${name} must be text`); - let result = value.trim(); + let result = verbatim ? value : value.trim(); if (!optional && !result) fail(`${name} is required`); if (result.length > max) fail(`${name} exceeds ${max} characters`); return result; @@ -68,7 +74,7 @@ function storedText(value: unknown, name: string, max: number, optional = false) * * @throws {QuestionError} */ -export function normalize(raw: unknown): Definition { +export function normalize(raw: unknown, { verbatim = false } = {}): Definition { let args = record(raw, "Question tool input"); exact(args, ["questions"], "Question tool input"); @@ -83,7 +89,7 @@ export function normalize(raw: unknown): Definition { let raw = record(source, `Question ${index + 1}`); exact(raw, ["header", "question", "options", "multiple"], `Question ${index + 1}`); - if (!Array.isArray(raw.options) || raw.options.length === 0) { + if (!Array.isArray(raw.options) || (!verbatim && raw.options.length === 0)) { fail(`Question ${index + 1} requires at least one option`); } if (raw.options.length > limits.MAX_OPTIONS) { @@ -102,12 +108,15 @@ export function normalize(raw: unknown): Definition { option.label, `Question ${index + 1} option ${position + 1} label`, limits.MAX_LABEL, + verbatim, + verbatim, ), description: text( option.description, `Question ${index + 1} option ${position + 1} description`, limits.MAX_DESCRIPTION, true, + verbatim, ), }; }); @@ -117,10 +126,23 @@ export function normalize(raw: unknown): Definition { return { id: `q${index}`, - header: text(raw.header, `Question ${index + 1} header`, limits.MAX_HEADER), - question: text(raw.question, `Question ${index + 1}`, limits.MAX_QUESTION), + header: text( + raw.header, + `Question ${index + 1} header`, + limits.MAX_HEADER, + verbatim, + verbatim, + ), + question: text( + raw.question, + `Question ${index + 1}`, + limits.MAX_QUESTION, + verbatim, + verbatim, + ), options, multiple: raw.multiple, + ...(verbatim ? { verbatim: true as const } : {}), }; }); @@ -144,17 +166,24 @@ export function identified(raw: unknown): Definition { let optionIds = new Set(); for (let [index, candidate] of source.questions.entries()) { let question = record(candidate, `Question ${index + 1}`); - exact(question, ["id", "header", "question", "options", "multiple"], `Question ${index + 1}`); + let verbatim = "verbatim" in question; + if (verbatim && question.verbatim !== true) fail(`Question ${index + 1} has invalid fields`); + exact( + question, + ["id", "header", "question", "options", "multiple", ...(verbatim ? ["verbatim"] : [])], + `Question ${index + 1}`, + ); storedText(question.id, `Question ${index + 1} id`, limits.MAX_CALL_ID); let id = question.id as string; if (questionIds.has(id)) fail("Questionnaire has duplicate question IDs"); questionIds.add(id); - storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER); - storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION); + storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER, verbatim); + storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION, verbatim); if ( typeof question.multiple !== "boolean" || !Array.isArray(question.options) - || question.options.length === 0 && (source.questions.length !== 1 || question.multiple) + || question.options.length === 0 && !verbatim + && (source.questions.length !== 1 || question.multiple) || question.options.length > limits.MAX_OPTIONS ) { fail(`Question ${index + 1} has invalid options or multiple value`); @@ -166,7 +195,7 @@ export function identified(raw: unknown): Definition { let optionId = option.id as string; if (optionIds.has(optionId)) fail("Questionnaire has duplicate option IDs"); optionIds.add(optionId); - storedText(option.label, `Question ${index + 1} option label`, limits.MAX_LABEL); + storedText(option.label, `Question ${index + 1} option label`, limits.MAX_LABEL, verbatim); storedText( option.description, `Question ${index + 1} option description`, From 59a8712b8873d6e5d159ca6f38ceb7f9522a3459 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 20:45:08 -0700 Subject: [PATCH 02/12] Declare MCP output schemas as objects Wrap the rename, archive and restore result unions in object schemas without changing their branches. Check every exported tool and the fully enabled MCP tools/list response. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: focused MCP tests passed: 81 tests, 450 assertions; the new invariant failed before the fix on rename_document Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix and bun run ci passed: formatting inspected, no errors and one existing lint warning User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- apps/server/src/mcp.ts | 3 +++ apps/server/src/mcp/documents.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index e92f569a8..b19d931dc 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -402,6 +402,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome([ @@ -430,6 +431,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ARCHIVED_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), @@ -453,6 +455,7 @@ export const TOOLS: Tool[] = [ additionalProperties: false, }, outputSchema: { + type: "object", oneOf: [ ACTIVE_DOCUMENT, outcome(["repository-forbidden", "document-unavailable"]), diff --git a/apps/server/src/mcp/documents.test.ts b/apps/server/src/mcp/documents.test.ts index ba51ab995..8a522ee41 100644 --- a/apps/server/src/mcp/documents.test.ts +++ b/apps/server/src/mcp/documents.test.ts @@ -50,7 +50,37 @@ async function json(response: Response): Promise> { return await response.json() as Record; } +async function unavailable() { + return { kind: "unavailable" as const }; +} + describe("the MCP document protocol", () => { + it("exposes only top-level object output schemas for every tool", async () => { + let mcp = handler({ + caller: () => "octocat", + documents: reader(), + create: { create: unavailable }, + update: { update: unavailable }, + rename: { rename: unavailable }, + archive: { archive: unavailable }, + restore: { restore: unavailable }, + implementations: { + readImplementation: async () => undefined, + startImplementation: unavailable, + reportLifecycle: unavailable, + }, + }); + let response = await mcp(request({ jsonrpc: "2.0", id: 1, method: "tools/list" })); + let listed = (await response.json()).result.tools as typeof TOOLS; + expect(listed).toEqual(TOOLS); + for (let tool of [...TOOLS, ...listed]) { + expect({ name: tool.name, type: tool.outputSchema.type }).toEqual({ + name: tool.name, + type: "object", + }); + } + }); + it("authenticates initialize, lists repository documents, and reads canonical source", async () => { let mcp = endpoint(); From c4e32f557bdb757844e383f61151faecacbafb25 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 20:59:31 -0700 Subject: [PATCH 03/12] Let a local agent hand an instruction to the Planner Expose invoke_planner only for the full local Atomic mode. Resolve the existing document and require a live browser login for the MCP caller's GitHub identity; the bearer never supplies Planner ownership. Verify an explicit checkout before posting, and retain it only for its own turn. Persist the verbatim instruction as a member message before returning the canonical document URL. Keep a server-opened room alive through the turn and queue, without changing ordinary socket-only eviction. Document the local shell and filesystem trust boundary and refusal codes. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: bun test passed: 1718 pass, 2 PostgreSQL skips, 0 fail, 7344 assertions across 196 files Assistant-verification: focused MCP tests passed: 87 tests, including capability gating, input bounds, output schemas, session ownership and checkout verification Assistant-verification: harness tests passed: 141 tests; full Atomic and default isolated behavior remain covered Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix and bun run ci passed: formatting inspected; no errors, one existing lint warning and no new design findings Assistant-verification: relative documentation links passed: 26 targets and anchors resolved with exact casing Assistant-verification: qlty smells completed: guard-return complexity and existing archive/restore duplication recorded in implementation notes User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- apps/server/src/auth/session.test.ts | 20 +++ apps/server/src/auth/session.ts | 10 ++ apps/server/src/chat/address.ts | 3 +- apps/server/src/chat/invoke.test.ts | 241 ++++++++++++++++++++++++++ apps/server/src/chat/limits.ts | 1 + apps/server/src/chat/service.ts | 88 +++++++++- apps/server/src/main.ts | 58 +++++-- apps/server/src/mcp.ts | 97 +++++++++++ apps/server/src/mcp/documents.test.ts | 1 + apps/server/src/mcp/hosted.test.ts | 173 ++++++++++++++++++ apps/server/src/mcp/hosted.ts | 61 +++++++ apps/server/src/mcp/invoke.test.ts | 109 ++++++++++++ apps/server/src/rooms.test.ts | 35 ++++ apps/server/src/rooms.ts | 18 ++ docs/hosted-agent.md | 7 + docs/local-agent-mcp.md | 72 +++++++- docs/self-hosting.md | 7 + 17 files changed, 976 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/chat/invoke.test.ts create mode 100644 apps/server/src/chat/limits.ts create mode 100644 apps/server/src/mcp/invoke.test.ts create mode 100644 apps/server/src/rooms.test.ts diff --git a/apps/server/src/auth/session.test.ts b/apps/server/src/auth/session.test.ts index 395137f72..f7d6db91d 100644 --- a/apps/server/src/auth/session.test.ts +++ b/apps/server/src/auth/session.test.ts @@ -30,6 +30,26 @@ function grant(accessToken: string, refreshToken = "ghr_refresh"): GitHubTokenGr } describe("hosted login sessions", () => { + it("finds the most recent live process-local session for only the requested user", async () => { + let storage = new MemoryStorage(); + let now = new Date("2026-08-13T12:00:00.000Z"); + for (let id of ["U_first", "U_second"]) { + await storage.users.put({ id, login: id, avatarUrl: "", now }); + } + let sessions = new Sessions(storage, false, () => now); + let first = await sessions.issue("U_first", grant("first-token")); + now = new Date(now.getTime() + 1_000); + let second = await sessions.issue("U_first", grant("newer-token")); + await sessions.issue("U_second", grant("other-user-token")); + expect((await sessions.forUser("U_first"))?.session.id).toBe(second.id); + expect(await sessions.forUser("U_unknown")).toBeUndefined(); + expect(await new Sessions(storage, false, () => now).forUser("U_first")).toBeUndefined(); + await sessions.revoke(request(pair(second.cookie))); + expect((await sessions.forUser("U_first"))?.session.id).toBe(first.id); + now = new Date(first.expiresAt); + expect(await sessions.forUser("U_first")).toBeUndefined(); + }); + it("stores only registry metadata while credentials remain process-local", async () => { let storage = new MemoryStorage(); let now = new Date("2026-08-13T12:00:00.000Z"); diff --git a/apps/server/src/auth/session.ts b/apps/server/src/auth/session.ts index 4fb66ec7c..af8017777 100644 --- a/apps/server/src/auth/session.ts +++ b/apps/server/src/auth/session.ts @@ -267,6 +267,16 @@ export class Sessions { : undefined; } + /** A local MCP handoff borrows an existing login, never the caller's bearer. */ + async forUser(userId: string): Promise { + for (let current of [...this.#sessions.values()].toReversed()) { + if (current.session.userId !== userId) continue; + let resolved = await this.resolve(current.session.id); + if (resolved) return resolved; + } + return undefined; + } + /** Read current credentials without triggering rotation from inside an active agent callback. */ async inspect(id: string): Promise { if (this.#refreshes.has(id) || this.#revocations.has(id)) return undefined; diff --git a/apps/server/src/chat/address.ts b/apps/server/src/chat/address.ts index 32371866f..81dd2191f 100644 --- a/apps/server/src/chat/address.ts +++ b/apps/server/src/chat/address.ts @@ -93,8 +93,9 @@ export function compose( ...backscroll.flatMap(said => said.references ?? []), ...references, ], + verbatim = false, ): string { - let asked = references.length > 0 ? text : instruction(text); + let asked = verbatim || references.length > 0 ? text : instruction(text); let readableIds = new Set(catalogReferences.map(reference => reference.id)); let current = annotatedText(asked, references, readableIds); diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts new file mode 100644 index 000000000..0e478a446 --- /dev/null +++ b/apps/server/src/chat/invoke.test.ts @@ -0,0 +1,241 @@ +import { expect, test } from "bun:test"; + +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { Admission } from "../auth/admission"; +import { Sessions } from "../auth/session"; +import * as Plan from "../plan/service"; +import { MemoryStorage } from "../storage/memory/adapter"; +import * as Chat from "./service"; + +import type { Server } from "bun"; +import type { HostedAuth } from "../auth/routes"; +import type { Config } from "../config"; +import type { GitHub } from "../github/client"; +import type { SocketData } from "../wire"; + +async function setup() { + let now = new Date(); + let storage = new MemoryStorage(); + let user = { id: "U_ana", login: "ana", avatarUrl: "" }; + await storage.users.put({ ...user, now }); + let sessions = new Sessions(storage, false); + let login = await sessions.issue(user.id, { + accessToken: "browser-token", + accessExpiresIn: 28_800, + refreshToken: "refresh-token", + refreshExpiresIn: 86_400, + }); + let repository = { id: "R_score", owner: "octo-org", name: "score", defaultBranch: "main" }; + let github = { + repositoryAccess: async () => ({ + ...repository, + permissions: { push: true, pull: true, admin: false }, + }), + } as unknown as GitHub; + let authConfig = { + origin: "http://localhost:8795", + appSlug: "test", + clientId: "test", + encryptionKey: new Uint8Array(32), + }; + let auth: HostedAuth = { + config: authConfig, + github, + storage, + sessions, + clock: () => new Date(), + admission: new Admission(authConfig, github), + }; + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: repository.id, + repositoryOwner: repository.owner, + repositoryName: repository.name, + title: "Document", + createdBy: user.id, + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let events: Array<{ kind: string; [key: string]: unknown }> = []; + let server = { + publish(_topic: string, message: string) { + events.push(JSON.parse(message)); + }, + } as unknown as Server; + let backend: Plan.Backend = { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }; + let plan = await Plan.open(channel.id, backend, server); + let owners = new ActiveOwnerBindings(auth); + let context: Chat.Room = { + chat: plan.chat, + plan, + room: channel.id, + server, + auth, + repository, + config: { agent: true } as Config, + claimantSessionId: login.id, + activeOwner: () => owners.resolve(channel.id), + persist: () => Plan.persist(plan), + }; + return { + context, + events, + user, + owners, + storage, + backend, + async close() { + owners.revokeAll(); + await Plan.close(plan); + }, + }; +} + +test("an invoked instruction persists verbatim as a member message before publication and returns before the turn", async () => { + let fixture = await setup(); + let { context, events, user } = fixture; + let saved = Promise.withResolvers(); + let persist = context.persist; + context.persist = async () => { + await saved.promise; + await persist(); + }; + let finished = Promise.withResolvers(); + let opened = Promise.withResolvers(); + let seen: Array<{ checkout?: string; prompt: string }> = []; + context.checkout = "/tmp/score"; + context.openPlannerSession = async (_owner, channel) => ({ + ok: true, + value: { + async stream(prompt) { + seen.push({ checkout: channel.checkout, prompt }); + opened.resolve(); + return { + fullStream: (async function*() { + await finished.promise; + yield { type: "finish" }; + })(), + } as never; + }, + destroy: async () => {}, + }, + }); + try { + let posting = Chat.invoke(context, user, " @chopin Plan this.\n"); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(events).toEqual([]); + expect(seen).toEqual([]); + saved.resolve(); + expect(await posting).toBeUndefined(); + await opened.promise; + expect(context.chat.busy).toBe(true); + expect(context.chat.entries[0]).toMatchObject({ + author: { kind: "member", handle: "ana" }, + text: " @chopin Plan this.\n", + }); + expect(events.some(event => event.kind === "chat:message")).toBe(true); + expect(seen[0]?.checkout).toBe("/tmp/score"); + expect(seen[0]?.prompt).toBe("@ana: @chopin Plan this.\n"); + finished.resolve(); + await context.chat.running; + expect(context.chat.busy).toBe(false); + } finally { + saved.resolve(); + finished.resolve(); + await fixture.close(); + } +}); + +test("queued invocations retain their own checkout and durable message exactly once", async () => { + let fixture = await setup(); + let { context, user } = fixture; + let first = Promise.withResolvers(); + let opened = Promise.withResolvers(); + let checkouts: Array = []; + context.openPlannerSession = async (_owner, channel) => ({ + ok: true, + value: { + async stream() { + checkouts.push(channel.checkout); + opened.resolve(); + return { + fullStream: (async function*() { + await first.promise; + yield { type: "finish" }; + })(), + } as never; + }, + destroy: async () => {}, + }, + }); + try { + await Chat.invoke({ ...context, checkout: "/tmp/first" }, user, "First"); + await opened.promise; + await Chat.invoke({ ...context, checkout: "/tmp/second" }, user, "Second"); + await Chat.invoke(context, user, "Second"); + expect(context.chat.waiting.map(item => item.text)).toEqual(["Second", "Second"]); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + expect(new Set(context.chat.entries.map(entry => entry.id)).size).toBe(3); + expect(checkouts).toEqual(["/tmp/first"]); + first.resolve(); + await context.chat.running; + expect(checkouts).toEqual(["/tmp/first", "/tmp/second", undefined]); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + expect(context.chat.waiting).toEqual([]); + expect(context.chat.busy).toBe(false); + await fixture.close(); + let restored = await Plan.open(context.room, fixture.backend, context.server); + expect(restored.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); + await Plan.close(restored); + } finally { + first.resolve(); + await fixture.close(); + } +}); + +test("failed persistence, unavailable Planner, full queue and another owner start no invoked turn", async () => { + let fixture = await setup(); + let { context, user, events } = fixture; + try { + context.config.agent = false; + expect(await Chat.invoke(context, user, "Review")).toBe("planner-unavailable"); + context.config.agent = true; + context.chat.busy = true; + context.chat.waiting = Array.from( + { length: 20 }, + (_, index) => ({ id: String(index), handle: "ana", text: "Waiting" }), + ); + expect(await Chat.invoke(context, user, "Review")).toBe("planner-queue-full"); + context.chat.waiting = []; + context.persist = async () => { + throw new Error("storage failed"); + }; + await expect(Chat.invoke(context, user, "Review")).rejects.toThrow("storage failed"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + await fixture.storage.users.put({ id: "U_bob", login: "bob", avatarUrl: "", now: new Date() }); + let login = await context.auth.sessions.issue("U_bob", { + accessToken: "bob-token", + accessExpiresIn: 28_800, + refreshToken: "refresh", + refreshExpiresIn: 86_400, + }); + expect( + await Chat.invoke( + { ...context, claimantSessionId: login.id }, + { id: "U_bob", login: "bob" }, + "Review", + ), + ).toBe("planner-owner-unavailable"); + expect(context.chat.waiting).toEqual([]); + expect(context.chat.running).toBeUndefined(); + } finally { + await fixture.close(); + } +}); diff --git a/apps/server/src/chat/limits.ts b/apps/server/src/chat/limits.ts new file mode 100644 index 000000000..d8e420753 --- /dev/null +++ b/apps/server/src/chat/limits.ts @@ -0,0 +1 @@ +export const MAX_MESSAGE_BYTES = 64 * 1024; diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index c4762ec43..66fdfb96d 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -37,6 +37,7 @@ 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 { MAX_MESSAGE_BYTES } from "./limits"; import type { Server } from "bun"; import type { TextStreamPart, ToolSet } from "ai"; @@ -53,7 +54,6 @@ import type { Socket, SocketData } from "../wire"; /** Beyond this the queue is a backlog nobody is going to read. */ const MAX_QUEUE = 20; const MAX_PENDING_SENDS = 20; -const MAX_MESSAGE_BYTES = 64 * 1024; const MAX_SESSION_REFERENCES = 50; const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const FINGERPRINT = /^sha256:[0-9a-f]{64}$/; @@ -91,6 +91,9 @@ export type Waiting = Wire.Waiting & { userId?: string; /** A queued background turn, resolved only after its scope is cleaned up. */ job?: JobTurn; + posted?: boolean; + checkout?: string; + invokedBy?: string; }; export type ActiveMemberRequest = { @@ -426,6 +429,7 @@ export type Room = { roomMessagePublished?: () => void; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; checkout?: string; + invokedBy?: string; ownerAvailable?: () => Promise; jobs?: JobService; references?: ReferenceService; @@ -459,6 +463,77 @@ export function send(context: Room, ws: Socket, msg: Request): Promis return completed; } +/** Accept a local MCP instruction without waiting for its Planner turn. */ +export async function invoke( + context: Room, + user: { id: string; login: string }, + text: string, +): Promise<"planner-unavailable" | "planner-owner-unavailable" | "planner-queue-full" | undefined> { + let { chat, server, room } = context; + if (chat.pendingSends >= MAX_PENDING_SENDS) return "planner-queue-full"; + chat.pendingSends++; + let post = async () => { + if (!context.config.agent || chat.closed) return "planner-unavailable" as const; + try { + let { owner } = await resolveOwner( + context.auth, + context.repository, + room, + context.claimantSessionId, + ); + if (owner.user.id !== user.id) return "planner-owner-unavailable" as const; + } catch { + return "planner-owner-unavailable" as const; + } + if (chat.closed) return "planner-unavailable" as const; + if (chat.busy && chat.waiting.length >= MAX_QUEUE) return "planner-queue-full" as const; + let entry: Wire.Entry = { + id: ulid(), + author: { kind: "member", handle: user.login }, + text, + ts: now(), + }; + chat.entries.push(entry); + try { + await context.persist(); + } catch (error) { + chat.entries = chat.entries.filter(value => value !== entry); + throw error; + } + if (chat.closed) return "planner-unavailable" as const; + announce(server, room, entry); + if (chat.busy) { + chat.waiting.push({ + id: entry.id, + handle: user.login, + text, + message: true, + posted: true, + sessionId: context.claimantSessionId, + userId: user.id, + checkout: context.checkout, + invokedBy: user.id, + }); + queued(chat, server, room); + } else { + startRun( + { ...context, invokedBy: user.id }, + user.login, + text, + undefined, + context.claimantSessionId, + false, + { entryId: entry.id, userId: user.id }, + ); + } + return undefined; + }; + let accepted = chat.sending.then(post, post); + let completed = accepted.finally(() => chat.pendingSends--); + chat.sending = completed.then(() => {}, () => {}); + return completed; +} + async function processSend(context: Room, ws: Socket, msg: Request): Promise { let { chat, room, server } = context; let suppliedRequestId = (msg as Request & { requestId?: unknown }).requestId; @@ -890,6 +965,9 @@ async function repositorySession( context.room, claimantSessionId, ); + if (context.invokedBy && owner.user.id !== context.invokedBy) { + throw new Error("The local Planner owner changed. Invoke the Planner again after signing in."); + } if (context.ownerAvailable) void context.ownerAvailable().catch(() => {}); let { chat, auth } = context; let lifecycle = chat.lifecycle; @@ -1094,7 +1172,7 @@ async function run( ]; retainReferences(chat, promptReferences); let available = promptReferences.filter(reference => chat.referenceCache.has(reference.id)); - prompt = compose(backscroll, handle, text, references, available); + prompt = compose(backscroll, handle, text, references, available, !!context.invokedBy); } sendStarted = true; let signal = AbortSignal.any([opened.binding.signal, turnController.signal]); @@ -1229,9 +1307,9 @@ async function run( (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); + if (entry && !next.posted) announce(server, room, entry); await run( - context, + { ...context, checkout: next.checkout, invokedBy: next.invokedBy }, next.handle, next.text, next.thread, @@ -1292,7 +1370,7 @@ function startRun( export function pending(chat: Chat): Waiting | undefined { let next = chat.waiting.shift(); while (next?.spent?.()) next = chat.waiting.shift(); - if (next?.message) { + if (next?.message && !next.posted) { let entry: MemberEntry = { id: next.id, author: { kind: "member", handle: next.handle }, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index bec2f69d8..882690090 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -240,8 +240,21 @@ async function plan(room: Rooms.Room, server: Server): Promise Service.persist(opened), activeOwner: () => ownerBindings!.resolve(room.id), ownerAvailable: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), @@ -296,7 +304,7 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { async function closeRoom(room: Rooms.Room, force = false): Promise { if (room.closing) return room.closing; let closing = withDocumentLock(room.id, async () => { - if (!force && room.members.size > 0) return; + if (!force && (room.members.size > 0 || room.holds)) return; let held = room.plan; room.plan = undefined; if (held) { @@ -315,10 +323,10 @@ async function closeRoom(room: Rooms.Room, force = false): Promise { } function evict(room: Rooms.Room): void { - if (room.eviction || room.members.size > 0) return; + if (room.eviction || room.members.size > 0 || room.holds) return; room.eviction = setTimeout(() => { room.eviction = undefined; - if (room.members.size > 0) return; + if (room.members.size > 0 || room.holds) return; void closeRoom(room).catch(err => { console.error("chopin: room close failed -", err); }); @@ -1342,6 +1350,34 @@ registerMcpRoutes(router, hostedAuth, { return heldLease; }, }, { + invokePlanner: config.atomicPlanner + ? input => + withDocumentTransition(input.channel.id, async () => { + let channel = await storage.channels.get(input.channel.id); + if (!channel || deletingChannels.has(channel.id)) return "document-unavailable"; + if (channel.archivedAt) return "document-archived"; + await Rooms.get(channel.id)?.closing; + let held = Rooms.hold(channel.id); + let release = () => { + held.release(); + evict(held.room); + }; + let running = false; + try { + await plan(held.room, server); + let context = conversation(held.room, input.session.session.id, input.repository); + context.checkout = input.checkout; + let code = await Chat.invoke(context, input.session.user, input.instruction); + if (!code && context.chat.running) { + running = true; + void context.chat.running.finally(release).catch(() => {}); + } + return code; + } finally { + if (!running) release(); + } + }) + : undefined, archiveChannel, isChannelDeleting: channelId => deletingChannels.has(channelId), onChannelRenamed: announceChannel, diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index b19d931dc..e4484ab33 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -5,6 +5,9 @@ * module only validates and presents their results through MCP. */ +import { isAbsolute } from "node:path"; +import { MAX_MESSAGE_BYTES } from "./chat/limits"; + import { BRIEF, isRepository, @@ -141,6 +144,24 @@ export type RestoreDocument = { >; }; +export type InvokePlannerInput = { id: string; instruction: string; checkout?: string }; + +export type InvokePlannerError = + | "document-unavailable" + | "repository-forbidden" + | "document-archived" + | "planner-owner-unavailable" + | "checkout-unverified" + | "planner-unavailable" + | "planner-queue-full"; + +export type InvokePlanner = { + invoke(caller: Caller, input: InvokePlannerInput): Promise< + | { kind: "invoked"; document: DocumentSummary & { url: string } } + | { kind: "refused"; code: InvokePlannerError } + >; +}; + export type McpOptions = { /** The host owns authentication; MCP only receives its result. */ caller(request: Request): Promise | Caller | undefined; @@ -150,6 +171,7 @@ export type McpOptions = { rename?: RenameDocument; archive?: ArchiveDocument; restore?: RestoreDocument; + invoke?: InvokePlanner; implementations?: Implementations; }; @@ -462,6 +484,56 @@ export const TOOLS: Tool[] = [ ], }, }, + { + name: "invoke_planner", + description: "Post an instruction to a document's Planner as the locally signed-in user and " + + "return its URL without waiting for the turn. Available only in full Atomic mode; " + + "a supplied checkout must match the document repository. Questions appear in Decisions.", + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: MAX_DOCUMENT_LOCATOR_LENGTH, + pattern: "\\S", + }, + instruction: { + type: "string", + minLength: 1, + maxLength: MAX_MESSAGE_BYTES, + pattern: "\\S", + description: "Instruction text, at most 64 KiB in UTF-8.", + }, + checkout: { + type: "string", + description: "Absolute path to a local checkout of this repository.", + }, + }, + required: ["id", "instruction"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + properties: { ...DOCUMENT_IDENTITY, url: { type: "string" } }, + required: ["id", "title", "url"], + additionalProperties: false, + }, + outcome([ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ]), + ], + }, + }, { name: "read_implementation", description: @@ -663,6 +735,7 @@ function serviceInstructions(tools: Tool[]): string | undefined { || tool.name === "rename_document" || tool.name === "archive_document" || tool.name === "restore_document" + || tool.name === "invoke_planner" ); let implementation = tools .filter(tool => @@ -733,6 +806,7 @@ export function handler( && (tool.name !== "rename_document" || renaming) && (tool.name !== "archive_document" || archiving) && (tool.name !== "restore_document" || restoring) + && (tool.name !== "invoke_planner" || options.invoke) && (!["read_implementation", "start_implementation"].includes(tool.name) || options.implementations) && (!isLifecycleTool(tool.name) || options.implementations?.reportLifecycle) @@ -917,6 +991,29 @@ export function handler( : "document-unavailable", }, true)); } + if (tool.name === "invoke_planner" && options.invoke) { + let args = tool.arguments; + if ( + Object.keys(args).some(key => !["id", "instruction", "checkout"].includes(key)) + || !isLocator(args.id) + || typeof args.instruction !== "string" || !args.instruction.trim() + || Buffer.byteLength(args.instruction) > MAX_MESSAGE_BYTES + || (Object.hasOwn(args, "checkout") + && (typeof args.checkout !== "string" || !isAbsolute(args.checkout))) + ) { + return notification + ? undefined + : error( + call.id, + -32602, + "invoke_planner requires an id or URL, instruction, and optional absolute checkout", + ); + } + let result = await options.invoke.invoke(caller, args as InvokePlannerInput); + return result.kind === "invoked" + ? respond(text(result.document)) + : respond(text({ code: result.code }, true)); + } if (tool.name === "read_implementation") { if (Object.keys(tool.arguments).length !== 1 || !isLocator(tool.arguments.id)) { return notification diff --git a/apps/server/src/mcp/documents.test.ts b/apps/server/src/mcp/documents.test.ts index 8a522ee41..19ad96a40 100644 --- a/apps/server/src/mcp/documents.test.ts +++ b/apps/server/src/mcp/documents.test.ts @@ -64,6 +64,7 @@ describe("the MCP document protocol", () => { rename: { rename: unavailable }, archive: { archive: unavailable }, restore: { restore: unavailable }, + invoke: { invoke: async () => ({ kind: "refused", code: "planner-unavailable" }) }, implementations: { readImplementation: async () => undefined, startImplementation: unavailable, diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 6c771ccae..97ec274c2 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ulid } from "@chopin/dialect"; @@ -24,6 +27,7 @@ import type { } from "../github/client"; import type { CreateDocumentInput } from "../mcp"; import type { Socket, SocketData } from "../wire"; +import type { PlannerInvocation } from "./hosted"; let creation: CreateDocumentInput = { idempotencyKey: "create-plan-1", @@ -204,6 +208,175 @@ async function plan(context: ReturnType) { } describe("the hosted MCP adapter", () => { + it("invokes only for a live local login of the MCP caller and verifies a supplied checkout", async () => { + let context = setup(); + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + let started: PlannerInvocation[] = []; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return undefined; + }, + }); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let input = { id: opened.channel.id, instruction: " Review this.\n" }; + try { + expect(hosted(context.auth).invoke).toBeUndefined(); + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "planner-owner-unavailable", + }); + await context.storage.users.put({ + id: "U_other", + login: "other", + avatarUrl: "", + now: context.now, + }); + await context.auth.sessions.issue("U_other", grant("another-browser")); + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "planner-owner-unavailable", + }); + let session = await context.auth.sessions.issue(caller.user.id, grant("browser-token")); + expect(await adapter.invoke!.invoke(caller, { ...input, checkout: "/does-not-exist" })) + .toEqual({ kind: "refused", code: "checkout-unverified" }); + expect(started).toEqual([]); + let result = await adapter.invoke!.invoke(caller, input); + expect(result).toEqual({ + kind: "invoked", + document: { + id: opened.channel.id, + title: opened.channel.title, + url: "https://chopin.test/documents/octo-org/score/release-readiness", + }, + }); + expect(started).toHaveLength(1); + expect(started[0]).toMatchObject({ + instruction: input.instruction, + session: { session: { id: session.id }, user: { id: caller.user.id } }, + }); + expect(started[0]!.checkout).toBeUndefined(); + } finally { + await Service.close(opened.plan); + } + }); + + it("passes a matching checkout and local session through a URL invocation", async () => { + let context = setup(); + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + context.github.repositoryValue.permissions.admin = true; + let opened = await plan(context); + let checkout = await mkdtemp(join(tmpdir(), "chopin-mcp-checkout-")); + let started: PlannerInvocation[] = []; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return undefined; + }, + }); + try { + let caller = (await adapter.caller(request("Bearer allowed")))!; + let session = await context.auth.sessions.issue(caller.user.id, grant("local-browser-token")); + for ( + let args of [["init", "--quiet"], [ + "remote", + "add", + "origin", + "workgit:other/repository.git", + ]] + ) { + let git = Bun.spawn(["git", "-C", checkout, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + } + let input = { + id: "https://chopin.test/documents/octo-org/score/release-readiness", + instruction: "Review", + checkout, + }; + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "checkout-unverified", + }); + expect(started).toHaveLength(0); + let git = Bun.spawn([ + "git", + "-C", + checkout, + "remote", + "set-url", + "origin", + "workgit:octo-org/score.git", + ], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + expect(await adapter.invoke!.invoke(caller, input)).toMatchObject({ + kind: "invoked", + document: { id: opened.channel.id, url: input.id }, + }); + expect(started).toHaveLength(1); + expect(started[0]).toMatchObject({ + checkout: await realpath(checkout), + instruction: input.instruction, + session: { session: { id: session.id } }, + }); + } finally { + await Service.close(opened.plan); + await rm(checkout, { recursive: true, force: true }); + } + }); + + it("refuses unavailable, forbidden, archived and deleting documents before starting a Planner", async () => { + let context = setup(); + let opened = await plan(context); + let calls = 0; + let deleting = false; + let callbacks = { + async invokePlanner() { + calls++; + return undefined; + }, + isChannelDeleting: () => deleting, + }; + expect(hosted(context.auth, undefined, callbacks).invoke).toBeUndefined(); + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + let adapter = hosted(context.auth, undefined, callbacks); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let invoke = (id = opened.channel.id) => + adapter.invoke!.invoke(caller, { id, instruction: "Review" }); + try { + expect(await invoke(crypto.randomUUID())).toEqual({ + kind: "refused", + code: "document-unavailable", + }); + expect(await invoke()).toEqual({ kind: "refused", code: "repository-forbidden" }); + context.github.repositoryValue.permissions.push = true; + context.github.repositoryValue.permissions.pull = false; + expect(await invoke()).toEqual({ kind: "refused", code: "repository-forbidden" }); + context.github.repositoryValue.permissions.pull = true; + deleting = true; + expect(await invoke()).toEqual({ kind: "refused", code: "document-unavailable" }); + deleting = false; + await context.storage.channels.archive({ id: opened.channel.id, now: context.now }); + expect(await invoke()).toEqual({ kind: "refused", code: "document-archived" }); + expect(calls).toBe(0); + } finally { + await Service.close(opened.plan); + } + }); + it("accepts exactly one bearer token and validates its GitHub identity per request", async () => { let { auth, github } = setup(); let adapter = hosted(auth); diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index bf59bd2fa..9661b2477 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -8,14 +8,18 @@ import * as Rooms from "../rooms"; import { claimImplementation, reportImplementationLifecycle } from "../tasks/plan-graphs"; import { StorageError } from "../storage/errors"; import { implementationLifecycle } from "../tasks/lifecycle"; +import { verifiedCheckout } from "../harness/atomic/checkout"; import type { Server } from "bun"; import type { HostedAuth } from "../auth/routes"; import type { GitHubUser } from "../github/client"; +import type { AuthenticatedSession } from "../auth/session"; +import type { HostedRepository } from "../agent/repository"; import type { DocumentSummary, Implementation, ImplementationInput, + InvokePlannerError, McpOptions, RenameDocumentInput, } from "../mcp"; @@ -30,6 +34,14 @@ export type HostedCaller = { user: GitHubUser; }; +export type PlannerInvocation = { + channel: ChannelRecord; + repository: HostedRepository; + session: AuthenticatedSession; + instruction: string; + checkout?: string; +}; + export type ImplementationPersistence = { lease(): Lease }; export type HostedCallbacks = { archiveChannel?: (channelId: string, now: Date) => Promise; @@ -38,6 +50,7 @@ export type HostedCallbacks = { serializeDocument?: (channelId: string, action: () => Promise) => Promise; onChannelRenamed?: (channel: ChannelRecord) => void; onDocumentPersisted?: (target: Plan.DocumentTarget) => void; + invokePlanner?: (input: PlannerInvocation) => Promise; }; const BEARER = new RegExp("^Bearer ([A-Za-z0-9._~+/-]+=*)$", "i"); @@ -255,6 +268,54 @@ export function hosted( } return { + invoke: auth.config.local && callbacks.invokePlanner + ? { + async invoke(caller, input) { + let located = await locatedChannel(caller, input.id); + if (!located) return { kind: "refused", code: "document-unavailable" }; + if ( + located === "forbidden" + || (!located.repository.permissions.push && !located.repository.permissions.admin) + ) { + return { kind: "refused", code: "repository-forbidden" }; + } + let { channel, repository } = located; + if (callbacks.isChannelDeleting?.(channel.id)) { + return { + kind: "refused", + code: "document-unavailable", + }; + } + if (channel.archivedAt) return { kind: "refused", code: "document-archived" }; + let session = await auth.sessions.forUser(caller.user.id); + if (!session) return { kind: "refused", code: "planner-owner-unavailable" }; + let checkout = input.checkout === undefined + ? undefined + : await verifiedCheckout(repository, [], input.checkout); + if (input.checkout !== undefined && !checkout) { + return { kind: "refused", code: "checkout-unverified" }; + } + let code = await callbacks.invokePlanner!({ + channel, + repository, + session, + instruction: input.instruction, + checkout, + }); + if (code) return { kind: "refused", code }; + return { + kind: "invoked", + document: { + ...summary(channel), + url: new URL( + documentPath(channel.repositoryOwner, channel.repositoryName, channel.slug), + auth.config.origin, + ).href, + }, + }; + }, + } + : undefined, async caller(request) { let match = request.headers.get("authorization")?.match(BEARER); if (!match) return undefined; diff --git a/apps/server/src/mcp/invoke.test.ts b/apps/server/src/mcp/invoke.test.ts new file mode 100644 index 000000000..e774e92eb --- /dev/null +++ b/apps/server/src/mcp/invoke.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; + +import { handler, TOOLS } from "../mcp"; + +import type { InvokePlanner, InvokePlannerInput } from "../mcp"; + +function endpoint(invoke?: InvokePlanner) { + return handler({ + caller: () => "octocat", + documents: { list: async () => [], read: async () => undefined }, + invoke, + }); +} + +async function call(mcp: ReturnType, method: string, params?: unknown) { + let response = await mcp( + new Request("http://localhost/mcp", { + method: "POST", + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }), + ); + return response.json(); +} + +test("invoke_planner is available only with its capability and returns the document URL", async () => { + let input = { + id: "/documents/octo-org/score/release", + instruction: " Plan this.\n", + checkout: "/tmp/score", + }; + let document = { + id: "document-id", + title: "Release", + url: "http://localhost/documents/octo-org/score/release", + }; + let received: Array<{ caller: string; input: InvokePlannerInput }> = []; + let mcp = endpoint({ + async invoke(caller, input) { + received.push({ caller, input }); + return { kind: "invoked", document }; + }, + }); + let tool = TOOLS.find(tool => tool.name === "invoke_planner"); + expect(tool).toBeDefined(); + expect((await call(endpoint(), "tools/list")).result.tools).not.toContainEqual(tool); + expect( + (await call(endpoint(), "tools/call", { name: "invoke_planner", arguments: input })).error.code, + ).toBe(-32601); + expect((await call(mcp, "tools/list")).result.tools).toContainEqual(tool); + expect((await call(mcp, "initialize", {})).result.instructions).toContain("invoke_planner:"); + let result = await call(mcp, "tools/call", { name: "invoke_planner", arguments: input }); + expect(result.result.structuredContent).toEqual(document); + expect(received).toEqual([{ caller: "octocat", input }]); +}); + +test("invoke_planner validates strict arguments and counts instruction bytes without rewriting text", async () => { + let received: InvokePlannerInput[] = []; + let mcp = endpoint({ + async invoke(_caller, input) { + received.push(input); + return { kind: "invoked", document: { id: input.id, title: "Document", url: "/document" } }; + }, + }); + let valid = { id: "document-id", instruction: "go" }; + for ( + let args of [ + {}, + { ...valid, id: " " }, + { ...valid, instruction: " \n\t" }, + { ...valid, instruction: "a".repeat(65_537) }, + { ...valid, instruction: "é".repeat(32_769) }, + { ...valid, instruction: 3 }, + { ...valid, checkout: "relative/path" }, + { ...valid, checkout: null }, + { ...valid, checkout: "" }, + { ...valid, extra: true }, + ] + ) { + let result = await call(mcp, "tools/call", { name: "invoke_planner", arguments: args }); + expect(result.error.code).toBe(-32602); + } + expect(received).toEqual([]); + let exact = { ...valid, instruction: "é".repeat(32_768) }; + expect( + (await call(mcp, "tools/call", { name: "invoke_planner", arguments: exact })).result.isError, + ).toBeUndefined(); + expect(received).toEqual([exact]); +}); + +test("invoke_planner exposes each refusal as an object code", async () => { + for ( + let code of [ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ] as const + ) { + let mcp = endpoint({ invoke: async () => ({ kind: "refused", code }) }); + let result = await call(mcp, "tools/call", { + name: "invoke_planner", + arguments: { id: "document-id", instruction: "go" }, + }); + expect(result.result).toMatchObject({ isError: true, structuredContent: { code } }); + } +}); diff --git a/apps/server/src/rooms.test.ts b/apps/server/src/rooms.test.ts new file mode 100644 index 000000000..c69e802a2 --- /dev/null +++ b/apps/server/src/rooms.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; + +import * as Rooms from "./rooms"; + +import type { Socket } from "./wire"; + +test("a Planner hold opens a memberless room, cancels eviction, and releases without affecting membership", () => { + let id = crypto.randomUUID(); + let first = Rooms.hold(id); + let room = first.room; + try { + expect(Rooms.get(id)).toBe(room); + expect(room.members.size).toBe(0); + expect(room.holds).toBe(1); + room.eviction = setTimeout(() => { + throw new Error("held room evicted"); + }, 10); + let second = Rooms.hold(id); + expect(second.room).toBe(room); + expect(room.eviction).toBeUndefined(); + expect(room.holds).toBe(2); + let socket = { data: { room: id, client: "client", handle: "ana" } } as Socket; + expect(Rooms.join(socket)).toBe(room); + first.release(); + first.release(); + expect(room.holds).toBe(1); + Rooms.leave(socket); + expect(room.members.size).toBe(0); + expect(room.holds).toBe(1); + second.release(); + expect(room.holds).toBe(0); + } finally { + Rooms.forget(room); + } +}); diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index 83405b7f7..a6340fe65 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -31,6 +31,8 @@ export type Room = { closing?: Promise; /** Pending eviction, cancelled if somebody comes back. */ eviction?: ReturnType; + /** Local MCP work keeps a memberless room alive until its turn and queue finish. */ + holds?: number; }; const rooms = new Map(); @@ -47,6 +49,22 @@ export function get(id: string): Room | undefined { return rooms.get(id); } +export function hold(id: string): { room: Room; release: () => void } { + let room = open(id); + if (room.eviction) clearTimeout(room.eviction); + room.eviction = undefined; + room.holds = (room.holds ?? 0) + 1; + let released = false; + return { + room, + release() { + if (released) return; + released = true; + room.holds = room.holds! - 1; + }, + }; +} + export function join(ws: Socket): Room { let room = open(ws.data.room); // A reload is a departure followed by an arrival. Cancelling here is what diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 9902716a8..d90b86ba8 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -122,6 +122,13 @@ authenticity of a remote host. A per-invocation candidate takes precedence over the configured list. No verified checkout means the same isolated session as before, with no Atomic resources or HostInput binding. +A local coding agent can supply that candidate with +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). +The tool requires a live browser login matching its MCP caller, posts a durable +member instruction, and returns the document URL without waiting for the turn. +An explicitly supplied but unverified checkout is refused before posting, rather +than falling back to another configured path. + With a verified checkout, it becomes the session's working directory. Atomic's workflows, subagents, MCP, web access, Intercom, and default coding tools run alongside Chopin's document tools. The normal Atomic agent directory (including diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index ee3d63e3d..9b6c24f3e 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -24,9 +24,10 @@ authorization. The MCP bearer boundary is separate from Chopin's browser GitHub App session. Chopin authenticates the supplied token, applies the instance admission policy, and asks GitHub directly for that token's repository permissions. The GitHub App -for Chopin does not need to be installed on a repository for MCP access. Browser -routes, WebSockets, and the hosted agent still require an active App -installation that includes the repository. +for Chopin does not need to be installed for ordinary MCP document operations. +Browser routes, WebSockets, and the Planner still require an active App +installation that includes the repository. The local-only `invoke_planner` +handoff uses an existing browser login for that Planner boundary, not the MCP bearer. ```bash export CHOPIN_URL="https://your-chopin-instance.example" @@ -54,6 +55,8 @@ The current MCP contract can: - claim a graph and report task, pull-request, blocker, revision, and verification lifecycle transitions. +Local instances with `ATOMIC_PLANNER=full` also offer `invoke_planner`, described below. + Chopin validates the shape of `baseBranch` and `baseCommit` during creation but does not resolve them against GitHub. The creating agent is responsible for reading those values from its checkout rather than asserting arbitrary input. @@ -69,6 +72,58 @@ graph, but the product has no user-facing way to approve the Planner's draft. See [Experimental implementation lifecycle](implementation-lifecycle.md). +## Hand an instruction to the local Planner + +`invoke_planner` is available only with `ATOMIC_PLANNER=full`, which requires +`AUTH_MODE=local` and `HARNESS=atomic`. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for setup +and the trust boundary. A coding agent using this tool can drive a Planner with +**shell and filesystem access as the local user**. Keep the instance loopback-only +and single-operator; do not expose it to untrusted clients. + +Sign in to Chopin through the local device flow as the same GitHub account used +by the MCP bearer. The most recently issued live browser session for that account +supplies the Planner claimant. An existing Planner owner must belong to that same +account; the MCP bearer never becomes the owner or supplies its credentials. +Both the bearer and the browser login need repository write access, and the +browser login must pass the GitHub App installation check. + +Call the tool with an existing document's UUID or canonical URL, an instruction, +and optionally an absolute checkout path: + +```json +{ + "id": "/documents/octo-org/score/release-readiness", + "instruction": "Run the planning workflow and ask blocking questions in Decisions.", + "checkout": "/absolute/path/to/score" +} +``` + +The instruction must contain non-whitespace text and fit within 64 KiB in UTF-8; +its text is preserved verbatim. A supplied checkout must have an `origin` matching +the document repository or the request is refused before posting. The verified +path applies to this turn only, including when queued. If omitted, the configured +`ATOMIC_PLANNER_CHECKOUTS` list applies; without a verified checkout the Planner +keeps its isolated behavior. + +The result contains `id`, `title`, and the canonical `url` (plus any generated +`description`). It returns after the instruction is durably posted, not after the +Planner finishes. The message appears in Chat under the local user's handle and +joins the existing Planner queue if busy. Progress and results appear in the +document and Chat; Atomic questions appear in Decisions. The browser need not +already have the document open: Chopin keeps its room alive while the invoked +turn and queue run. An interrupted turn is not replayed automatically on restart. + +Refusals return an object with `code`: + +- `planner-owner-unavailable`: sign in locally as the MCP account and check the + existing Planner owner and browser repository access. +- `checkout-unverified`: correct the supplied path or its repository origin. +- `planner-unavailable`: the Planner is disabled or the room is closing. +- `planner-queue-full`: wait for queued work to drain before invoking again. +- `document-unavailable`, `document-archived`, and `repository-forbidden` retain + their ordinary document/access meanings. + ## Document URLs and IDs The `url` returned by `create_document` is the readable canonical route: @@ -78,8 +133,8 @@ The `url` returned by `create_document` is the readable canonical route: ``` `read_document`, `read_implementation`, `archive_document`, `restore_document`, -and `update_document` accept either a document UUID or that canonical URL in their -`id` input. The URL may be passed back exactly as returned; an absolute URL must +`update_document`, and (in full mode) `invoke_planner` accept either a document UUID +or that canonical URL in their `id` input. The URL may be passed back exactly as returned; an absolute URL must use the configured Chopin origin. Both reads return the stable UUID as the document `id`. @@ -222,9 +277,10 @@ token's `read:org` or Members access, SSO authorization, and GitHub availability lacks the operation's repository permission; it does not mean the GitHub App for Chopin must be installed. Pull access is enough for `list_documents`, `read_document`, and `read_implementation`. Pull plus push or admin access is -required for create, update, rename, archive, restore, start, and report lifecycle -operations. Use an account with the required access or ask a repository owner to -grant it. +required for create, update, rename, archive, restore, invoke, start, and report +lifecycle operations. `invoke_planner` additionally requires the local browser +login described above. Use an account with the required access or ask a repository +owner to grant it. Use the optional [creating-chopin-plans skill](../skills/creating-chopin-plans/SKILL.md) to turn a diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 05fd69a51..c712a0e0e 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -471,6 +471,13 @@ Cancellation or timeout withdraws pending cards and never approves an action. See [Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for mapping, resource loading, persistence, and workflow restart boundaries. +This mode also exposes the MCP +[`invoke_planner` handoff](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). +Sign in locally as the MCP caller before using it. A supplied `checkout` is verified +before the instruction is posted; an invalid explicit path refuses the invocation. +The tool returns a document URL immediately while the Planner continues in Chat +and asks questions in Decisions. + ## First-start smoke test Startup validates configuration, database connectivity, migration history, the From f94d23cfa0e81b8e76b6fa106ec8d64d4ee8c638 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 21:51:05 -0700 Subject: [PATCH 04/12] Present every Atomic host input in Decisions Return a written answer as Atomic's custom kind only where its own dialog accepts typed text, and as typed chat on multi-select or preview questions, so Atomic's validator accepts every answer a member can give. Hold verbatim host input only to aggregate bounds. Normalization and the shared draft skip per-field counts and lengths for verbatim questions, the dialect bounds questionnaire text by the source size, and an ask that would take the document past 256 KiB fails before any card or record exists. The Planner's ask tool keeps its per-field limits. Read the Atomic checkout and timeout settings only in full mode, treating empty values as unset. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: fail-before/pass-after passed: the 9 new tests failed with source changes stashed (InvalidHostInput, per-field QuestionErrors, config throw) and 93 of 93 passed after Assistant-verification: bun test passed: 1727 pass, 2 PostgreSQL skips, 0 fail, 7393 assertions across 196 files Assistant-verification: focused bun test passed: 294 tests in harness, question, dialect and config, including a full Atomic session answered through Decisions Assistant-verification: aggregate-bound mutation check passed: disabling the document fit check made the oversize dialog test time out; source restored byte-for-byte Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix and bun run ci passed: formatting inspected; no errors, one existing lint warning and no new design findings Assistant-verification: relative documentation links passed: 27 links and anchors resolved with exact casing User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- apps/server/src/config.test.ts | 31 +++ apps/server/src/config.ts | 20 +- apps/server/src/harness/atomic/full.test.ts | 46 +++- .../src/harness/atomic/human-input.test.ts | 250 ++++++++++-------- apps/server/src/harness/atomic/human-input.ts | 4 +- apps/server/src/plan/room.ts | 13 + apps/server/src/questions/service-ask.ts | 12 +- apps/server/src/testing/decisions.ts | 128 +++++++++ docs/hosted-agent.md | 28 +- docs/self-hosting.md | 11 +- packages/dialect/src/dialect.test.ts | 14 + packages/dialect/src/dialect.ts | 10 +- packages/dialect/src/index.ts | 1 + packages/dialect/src/limits.ts | 14 +- packages/question/src/draft.ts | 2 +- packages/question/src/limits.ts | 4 +- packages/question/src/question.test.ts | 52 +++- .../question/src/react/question-view.test.ts | 23 ++ packages/question/src/schema.ts | 41 ++- 19 files changed, 551 insertions(+), 153 deletions(-) create mode 100644 apps/server/src/testing/decisions.ts diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 35596151e..e2395d8db 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -187,6 +187,37 @@ describe("configuration", () => { } }); + it("ignores Atomic Planner settings outside full mode and treats empty values as unset", () => { + for (let planner of [undefined, ""]) { + for (let [checkouts, timeout] of [["", ""], ["relative", "oops"]]) { + expect( + configured({ + ATOMIC_PLANNER: planner, + ATOMIC_PLANNER_CHECKOUTS: checkouts, + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout, + }).atomicPlanner, + ).toBeUndefined(); + } + } + let full = { + ATOMIC_PLANNER: "full", + AUTH_MODE: "local", + HARNESS: "atomic", + MODEL: "stub/model", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", + }; + for (let checkouts of [undefined, ""]) { + expect( + configured({ + ...full, + ATOMIC_PLANNER_CHECKOUTS: checkouts, + ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "", + }).atomicPlanner, + ).toEqual({ checkouts: [] }); + } + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 6a8603e24..3d9037069 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -74,13 +74,19 @@ export function harnessSelection(): Pick< if (full && (process.env.AUTH_MODE !== "local" || process.env.HARNESS !== "atomic")) { throw new Error("ATOMIC_PLANNER=full requires AUTH_MODE=local and HARNESS=atomic"); } - let checkouts = process.env.ATOMIC_PLANNER_CHECKOUTS?.split(delimiter) ?? []; + let selection = { + host: process.env.SERVER_HOST || "127.0.0.1", + harness: process.env.HARNESS || "copilot-sdk", + harnessAuth: process.env.HARNESS_AUTH || undefined, + }; + if (!full) return selection; + let checkouts = (process.env.ATOMIC_PLANNER_CHECKOUTS || undefined)?.split(delimiter) ?? []; if (checkouts.some(path => !isAbsolute(path))) { throw new Error( "ATOMIC_PLANNER_CHECKOUTS must contain absolute paths separated by the platform path delimiter", ); } - let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS; + let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS || undefined; let inputTimeoutMs = rawTimeout === undefined ? undefined : Number(rawTimeout); if ( inputTimeoutMs !== undefined @@ -90,14 +96,8 @@ export function harnessSelection(): Pick< throw new Error("ATOMIC_PLANNER_INPUT_TIMEOUT_MS must be an integer between 1 and 2147483647"); } return { - host: process.env.SERVER_HOST || "127.0.0.1", - harness: process.env.HARNESS || "copilot-sdk", - harnessAuth: process.env.HARNESS_AUTH || undefined, - ...(full - ? { - atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, - } - : {}), + ...selection, + atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, }; } diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 9330beee7..7dd8aa518 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -12,6 +12,7 @@ import { } from "./adapter"; import { registerFullPlanner } from "./full"; import { startStubModelServer } from "../pi/model-stub"; +import { hostInputRoom } from "../../testing/decisions"; import type { HostInput, QuestionParams } from "@bastani/atomic"; let params: QuestionParams = { @@ -24,18 +25,43 @@ let params: QuestionParams = { ], }], }; +let freeText: Record = { + multiple: { + questions: [{ + header: "Scope", + question: "Which areas?", + multiSelect: true, + options: [ + { label: "Server", description: "Back end" }, + { label: "Web", description: "Front end" }, + ], + }], + }, + preview: { + questions: [{ + header: "Layout", + question: "Which layout?", + options: [ + { label: "Rows", description: "Stacked", preview: "row\nrow" }, + { label: "Columns", description: "Side by side", preview: "col | col" }, + ], + }], + }, +}; let stub = startStubModelServer((prompt, prior) => prior ? { kind: "text", text: "Answered." } : prompt === "question" ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(params) } + : freeText[prompt] + ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(freeText[prompt]) } : prompt === "cwd" ? { kind: "tool", name: "bash", arguments: JSON.stringify({ command: "pwd" }) } : { kind: "text", text: "Ready." } ); afterAll(stub.stop); -async function run(full: boolean, registered: boolean, prompt = "plain") { +async function run(full: boolean, registered: boolean, prompt = "plain", host?: HostInput) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); let agentDir = join(root, "agent"); let cwd = join(root, "checkout"); @@ -86,7 +112,7 @@ async function run(full: boolean, registered: boolean, prompt = "plain") { let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { stdout: "pipe", stderr: "pipe" }); expect(await git.exited).toBe(0); process.env.ATOMIC_CODING_AGENT_DIR = agentDir; - let humanInput: HostInput = { + let humanInput: HostInput = host ?? { confirm: async () => false, select: async () => undefined, input: async () => undefined, @@ -179,6 +205,22 @@ test("full sessions use the checkout cwd and route ask_user_question through Hos expect(prompt.requests[0]!.prompt).toBe("OPERATOR-PROMPT-MARKER"); }); +test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { + let room = await hostInputRoom(); + try { + for (let prompt of ["multiple", "preview"]) { + let turn = run(true, true, prompt, room.input); + let [card] = await room.cards(1); + await room.answer(card!.id, ` typed ${prompt}\n`); + let output = (await turn).requests.at(-1)!.toolResults.join("\n"); + expect(output).toContain(` typed ${prompt}\n`); + expect(output).not.toContain("InvalidHostInput"); + } + } finally { + await room.close(); + } +}); + test("workers without a verified registration and default-mode sessions stay isolated", async () => { for (let [full, registered] of [[true, false], [false, true]]) { let result = await run(full!, registered!); diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index 6903c397f..e7f3926c3 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -1,16 +1,13 @@ import { afterEach, expect, test } from "bun:test"; -import * as Question from "@chopin/question"; +import { $nodesOfType } from "lexical"; +import { limits, QuestionnaireNode } from "@chopin/dialect"; import * as Questions from "../../questions/service"; import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; import * as Room from "../../plan/room"; -import { MemoryStorage } from "../../storage/memory/adapter"; import * as Edit from "../../plan/edit"; -import { createHumanInput } from "./human-input"; -import type { Server } from "bun"; -import type { HostInputOptions, QuestionParams } from "@bastani/atomic"; -import type { Socket, SocketData } from "../../wire"; -import type { DocumentRoom } from "../../agent/tools"; +import { hostInputRoom } from "../../testing/decisions"; +import type { QuestionParams } from "@bastani/atomic"; let cleanups: Array<() => Promise> = []; afterEach(async () => { @@ -18,102 +15,9 @@ afterEach(async () => { }); async function fixture(timeoutMs?: number) { - let storage = new MemoryStorage(); - let now = new Date(); - await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); - let channel = await storage.channels.create({ - id: crypto.randomUUID(), - repositoryId: "R_test", - repositoryOwner: "org", - repositoryName: "repo", - title: "Questions", - createdBy: "user", - now, - }); - let lease = (await storage.leases.acquire("writer", "test", 60_000))!; - let frames: any[] = []; - let server = { - publish(_topic: string, frame: string) { - frames.push(JSON.parse(frame)); - }, - } as unknown as Server; - let plan = await Plan.open(channel.id, { - storage, - lease: () => lease, - fatal: error => { - throw error; - }, - }, server); - let room = { - id: channel.id, - plan, - server, - anchors() {}, - } as DocumentRoom; - let ws = { - data: { handle: "reader", room: channel.id, client: "client" }, - send(frame: string) { - frames.push(JSON.parse(frame)); - }, - publish(_topic: string, frame: string) { - frames.push(JSON.parse(frame)); - }, - } as unknown as Socket; - cleanups.push(() => Plan.close(plan)); - let controller = new AbortController(); - let options: HostInputOptions = { - requestId: "request", - sessionId: "session", - signal: controller.signal, - }; - let input = createHumanInput(room, timeoutMs); - async function cards(count: number) { - await wait(() => Store.outstanding(plan.questions).length === count); - return Store.outstanding(plan.questions); - } - async function answer(id: string, value: number[] | string) { - let opened = Store.snapshot(plan.questions, id); - if (!opened.open) throw new Error("question closed"); - let definition = Store.get(plan.questions, id)!.definition; - let model = Question.restore(opened.model, definition); - let question = definition.questions[0]; - if (typeof value === "string") { - model.api.val([question.id, "mode"]).set("custom"); - if (value) model.api.str([question.id, "custom"]).ins(0, value); - } else if (question.multiple) { - for (let index of value) { - model.api.val([question.id, "options", question.options[index]!.id]).set(true); - } - } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); - let patch = model.api.flush(); - if (patch) { - await Questions.edit(plan, ws, { - kind: "question:edit", - rid: "edit", - ts: 0, - id, - patch: [...patch.toBinary()], - }); - } - let current = Store.snapshot(plan.questions, id); - if (!current.open) throw new Error("question closed"); - await Questions.submit(plan, server, channel.id, ws, { - kind: "question:submit", - rid: "submit", - ts: 0, - id, - revision: current.revision, - }); - } - return { input, plan, storage, frames, controller, options, cards, answer, ws, server, room }; -} - -async function wait(ready: () => boolean) { - for (let i = 0; i < 200; i++) { - if (ready()) return; - await Bun.sleep(5); - } - throw new Error("question did not arrive"); + let f = await hostInputRoom(timeoutMs); + cleanups.push(f.close); + return f; } let params: QuestionParams = { @@ -192,6 +96,36 @@ test("HostInput appends one ordered questionnaire batch and returns typed answer }); }); +test("Atomic's own HostInput validator accepts every answer a member can give", async () => { + let { HostInputBridge, getHostQuestionnaire } = await import( + new URL("./core/extensions/host-input.js", import.meta.resolve("@bastani/atomic")).href + ); + let f = await fixture(); + let bridge = new HostInputBridge(() => "session", () => undefined); + bridge.bind(f.input, "chopin"); + let questionnaire = getHostQuestionnaire(bridge.wrap({})); + let [preview, multiple, plain] = params.questions; + for ( + let [question, value, expected] of [ + [preview!, [1], { kind: "option", answer: "B" }], + [preview!, [0], { kind: "option", answer: " A ", preview: "preview A" }], + [preview!, " typed\n", { kind: "chat", answer: " typed\n" }], + [multiple!, [1, 0], { kind: "multi", answer: null, selected: ["C", "D"] }], + [multiple!, "several\n", { kind: "chat", answer: "several\n" }], + [plain!, [1], { kind: "option", answer: "F" }], + [plain!, " own ", { kind: "custom", answer: " own " }], + ] as const + ) { + let response = questionnaire({ questions: [question] }, new AbortController().signal); + let [card] = await f.cards(1); + await f.answer(card!.id, typeof value === "string" ? value : [...value]); + expect(await response).toEqual({ + cancelled: false, + answers: [{ questionIndex: 0, question: question.question, ...expected }], + }); + } +}); + test("maps confirm/select dialogs without treating custom text as approval or a selection", async () => { let f = await fixture(); for (let [value, approved] of [[[0], true], [[1], false], ["Yes", false]] as const) { @@ -367,3 +301,113 @@ test("blank dialog titles and supplied empty choices remain valid verbatim input expect(Room.project(restored)).toContain('value=""'); restored.doc.destroy(); }); + +/** Re-import the canonical document, proving the dialect accepts every card it holds. */ +async function documentQuestionnaires(source: string) { + let document = await Room.create(source); + try { + return document.editor.getEditorState().read(() => + $nodesOfType(QuestionnaireNode).map(node => node.getQuestionnaire().questions[0]!) + ); + } finally { + document.doc.destroy(); + } +} + +test("dialogs beyond the Planner's per-field limits become verbatim cards and answers", async () => { + let f = await fixture(); + let initial = "Keep this line of the plan.\n".repeat(180); + let edited = `${initial}${"Add this reviewed line.\n".repeat(60)}`; + let editor = f.input.editor("Edit plan", initial, f.options); + let [card] = await f.cards(1); + expect(initial.length).toBeGreaterThan(5_000); + expect(card!.definition.questions[0].question).toBe(`Edit plan\n\n${initial}`); + await f.answer(card!.id, edited); + expect(edited.length).toBeGreaterThan(6_000); + expect(await editor).toBe(edited); + + let message = "This workflow step rewrites generated files. ".repeat(30); + let confirmed = f.input.confirm("Proceed?", message, f.options); + [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await confirmed).toBe(true); + + let choices = Array.from({ length: 25 }, (_, index) => `Choice ${index}`); + choices[24] = `The long choice ${"x".repeat(250)}`; + let selected = f.input.select("Pick one", choices, f.options); + [card] = await f.cards(1); + expect(card!.definition.questions[0].options.map(option => option.label)).toEqual(choices); + await f.answer(card!.id, [24]); + expect(await selected).toBe(choices[24]); + + let rollout = "Explain the rollout, its owners, and its checks. ".repeat(40); + let asked = f.input.questionnaire({ + questions: [{ + header: "Rollout", + question: "Which rollout?", + options: [{ label: "Staged", description: rollout }, { label: "All", description: "" }], + }], + }, f.options); + [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect((await asked).answers[0]).toMatchObject({ kind: "option", answer: "Staged" }); + + expect(await documentQuestionnaires(Plan.source(f.plan))).toMatchObject([ + { header: "Editor", prompt: `Edit plan\n\n${initial}`, options: [], answer: edited }, + { header: "Confirm", prompt: `Proceed?\n\n${message}`, answer: "Yes" }, + { + header: "Select", + prompt: "Pick one", + options: choices.map(label => ({ label })), + answer: choices[24], + }, + { + header: "Rollout", + options: [{ label: "Staged", description: rollout }, { label: "All" }], + answer: "Staged", + }, + ]); +}); + +test("a workflow label never pushes a verbatim question into a rejection", async () => { + let f = await fixture(); + let question = "Should the review stage accept this change? ".repeat(23).slice(0, 990); + let long: QuestionParams = { + questions: [{ + header: "Review", + question, + options: [{ label: "Ship", description: "" }, { label: "Hold", description: "" }], + }], + }; + let response = f.input.questionnaire(long, { + ...f.options, + workflowRunId: "run-123", + workflowStageId: "review", + }); + let [card] = await f.cards(1); + let labelled = `${question}\n\nWorkflow run: run-123; stage: review`; + expect(card!.definition.questions[0].question).toBe(labelled); + expect(await documentQuestionnaires(Plan.source(f.plan))).toMatchObject([{ prompt: labelled }]); + await f.answer(card!.id, [0]); + expect(await response).toEqual({ + cancelled: false, + answers: [{ questionIndex: 0, question, kind: "option", answer: "Ship" }], + }); +}); + +test("a dialog that cannot fit in the document fails loudly without leaving a card", async () => { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: `${"Existing prose in the document. ".repeat(3_000)}\n`, + }]); + if (!edited.ok || !edited.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let before = Plan.source(f.plan); + await expect( + f.input.editor("Too large", "x".repeat(limits.MAX_SOURCE_BYTES - 90_000), f.options), + ).rejects.toThrow(`${limits.MAX_SOURCE_BYTES / 1024} KiB`); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.size).toBe(0); + expect(Plan.source(f.plan)).toBe(before); +}); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index a3812a0c9..1f02910d0 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -56,7 +56,9 @@ export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostIn let question = params.questions[questionIndex]!; let identity = { questionIndex, question: question.question }; if (answer.custom !== undefined) { - answers.push({ ...identity, kind: "custom", answer: answer.custom }); + // Atomic accepts `custom` only where its own dialog offers a typed row. + let typed = !question.multiSelect && !question.options.some(option => option.preview); + answers.push({ ...identity, kind: typed ? "custom" : "chat", answer: answer.custom }); } else if (question.multiSelect) { answers.push({ ...identity, kind: "multi", answer: null, selected: answer.choices ?? [] }); } else { diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index 0af53e562..ef59f2500 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -28,6 +28,7 @@ import { limits, parse, PlanValidationError, + questionnaireElement, QuestionnaireNode, registry as buildRegistry, ResearchNode, @@ -512,6 +513,18 @@ export function insertQuestionnaires( }, insertions); } +/** + * Whether appending these questionnaires keeps the source within its byte limit. + * + * Measured before mutating, because Yjs cannot undo an insertion that turns out + * to be too large. + */ +export function fitsQuestionnaires(target: Document, values: Questionnaire[]): boolean { + let tree = parse(project(target)); + tree.children.push(...values.map(questionnaireElement)); + return Buffer.byteLength(serialize(tree)) <= limits.MAX_SOURCE_BYTES; +} + /** Append one questionnaire to the plan. */ export function insertQuestionnaire(target: Document, value: Questionnaire): Mutation | undefined { return insertQuestionnaires(target, [{ value }]); diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts index 34ad2d8fc..856336900 100644 --- a/apps/server/src/questions/service-ask.ts +++ b/apps/server/src/questions/service-ask.ts @@ -1,4 +1,4 @@ -import { ulid } from "@chopin/dialect"; +import { limits, ulid } from "@chopin/dialect"; import * as Question from "@chopin/question"; import * as Y from "yjs"; @@ -141,6 +141,16 @@ export async function ask( records.set(id, record); return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; }); + // Verbatim host input has no per-field limits, so the document bounds it. Refuse before + // anything is registered or published. + if ( + definition.questions.some(question => question.verbatim) + && !room.fitsQuestionnaires(plan.document, asked.map(item => item.value)) + ) { + Question.reject( + `This input would take the document past its ${limits.MAX_SOURCE_BYTES / 1024} KiB limit`, + ); + } let document = await room.restore( plan.document.epoch, Y.encodeStateAsUpdate(plan.document.doc), diff --git a/apps/server/src/testing/decisions.ts b/apps/server/src/testing/decisions.ts new file mode 100644 index 000000000..2897838b6 --- /dev/null +++ b/apps/server/src/testing/decisions.ts @@ -0,0 +1,128 @@ +import * as Question from "@chopin/question"; + +import { createHumanInput } from "../harness/atomic/human-input"; +import * as Plan from "../plan/service"; +import * as Questions from "../questions/service"; +import * as Store from "../questions/store"; +import { MemoryStorage } from "../storage/memory/adapter"; + +import type { Server } from "bun"; +import type { HostInputOptions } from "@bastani/atomic"; +import type { DocumentRoom } from "../agent/tools"; +import type { Socket, SocketData } from "../wire"; + +/** A room whose Decisions answer Atomic HostInput requests, with a member to answer them. */ +export async function hostInputRoom(timeoutMs?: number) { + let storage = new MemoryStorage(); + let now = new Date(); + await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); + let channel = await storage.channels.create({ + id: crypto.randomUUID(), + repositoryId: "R_test", + repositoryOwner: "org", + repositoryName: "repo", + title: "Questions", + createdBy: "user", + now, + }); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let frames: any[] = []; + let server = { + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Server; + let plan = await Plan.open(channel.id, { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }, server); + let room = { + id: channel.id, + plan, + server, + anchors() {}, + } as DocumentRoom; + let ws = { + data: { handle: "reader", room: channel.id, client: "client" }, + send(frame: string) { + frames.push(JSON.parse(frame)); + }, + publish(_topic: string, frame: string) { + frames.push(JSON.parse(frame)); + }, + } as unknown as Socket; + let controller = new AbortController(); + let options: HostInputOptions = { + requestId: "request", + sessionId: "session", + signal: controller.signal, + }; + let input = createHumanInput(room, timeoutMs); + async function cards(count: number) { + for (let deadline = Date.now() + 4_000; Date.now() < deadline; await Bun.sleep(5)) { + if (Store.outstanding(plan.questions).length === count) { + return Store.outstanding(plan.questions); + } + } + throw new Error("question did not arrive"); + } + function refused(rid: string) { + let frame = frames.findLast(frame => frame.rid === rid); + if (frame?.accepted === false || frame?.ok === false || frame?.kind === "session:error") { + throw new Error(frame.message ?? frame.reason); + } + } + async function answer(id: string, value: number[] | string) { + let opened = Store.snapshot(plan.questions, id); + if (!opened.open) throw new Error("question closed"); + let definition = Store.get(plan.questions, id)!.definition; + let model = Question.restore(opened.model, definition); + let question = definition.questions[0]; + if (typeof value === "string") { + model.api.val([question.id, "mode"]).set("custom"); + if (value) model.api.str([question.id, "custom"]).ins(0, value); + } else if (question.multiple) { + for (let index of value) { + model.api.val([question.id, "options", question.options[index]!.id]).set(true); + } + } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + let patch = model.api.flush(); + if (patch) { + await Questions.edit(plan, ws, { + kind: "question:edit", + rid: "edit", + ts: 0, + id, + patch: [...patch.toBinary()], + }); + refused("edit"); + } + let current = Store.snapshot(plan.questions, id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(plan, server, channel.id, ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id, + revision: current.revision, + }); + refused("submit"); + } + return { + input, + plan, + storage, + frames, + controller, + options, + cards, + answer, + ws, + server, + room, + close: () => Plan.close(plan), + }; +} diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index d90b86ba8..f94ad313e 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -143,15 +143,22 @@ Chopin implements Atomic's `HostInput`, bound through `extensionBindings.humanInput`; it does not intercept tools. `ask_user_question`, extension dialogs, and workflow-stage input become ordinary shared Decisions: -- Questionnaires retain question and option order, multi-selection, and custom - text; answers return their Atomic question indices and answer kinds, including - a selected option's preview. +- Questionnaires retain question and option order, multi-selection, and the + exact text Atomic supplied; answers return their Atomic question indices and + answer kinds, including a selected option's preview. +- Every card offers a written answer. It returns as Atomic's `custom` kind where + Atomic's own dialog accepts typed text (single-select without previews), and + otherwise as typed `chat`, which Atomic delivers to the model as the member's + inline message (blank `chat` text is Atomic's plain request to talk first). + Chopin never reports written text as a chosen option, and Atomic workflows + treat `chat` as a request to keep discussing, not approval. - Confirm uses Yes/No; only choosing Yes approves. Select returns only a supplied choice. Input and editor use free-text cards; hints and initial editor text appear verbatim in the prompt. Explicitly submitted empty text is an answer, not cancellation. - Workflow cards show `Workflow run: ; stage: ` below their question. - A request is appended as one adjacent batch at the end of the document. + The label is added to the card only; Atomic receives its question text as + asked. A request is appended as one adjacent batch at the end of the document. - An abort withdraws still-open cards, removes their document nodes, and records cancellation by `@chopin` in Decisions. Member cancellations retain any answers already given in that batch. Late submissions cannot approve withdrawn input. @@ -159,10 +166,15 @@ extension dialogs, and workflow-stage input become ordinary shared Decisions: integer in milliseconds, at most 2147483647). Unset waits indefinitely. Expiry withdraws pending cards and returns no answer; confirm returns false. -Chopin's question size limits still apply: oversized dialogs fail rather than -silently truncating text. Atomic keeps durable workflow approvals pending on -withdrawal; Chopin does not automatically resume workflows or replay interrupted -turns after a restart. A new Atomic session must explicitly resume a saved run. +Host input is not held to the Planner `ask` tool's per-field limits on question +and option counts or header, question, label, description, and answer lengths. +Only Chopin's aggregate bounds apply. A request whose cards would take the +document past its 256 KiB source limit fails with that message before any card +appears. A written answer is limited by one shared-draft edit (64 KiB) and the +whole draft (256 KiB). Text is never silently truncated. Atomic keeps durable +workflow approvals pending on withdrawal; Chopin does not automatically resume +workflows or replay interrupted turns after a restart. A new Atomic session must +explicitly resume a saved run. ## Permission checks diff --git a/docs/self-hosting.md b/docs/self-hosting.md index c712a0e0e..fb2816523 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -261,8 +261,8 @@ the image. | `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | | `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | | `ATOMIC_PLANNER` | unset | Set exactly `full` to allow full Atomic Planner sessions. Requires `AUTH_MODE=local` and `HARNESS=atomic`; every other nonempty value or combination fails startup. Both Atomic auth modes are supported. | -| `ATOMIC_PLANNER_CHECKOUTS` | unset | Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. | -| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | +| `ATOMIC_PLANNER_CHECKOUTS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. With no list, only an `invoke_planner` checkout can enable a full session. | +| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | | `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | | `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | | `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | @@ -467,8 +467,11 @@ The check does not establish remote-host authenticity or confine shell access. Atomic input appears as shared Decisions instead of terminal dialogs, including workflow run/stage labels. Unanchored batches append at the document's end. -Cancellation or timeout withdraws pending cards and never approves an action. -See [Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) +Dialog text and written answers stay verbatim, bounded only by the document's +256 KiB source limit and the shared-draft limits rather than the Planner's own +question limits. Cancellation or timeout withdraws pending cards and never +approves an action. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for mapping, resource loading, persistence, and workflow restart boundaries. This mode also exposes the MCP diff --git a/packages/dialect/src/dialect.test.ts b/packages/dialect/src/dialect.test.ts index 196a4fbc1..9cc9537ca 100644 --- a/packages/dialect/src/dialect.test.ts +++ b/packages/dialect/src/dialect.test.ts @@ -290,6 +290,20 @@ describe("limits", () => { expect(codes(`y`)) .toContain("attribute-too-long"); }); + + it("bounds questionnaire projections only by the source size", () => { + let questionnaire = (text: string) => + `\n` + + `\n` + + `\n` + + ``; + accepts(questionnaire("x".repeat(5_000))); + let source = questionnaire("x".repeat(limits.MAX_SOURCE_BYTES)); + let result = validate(parse(source), { bytes: new TextEncoder().encode(source).byteLength }); + expect(result.ok ? [] : result.issues.map(issue => issue.code)).toEqual(["source-too-large"]); + }); }); describe("diagnostics", () => { diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index 6116483e9..1e4409024 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -116,8 +116,8 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "components", names: ["Option", "Answer", "Previous"] }, parent: ["Questionnaire"], attributes: { - header: { type: "text", required: true, max: limits.MAX_QUESTION_HEADER }, - prompt: { type: "text", required: true, max: limits.MAX_QUESTION_PROMPT }, + header: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, + prompt: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, multiple: { type: "enum", required: true, values: ["true", "false"] }, }, }), @@ -128,8 +128,8 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "empty" }, parent: ["Question"], attributes: { - label: { type: "text", required: true, max: limits.MAX_OPTION_LABEL }, - description: { type: "text", required: false, max: limits.MAX_OPTION_DESCRIPTION }, + label: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, + description: { type: "text", required: false, max: limits.MAX_QUESTIONNAIRE_TEXT }, }, }), @@ -143,7 +143,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ content: { type: "empty" }, parent: ["Question"], attributes: { - value: { type: "text", required: true, max: limits.MAX_CUSTOM_ANSWER }, + value: { type: "text", required: true, max: limits.MAX_QUESTIONNAIRE_TEXT }, choices: { type: "text", required: false, max: limits.MAX_ANSWER_CHOICES }, }, }), diff --git a/packages/dialect/src/index.ts b/packages/dialect/src/index.ts index d9a87ef52..cfe1acda7 100644 --- a/packages/dialect/src/index.ts +++ b/packages/dialect/src/index.ts @@ -75,6 +75,7 @@ export { $isQuestionnaireNode, cardStatus, QuestionnaireNode, + toElement as questionnaireElement, } from "./nodes/questionnaire"; export type { CardStatus, Option, Previous, Question, Questionnaire } from "./nodes/questionnaire"; diff --git a/packages/dialect/src/limits.ts b/packages/dialect/src/limits.ts index 5d03d7634..e936a3163 100644 --- a/packages/dialect/src/limits.ts +++ b/packages/dialect/src/limits.ts @@ -32,14 +32,20 @@ export const MAX_CALLOUT_TITLE = 100; /** Questionnaire shape, matching the `ask` tool's contract. */ export const MAX_QUESTIONS = 10; export const MAX_OPTIONS = 20; -export const MAX_QUESTION_HEADER = 80; -export const MAX_QUESTION_PROMPT = 1_000; -export const MAX_OPTION_LABEL = 200; -export const MAX_OPTION_DESCRIPTION = 1_000; +/** A reopened question's previous custom answer, kept at the `ask` tool's bound. */ export const MAX_CUSTOM_ANSWER = 4_000; /** Up to twenty ULIDs with separators in one answer. */ export const MAX_ANSWER_CHOICES = 600; +/** + * Questionnaire text, a projection of its server-owned record. + * + * The `ask` tool bounds each field of its own questions; a verbatim host dialog + * keeps whatever its runtime permitted. The Planner cannot author these + * components, so the source size is the only bound the document adds. + */ +export const MAX_QUESTIONNAIRE_TEXT = MAX_SOURCE_BYTES; + /** * Accepted comment threads, projected into the plan as ``. * diff --git a/packages/question/src/draft.ts b/packages/question/src/draft.ts index fe25a1df6..ff690f19f 100644 --- a/packages/question/src/draft.ts +++ b/packages/question/src/draft.ts @@ -149,7 +149,7 @@ export function read(model: Model, definition: Definition): Drafts { let custom = node.get("custom"); if (!(custom instanceof crdt.StrNode)) reject(`${question.id}.custom must be a CRDT string`); let value = (custom as crdt.StrNode).view(); - if (value.length > limits.MAX_CUSTOM) { + if (!question.verbatim && value.length > limits.MAX_CUSTOM) { reject(`${question.id}.custom exceeds ${limits.MAX_CUSTOM} characters`); } diff --git a/packages/question/src/limits.ts b/packages/question/src/limits.ts index 2390e242b..61b35c128 100644 --- a/packages/question/src/limits.ts +++ b/packages/question/src/limits.ts @@ -2,7 +2,9 @@ * Bounds on a questionnaire. * * Enforced by the VM when the agent asks a question and again on every edit. - * Clients apply the same numbers so a rejection is never a surprise. + * Clients apply the same numbers so a rejection is never a surprise. Verbatim + * host dialogs skip the per-field counts and lengths; the byte limits below + * still apply to them. */ export const MAX_QUESTIONS = 10; diff --git a/packages/question/src/question.test.ts b/packages/question/src/question.test.ts index e3fdef5f3..9f96f1ce5 100644 --- a/packages/question/src/question.test.ts +++ b/packages/question/src/question.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { derive, incomplete, summarize } from "./answer"; -import { answered, apply, assertPatch, crdt, create, read } from "./draft"; +import { answered, apply, assertPatch, crdt, create, read, restore } from "./draft"; import * as limits from "./limits"; import { identified } from "./index"; import { appendOption, normalize, QuestionError } from "./schema"; @@ -68,8 +68,54 @@ describe("normalize", () => { it("enforces the documented limits", () => { expect(() => normalize({ questions: Array.from({ length: 11 }, () => tool().questions[0]) })) .toThrow(/at most 10 questions/); - expect(() => normalize(tool({ header: "x".repeat(limits.MAX_HEADER + 1) }))) - .toThrow(/exceeds 80 characters/); + let many = Array.from({ length: limits.MAX_OPTIONS + 1 }, (_, index) => ({ + label: `${index}`, + description: "", + })); + for ( + let [override, message] of [ + [{ header: "x".repeat(limits.MAX_HEADER + 1) }, /exceeds 80 characters/], + [{ question: "x".repeat(limits.MAX_QUESTION + 1) }, /exceeds 1000 characters/], + [{ options: many }, /at most 20 options/], + [{ options: [{ label: "x".repeat(limits.MAX_LABEL + 1), description: "" }] }, /label/], + [ + { options: [{ label: "a", description: "x".repeat(limits.MAX_DESCRIPTION + 1) }] }, + /description exceeds 1000 characters/, + ], + ] as const + ) expect(() => normalize(tool(override))).toThrow(message); + let model = create(normalize(tool())); + model.api.val(["q0", "mode"]).set("custom"); + model.api.str(["q0", "custom"]).ins(0, "x".repeat(limits.MAX_CUSTOM + 1)); + expect(() => read(model, normalize(tool()))).toThrow(/custom exceeds 4000 characters/); + }); + + it("bounds verbatim host input only by the shared draft's byte limits", () => { + let question = { + header: "x".repeat(limits.MAX_HEADER + 1), + question: "x".repeat(limits.MAX_QUESTION + 1), + multiple: false, + options: Array.from({ length: limits.MAX_OPTIONS + 1 }, (_, index) => ({ + label: `${index}`.padEnd(limits.MAX_LABEL + 1, " "), + description: "x".repeat(limits.MAX_DESCRIPTION + 1), + })), + }; + let definition = normalize({ + questions: Array.from({ length: limits.MAX_QUESTIONS + 1 }, () => question), + }, { verbatim: true }); + expect(definition.questions).toHaveLength(limits.MAX_QUESTIONS + 1); + expect(definition.questions[0]).toMatchObject({ + header: question.header, + question: question.question, + options: question.options, + }); + let custom = "x".repeat(limits.MAX_CUSTOM + 1); + let model = create(definition); + model.api.val(["q0", "mode"]).set("custom"); + model.api.str(["q0", "custom"]).ins(0, custom); + expect(read(model, definition).q0!.custom).toBe(custom); + model.api.str(["q0", "custom"]).ins(0, "x".repeat(limits.MAX_MODEL_BYTES)); + expect(() => restore([...model.toBinary()], definition)).toThrow(/256 KiB limit/); }); it("requires multiple to be a boolean", () => { diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index 1252ac1e2..d2825dd00 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -240,3 +240,26 @@ test("a free-text host dialog renders an editable textarea without inventing a c expect(markup).toContain("Write the brief"); expect(markup).not.toContain('disabled=""'); }); + +test("only the Planner's own questions cap the custom answer textarea", () => { + let question = { header: "Input", question: "Write the brief", multiple: false }; + for ( + let [definition, cap] of [ + [normalize({ questions: [{ ...question, options: [] }] }, { verbatim: true }), false], + [ + normalize({ questions: [{ ...question, options: [{ label: "A", description: "" }] }] }), + true, + ], + ] as const + ) { + let drafts = read(create(definition), definition); + drafts.q0!.mode = "custom"; + let markup = renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts, + onChange() {}, + })); + expect(markup).toContain(" max) fail(`${name} exceeds ${max} characters`); return result; } /** A stored definition keeps its original string, so enforce the bound on that string. */ -function storedText(value: unknown, name: string, max: number, optional = false): void { - text(value, name, max, optional); - if ((value as string).length > max) fail(`${name} exceeds ${max} characters`); +function storedText( + value: unknown, + name: string, + max: number, + optional = false, + verbatim = false, +): void { + text(value, name, max, optional, verbatim); + if (!verbatim && (value as string).length > max) fail(`${name} exceeds ${max} characters`); } /** @@ -72,6 +79,10 @@ function storedText(value: unknown, name: string, max: number, optional = false) * unique within one call. Durable plan questionnaires re-key them to ULIDs on * the way in, since those do have to survive rewrites. * + * A verbatim definition is a host dialog the runtime already permitted: it + * keeps its text exactly and is bounded by the draft and document byte limits + * rather than by the `ask` tool's per-field contract. + * * @throws {QuestionError} */ export function normalize(raw: unknown, { verbatim = false } = {}): Definition { @@ -81,7 +92,7 @@ export function normalize(raw: unknown, { verbatim = false } = {}): Definition { if (!Array.isArray(args.questions) || args.questions.length === 0) { fail("At least one question is required"); } - if (args.questions.length > limits.MAX_QUESTIONS) { + if (!verbatim && args.questions.length > limits.MAX_QUESTIONS) { fail(`A questionnaire can contain at most ${limits.MAX_QUESTIONS} questions`); } @@ -92,7 +103,7 @@ export function normalize(raw: unknown, { verbatim = false } = {}): Definition { if (!Array.isArray(raw.options) || (!verbatim && raw.options.length === 0)) { fail(`Question ${index + 1} requires at least one option`); } - if (raw.options.length > limits.MAX_OPTIONS) { + if (!verbatim && raw.options.length > limits.MAX_OPTIONS) { fail(`Question ${index + 1} can contain at most ${limits.MAX_OPTIONS} options`); } if (typeof raw.multiple !== "boolean") { @@ -159,6 +170,9 @@ export function identified(raw: unknown): Definition { if ( !Array.isArray(source.questions) || source.questions.length === 0 || source.questions.length > limits.MAX_QUESTIONS + && !source.questions.every(question => + (question as { verbatim?: unknown })?.verbatim === true + ) ) { fail("Questionnaire definition has an invalid question count"); } @@ -177,14 +191,14 @@ export function identified(raw: unknown): Definition { let id = question.id as string; if (questionIds.has(id)) fail("Questionnaire has duplicate question IDs"); questionIds.add(id); - storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER, verbatim); - storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION, verbatim); + storedText(question.header, `Question ${index + 1} header`, limits.MAX_HEADER, false, verbatim); + storedText(question.question, `Question ${index + 1}`, limits.MAX_QUESTION, false, verbatim); if ( typeof question.multiple !== "boolean" || !Array.isArray(question.options) || question.options.length === 0 && !verbatim && (source.questions.length !== 1 || question.multiple) - || question.options.length > limits.MAX_OPTIONS + || !verbatim && question.options.length > limits.MAX_OPTIONS ) { fail(`Question ${index + 1} has invalid options or multiple value`); } @@ -195,12 +209,19 @@ export function identified(raw: unknown): Definition { let optionId = option.id as string; if (optionIds.has(optionId)) fail("Questionnaire has duplicate option IDs"); optionIds.add(optionId); - storedText(option.label, `Question ${index + 1} option label`, limits.MAX_LABEL, verbatim); + storedText( + option.label, + `Question ${index + 1} option label`, + limits.MAX_LABEL, + false, + verbatim, + ); storedText( option.description, `Question ${index + 1} option description`, limits.MAX_DESCRIPTION, true, + verbatim, ); } } From d975c555189029b0e393e385e8a56f92a4092dae Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 28 Sep 2026 22:22:07 -0700 Subject: [PATCH 05/12] Keep the first input in an empty custom answer Replacing a custom answer always deleted the current text first. On an empty answer json-joy 17 binary-encodes that zero-length delete so the server decodes garbage that swallows the following insert, yet still acknowledges the edit. A paste, fill or one-character answer into the empty box was lost, and a verbatim host question then saved "". Delete only existing text, and skip the insert for an empty value, which json-joy refuses: clearing a typed answer threw after its delete. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: fail-before/pass-after passed: with the unfixed change() the 5 new controller tests failed (the server-applied draft kept custom "" after one input for verbatim and ordinary questions and a host-only paste, and clearing threw EMPTY_STRING); 12 of 12 passed after Assistant-verification: bun test passed: 1732 pass, 2 PostgreSQL skips, 0 fail, 7402 assertions across 196 files Assistant-verification: bun test packages/question passed: 35 pass, 0 fail Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix passed: no change to tracked files; one existing lint warning Assistant-verification: bun run ci failed: dprint flagged only three untracked .atomic/todos files; dprint check excluding .atomic, oxlint, token, type-scale and design checks then passed User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" Co-authored-by: Alex Lavaee --- .../src/react/questionnaire-controller.ts | 7 +- .../src/react/use-questionnaire.test.ts | 96 ++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/question/src/react/questionnaire-controller.ts b/packages/question/src/react/questionnaire-controller.ts index a7ac5fb1e..d1850743c 100644 --- a/packages/question/src/react/questionnaire-controller.ts +++ b/packages/question/src/react/questionnaire-controller.ts @@ -156,8 +156,11 @@ export class QuestionnaireController { } if (patch.custom !== undefined) { let value = doc.api.str([question, "custom"]); - value.del(0, value.length()); - value.ins(0, patch.custom as string); + let text = patch.custom as string; + // json-joy 17 binary-encodes a zero-length delete so that the receiver decodes + // garbage that swallows the following insert, and it throws on an empty insert. + if (value.length() > 0) value.del(0, value.length()); + if (text) value.ins(0, text); } }); }; diff --git a/packages/question/src/react/use-questionnaire.test.ts b/packages/question/src/react/use-questionnaire.test.ts index c67da249b..445ac3a02 100644 --- a/packages/question/src/react/use-questionnaire.test.ts +++ b/packages/question/src/react/use-questionnaire.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { create, decision, normalize } from "../index"; +import { apply, create, decision, normalize, read } from "../index"; import { DEFINITION } from "./use-questionnaire.test-fixtures"; import { FocusReporter, QuestionnaireController } from "./use-questionnaire"; +import type { Definition } from "../index"; import type { Transport } from "./use-questionnaire"; const QUESTIONNAIRE = normalize({ @@ -48,6 +49,12 @@ function transport(definition = DEFINITION) { } if (kind === "question:edit") { edits++; + // The server's own gate: an edit counts only if its wire bytes apply. + let outcome = apply(model, definition, payload.patch as number[]); + if (!outcome.ok) { + return { open: true, accepted: false, revision: edits, message: outcome.message }; + } + model = outcome.model; return { open: true, accepted: true, applied: false, revision: edits }; } if (kind === "question:submit") { @@ -81,9 +88,22 @@ function transport(definition = DEFINITION) { submitRevisions: () => submitRevisions, presence: () => presence, frames: () => frames, + drafts: () => read(model, definition), }; } +async function open(definition: Definition) { + let bridge = transport(definition); + let controller = new QuestionnaireController(bridge.value, "question-1", definition, true); + let off = controller.subscribe(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + return { bridge, controller, off }; +} + +function settle() { + return new Promise(resolve => setTimeout(resolve, 0)); +} + describe("QuestionnaireController", () => { it("rejects a questionnaire returned for an independent decision record", () => { let bridge = transport(QUESTIONNAIRE); @@ -252,3 +272,77 @@ describe("FocusReporter", () => { expect(sent.slice(3)).toEqual(["b", "c"]); }); }); + +describe("custom answer edits", () => { + let text = " pasted in one input,\nwith spacing kept "; + + for (let verbatim of [false, true]) { + it(`stores one input into an empty custom answer (verbatim: ${verbatim})`, async () => { + let definition = normalize({ + questions: [{ + header: "Scope", + question: "Which areas?", + multiple: true, + options: [{ label: "Server", description: "" }, { label: "Web", description: "" }], + }], + }, { verbatim }); + let { bridge, controller, off } = await open(definition); + + controller.change("q0", { mode: "custom" }); + await settle(); + controller.change("q0", { custom: text }); + await settle(); + + expect(controller.getSnapshot().error).toBeUndefined(); + expect(bridge.drafts().q0).toMatchObject({ mode: "custom", custom: text }); + off(); + }); + } + + it("stores a paste into a host dialog that has only a custom answer", async () => { + let definition = normalize({ + questions: [{ header: "Editor", question: "Edit the notes", multiple: false, options: [] }], + }, { verbatim: true }); + let { bridge, controller, off } = await open(definition); + let long = "notes line\n".repeat(500); + + controller.change("q0", { custom: long }); + await settle(); + + expect(bridge.drafts().q0?.custom).toBe(long); + off(); + }); + + it("replaces a non-empty custom answer", async () => { + let { bridge, controller, off } = await open(DEFINITION); + + controller.change("q0", { mode: "custom" }); + await settle(); + controller.change("q0", { custom: "first" }); + await settle(); + expect(bridge.drafts().q0?.custom).toBe("first"); + controller.change("q0", { custom: text }); + await settle(); + + expect(bridge.drafts().q0?.custom).toBe(text); + off(); + }); + + it("submits a verbatim answer that was typed and then cleared", async () => { + let definition = normalize({ + questions: [{ header: "Input", question: "Name?", multiple: false, options: [] }], + }, { verbatim: true }); + let { bridge, controller, off } = await open(definition); + + controller.change("q0", { custom: "typed" }); + await settle(); + controller.change("q0", { custom: "" }); + await settle(); + expect(bridge.drafts().q0?.custom).toBe(""); + + controller.submit(); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(bridge.submits()).toBe(1); + off(); + }); +}); From a99490352c79d7b7ac11c7097b3af5496846b0e5 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Tue, 29 Sep 2026 00:08:24 -0700 Subject: [PATCH 06/12] Run every atomic Planner session as a full Atomic session HARNESS=atomic is now the only switch. Every Planner session on that harness, local or hosted, runs as a full Atomic session: builtins, default coding tools, the operator's Atomic resources, and Chopin bound as HostInput. The summary and research workers keep their isolated sessions, and copilot-sdk and pi are unchanged. This supersedes the local-only opt-in from c84fa4e. Its environment variables and Config.atomicPlanner are gone, and nothing replaces them. A checkout now comes only from invoke_planner. On the atomic harness it is verified against the document's origin before anything is posted, then remembered in memory for that channel. Every later Planner session re-verifies it before use, whether it starts from the browser or MCP. Without a remembered checkout, the session runs with the same tools in an empty 0700 directory created for that channel alone and removed at shutdown. The Planner's instructions say which case applies. Host input expires after a fixed 30 minutes. The questionnaire record and question:resolved now carry an "expired" status. The card stays in the document with status="expired" and is listed among the resolved Decisions, with a note that nobody answered and the Planner will use its best judgement. Atomic receives no answer, and late submissions are refused. An abort or member cancel still withdraws cards as before. invoke_planner is offered for every harness and auth mode. The posted message is attributed to the caller. The turn runs under the channel's existing owner, or the caller's live browser login claims ownership, or the call is refused before anything is posted. Other harnesses ignore the checkout. Assistant-model: Claude Opus 5.5 Assistant-workflow: goal (run 98acf681-3730-4957-b885-0d70e0e6b32a) Assistant-verification: bun test passed: 1746 pass, 2 PostgreSQL skips, 0 fail, 7619 assertions across 196 files Assistant-verification: focused bun test (harness, mcp, chat, config, question, protocol) passed: 390 pass, 0 fail, 1821 assertions across 38 files Assistant-verification: worker isolation mutations passed: BUILTINS_OFF set true failed 17 of 34, isolated skill discovery failed 2, the result tool offered on plain turns failed 11; the adapter was restored byte-for-byte Assistant-verification: behaviour mutations passed: never registering atomic sessions, withdrawing on expiry, rejecting expired records on restore, and refusing an owner other than the caller each failed their new tests; every file was restored byte-for-byte Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run fix passed: formatted changed files and one untracked .atomic todo, which was restored byte-for-byte; one existing lint warning Assistant-verification: bun run ci failed: dprint flagged only one untracked .atomic/todos file; dprint check on the tracked and new files, oxlint, token, type-scale and design checks then passed Assistant-verification: relative-link check passed: every relative link and anchor in README.md, AGENTS.md and docs resolves with exact casing User-preference: "Atomic's SDK is the runtime and the host supplies human input; Chopin implements HostInput rather than intercepting tools" User-preference: "In local mode the Planner may run as a full Atomic session so workflows run inside Chopin with questions in Decisions" User-preference: No extra per-harness flags; HARNESS=atomic always runs the Planner as a full Atomic session, hosted included User-preference: No extra per-harness env settings; the checkout comes from invoke_planner and the input timeout is a code constant Co-authored-by: Alex Lavaee --- AGENTS.md | 25 +- README.md | 11 +- apps/server/src/agent/planner.ts | 31 +- apps/server/src/auth/session.ts | 2 +- apps/server/src/chat/invoke.test.ts | 310 ++++++++++++++---- apps/server/src/chat/service.ts | 88 +++-- apps/server/src/config.test.ts | 144 +------- apps/server/src/config.ts | 38 +-- .../src/conversation-plan/job-context.ts | 3 +- .../src/harness/atomic.contract.test.ts | 10 +- .../server/src/harness/atomic/adapter.test.ts | 26 +- apps/server/src/harness/atomic/adapter.ts | 11 +- .../src/harness/atomic/checkout.test.ts | 13 +- apps/server/src/harness/atomic/checkout.ts | 53 ++- apps/server/src/harness/atomic/full.test.ts | 102 ++++-- apps/server/src/harness/atomic/full.ts | 2 +- .../src/harness/atomic/human-input.test.ts | 108 ++++-- apps/server/src/harness/atomic/human-input.ts | 43 +-- apps/server/src/harness/atomic/workspace.ts | 67 ++++ apps/server/src/harness/harnesses.ts | 13 +- apps/server/src/harness/session.test.ts | 152 ++++++--- apps/server/src/harness/session.ts | 25 +- apps/server/src/main.ts | 55 ++-- apps/server/src/mcp.ts | 11 +- apps/server/src/mcp/hosted.test.ts | 171 ++++------ apps/server/src/mcp/hosted.ts | 21 +- apps/server/src/plan/room.ts | 13 + apps/server/src/questions/record-fields.ts | 14 +- apps/server/src/questions/record-types.ts | 2 +- .../server/src/questions/record-validation.ts | 18 +- apps/server/src/questions/service-ask.ts | 30 +- apps/server/src/questions/service-cancel.ts | 74 ++++- apps/server/src/questions/service.ts | 2 +- apps/server/src/questions/store-types.ts | 2 +- apps/server/src/questions/store.ts | 6 +- apps/server/src/rooms.ts | 2 +- apps/server/src/testing/config.ts | 54 +++ apps/server/src/testing/decisions.ts | 4 +- docs/architecture.md | 7 +- docs/authentication.md | 16 +- docs/hosted-agent.md | 168 ++++++---- docs/local-agent-mcp.md | 85 +++-- docs/self-hosting.md | 162 ++++----- packages/dialect/src/convert.test.ts | 31 ++ packages/dialect/src/dialect.test.ts | 13 + packages/dialect/src/dialect.ts | 5 +- .../dialect/src/nodes/questionnaire-fields.ts | 11 +- packages/dialect/src/validate.ts | 7 +- packages/editor/src/decision-state.test.ts | 8 + packages/editor/src/decision-state.ts | 2 +- packages/editor/src/decisions.test.tsx | 64 +++- packages/editor/src/widgets/questionnaire.tsx | 15 + packages/protocol/question.d.ts | 18 +- packages/question/src/limits.ts | 3 + .../question/src/react/question-view.test.ts | 37 ++- packages/question/src/react/question-view.tsx | 35 +- 56 files changed, 1549 insertions(+), 894 deletions(-) create mode 100644 apps/server/src/harness/atomic/workspace.ts create mode 100644 apps/server/src/testing/config.ts diff --git a/AGENTS.md b/AGENTS.md index 2d9c9c25b..6b4487cbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,10 +114,15 @@ external implementation runs are durable. model-backed research request supplies its GitHub App token and Copilot entitlement. The database stores only a token-free owner reference and durable context. -- **Planner tools are repository-fixed by default.** The hosted runtime has no shell, - checkout, host filesystem, skills, plugins, or arbitrary GitHub access. Local-only - `ATOMIC_PLANNER=full` with a verified checkout deliberately enables the operator's - Atomic tools and resources, including shell and filesystem access. +- **Planner tools are repository-fixed under `copilot-sdk` and `pi`.** Those + Planners have no shell, checkout, host filesystem, skills, plugins, or arbitrary + GitHub access. `HARNESS=atomic` deliberately runs every Planner session, local + or hosted, as a full Atomic session with the operator's Atomic tools and + resources, including shell and filesystem access as the server process's user. + Its working directory is a checkout verified from `invoke_planner` and + remembered per channel in memory, or else an empty per-channel directory. Its + summary and research workers stay isolated. There is no flag for this; the + harness is the choice. - **Repository node IDs are authoritative.** Owner and repository names resolve GitHub requests but never replace the stored node identity. - **Persistence should precede publication.** Do not acknowledge or broadcast a @@ -133,9 +138,13 @@ Planner destination without a mention. `instruction()` strips the mention before model input. Recent room messages that did not address the Planner still enter a bounded backscroll for the next turn. -An accepted comment also starts an explicit Planner turn after it commits. +An accepted comment also starts an explicit Planner turn after it commits. An MCP +`invoke_planner` call posts its instruction as the caller's member message and +runs under the channel's existing Planner owner; only a caller with a live +browser login can claim an unowned channel. -The Planner is a custom agent, not a general coding agent. Its turn runs +Under `copilot-sdk` and `pi` the Planner is a custom agent, not a general coding +agent; under `atomic` it is a full Atomic session. Either way its turn runs through `HarnessAgent.stream()` from `@ai-sdk/harness`; the conversation stays active until the returned stream finishes. An interrupted turn is never replayed automatically because it may already have made durable tool changes. @@ -401,7 +410,9 @@ names and the external GitHub MCP contribution. Counts vary with SDK and remote MCP versions. A healthy boundary includes Chopin's document tools (currently plan-named), repository tools, and allowed pull-request tools, and excludes ambient capabilities such as `bash`, filesystem access, URL fetch, host Git, -issues, and unrestricted search. +issues, and unrestricted search. The exception is an `atomic` Planner session, +which deliberately adds Atomic's builtins and coding tools; its summary and +research worker sessions must still show only their own tools. Treat a missing required tool or an unexpected ambient tool as a security or configuration failure even when the overall count looks plausible. diff --git a/README.md b/README.md index a7413c5ff..b3f359132 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,11 @@ appear as documents in navigation. https://github.com/user-attachments/assets/72a85be8-685f-4d60-9937-b3855b46cebe The Planner can inspect the selected GitHub repository and its pull requests -through bounded, read-only tools, then co-author the document. It cannot write to -GitHub, edit a checkout, or implement code. A separate coding agent can connect +through bounded, read-only tools, then co-author the document. Under the default +`copilot-sdk` harness, and under `pi`, it cannot write to GitHub, edit a checkout, +or implement code. `HARNESS=atomic` deliberately runs it as a full Atomic session +with shell and filesystem access as the server process's user; see +[Self-hosting](docs/self-hosting.md#choose-and-trust-a-harness). A separate coding agent can connect to Chopin through MCP to create or revise documents and consume an approved implementation graph. @@ -60,8 +63,8 @@ and tool vocabulary remain optimized for planning. - Chopin supports GitHub.com. GitHub Enterprise Server endpoints are not configurable. - The Planner's file, tree, and history tools read the default branch captured - when its session starts; code search is repository-scoped. It never reads a - local checkout or uncommitted changes. + when its session starts; code search is repository-scoped. Except under + `HARNESS=atomic`, it never reads a local checkout or uncommitted changes. - Every browser participant signs in, passes the instance admission policy, and needs repository access through the GitHub App installation. MCP callers also pass instance admission, but use their own bearer token for repository diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index db9ba6f97..1f646f34f 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -17,6 +17,7 @@ import { COMPONENTS, DIFF_LANGUAGE, MERMAID_LANGUAGE } from "@chopin/dialect/dialect"; import type { Component } from "@chopin/dialect/dialect"; +import type { PlannerWorkspace } from "../harness/atomic/workspace"; /** Components the agent writes itself. The rest are created for it. */ const AUTHORABLE = ["Callout", "Tabs", "Tab", "Underline"]; @@ -233,18 +234,28 @@ the plan, use the \`detach_question\` operation rather than deleting the block.` export function plannerInstructions( repository: string, bootstrap?: string, - checkout?: string, + workspace?: PlannerWorkspace, ): string { - let access = checkout - ? `The selected repository is ${repository}. Your working directory is the verified local checkout ${checkout}. -You have the operator's Atomic coding tools, workflows and resources, with shell and filesystem access as the local user. -Atomic human input, including workflow questions, goes to Chopin Decisions. Chopin's document tools remain fixed to this document.` - : `Read before you propose. The selected repository is ${repository}. Use + let reading = `Read before you propose. The selected repository is ${repository}. Use \`read_repository_file\`, \`list_repository_tree\`, \`search_repository\` and \`repository_history\` for its code, and \`list_pull_requests\` and -\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository. - -You have no shell, checkout, host filesystem, skills or repository instructions, +\`pull_request_read\` for its pull requests. Every repository tool is fixed to this repository.`; + if (!workspace) { + let isolated = `You have no shell, checkout, host filesystem, skills or repository instructions, and cannot change GitHub. Ground the plan in what those reading tools return.`; - return [PROMPT, access, bootstrap].filter(Boolean).join("\n\n"); + return [PROMPT, reading, isolated, bootstrap].filter(Boolean).join("\n\n"); + } + let place = workspace.checkout + ? `Your working directory, ${workspace.cwd}, is a local checkout of ${repository} +verified against its origin. Its branch and working tree may differ from what the +repository tools read.` + : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created +empty for this document. It is not a checkout and holds no repository files, so read +${repository} through the repository tools.`; + let full = `This is a full Atomic session: you have the operator's Atomic coding tools, workflows, +subagents and resources, with shell and filesystem access as the server process's user. +Atomic human input, including workflow questions, goes to Chopin Decisions. Input nobody +answers in time expires with no answer; then proceed on your best judgement and say what +you assumed. Chopin's document tools remain fixed to this document.`; + return [PROMPT, reading, place, full, bootstrap].filter(Boolean).join("\n\n"); } diff --git a/apps/server/src/auth/session.ts b/apps/server/src/auth/session.ts index af8017777..3de98e931 100644 --- a/apps/server/src/auth/session.ts +++ b/apps/server/src/auth/session.ts @@ -267,7 +267,7 @@ export class Sessions { : undefined; } - /** A local MCP handoff borrows an existing login, never the caller's bearer. */ + /** An MCP handoff may borrow an existing login to claim ownership, never the caller's bearer. */ async forUser(userId: string): Promise { for (let current of [...this.#sessions.values()].toReversed()) { if (current.session.userId !== userId) continue; diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts index 0e478a446..78fd750e6 100644 --- a/apps/server/src/chat/invoke.test.ts +++ b/apps/server/src/chat/invoke.test.ts @@ -1,30 +1,50 @@ -import { expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ActiveOwnerBindings } from "../agent/active-owner"; import { Admission } from "../auth/admission"; import { Sessions } from "../auth/session"; +import { fullPlanner } from "../harness/atomic/full"; +import { removeWorkspaces } from "../harness/atomic/workspace"; +import { openPlannerSession } from "../harness/session"; import * as Plan from "../plan/service"; import { MemoryStorage } from "../storage/memory/adapter"; +import { configured, LOCAL } from "../testing/config"; import * as Chat from "./service"; import type { Server } from "bun"; import type { HostedAuth } from "../auth/routes"; import type { Config } from "../config"; import type { GitHub } from "../github/client"; -import type { SocketData } from "../wire"; +import type { Socket, SocketData } from "../wire"; -async function setup() { +const ATOMIC = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + +let cleanups: Array<() => Promise> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0)) await cleanup(); + await removeWorkspaces(); +}); + +function grant(accessToken: string) { + return { + accessToken, + accessExpiresIn: 28_800, + refreshToken: "refresh-token", + refreshExpiresIn: 86_400, + }; +} + +async function setup(config: Config = { agent: true } as Config) { let now = new Date(); let storage = new MemoryStorage(); let user = { id: "U_ana", login: "ana", avatarUrl: "" }; - await storage.users.put({ ...user, now }); + let bob = { id: "U_bob", login: "bob", avatarUrl: "" }; + for (let person of [user, bob]) await storage.users.put({ ...person, now }); let sessions = new Sessions(storage, false); - let login = await sessions.issue(user.id, { - accessToken: "browser-token", - accessExpiresIn: 28_800, - refreshToken: "refresh-token", - refreshExpiresIn: 86_400, - }); + let login = await sessions.issue(user.id, grant("browser-token")); let repository = { id: "R_score", owner: "octo-org", name: "score", defaultBranch: "main" }; let github = { repositoryAccess: async () => ({ @@ -78,28 +98,97 @@ async function setup() { server, auth, repository, - config: { agent: true } as Config, + config, claimantSessionId: login.id, activeOwner: () => owners.resolve(channel.id), persist: () => Plan.persist(plan), }; - return { + let closed = false; + let fixture = { context, events, user, - owners, - storage, + bob, + login, backend, + /** The session that owns the channel's Planner, if any. */ + owner: async () => + (await storage.channels.readAgent(channel.id, new Date()))?.agent?.ownerSessionId, async close() { + if (closed) return; + closed = true; owners.revokeAll(); await Plan.close(plan); }, }; + cleanups.push(fixture.close); + return fixture; +} + +type Seen = { + owner: string; + full: ReturnType; + instructions: string; + prompt: string; +}; + +/** The real session opener, over a harness stub that records what each turn runs with. */ +function observe(context: Chat.Room): Seen[] { + let seen: Seen[] = []; + context.openPlannerSession = (owner, channel) => + openPlannerSession(owner, channel, { + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => ({ destroy: async () => {} }) as never, + registerCredential: () => () => {}, + agent: { + createSession: async ({ sessionId }: { sessionId: string }) => ({ + sessionId, + destroy: async () => {}, + }), + stream: async (call: { + session: { sessionId: string }; + prompt: string; + options: { instructions: string }; + }) => { + seen.push({ + owner: owner.ownerSessionId, + full: fullPlanner(call.session.sessionId), + instructions: call.options.instructions, + prompt: call.prompt, + }); + return { fullStream: (async function*() {})() }; + }, + } as never, + }); + return seen; +} + +async function checkout(origin: string): Promise { + let root = await mkdtemp(join(tmpdir(), "chopin-invoke-checkout-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + let git = Bun.spawn(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }); + expect(await git.exited).toBe(0); + } + return realpath(root); +} + +/** A browser member's `@chopin` message, through the ordinary composer path. */ +async function browserTurn(context: Chat.Room, text: string) { + let ws = { data: { handle: "ana", principalId: "U_ana" }, send() {} } as unknown as Socket; + await Chat.send(context, ws, { + kind: "chat:send", + rid: "send", + requestId: crypto.randomUUID(), + to: "planner", + text, + ts: 0, + } as never); + await context.chat.running; } test("an invoked instruction persists verbatim as a member message before publication and returns before the turn", async () => { - let fixture = await setup(); - let { context, events, user } = fixture; + let { context, events, user } = await setup(); let saved = Promise.withResolvers(); let persist = context.persist; context.persist = async () => { @@ -108,13 +197,12 @@ test("an invoked instruction persists verbatim as a member message before public }; let finished = Promise.withResolvers(); let opened = Promise.withResolvers(); - let seen: Array<{ checkout?: string; prompt: string }> = []; - context.checkout = "/tmp/score"; - context.openPlannerSession = async (_owner, channel) => ({ + let seen: string[] = []; + context.openPlannerSession = async () => ({ ok: true, value: { async stream(prompt) { - seen.push({ checkout: channel.checkout, prompt }); + seen.push(prompt); opened.resolve(); return { fullStream: (async function*() { @@ -140,29 +228,27 @@ test("an invoked instruction persists verbatim as a member message before public text: " @chopin Plan this.\n", }); expect(events.some(event => event.kind === "chat:message")).toBe(true); - expect(seen[0]?.checkout).toBe("/tmp/score"); - expect(seen[0]?.prompt).toBe("@ana: @chopin Plan this.\n"); + expect(seen).toEqual(["@ana: @chopin Plan this.\n"]); finished.resolve(); await context.chat.running; expect(context.chat.busy).toBe(false); } finally { saved.resolve(); finished.resolve(); - await fixture.close(); } }); -test("queued invocations retain their own checkout and durable message exactly once", async () => { +test("queued invocations post their durable message exactly once", async () => { let fixture = await setup(); let { context, user } = fixture; let first = Promise.withResolvers(); let opened = Promise.withResolvers(); - let checkouts: Array = []; - context.openPlannerSession = async (_owner, channel) => ({ + let prompts: string[] = []; + context.openPlannerSession = async () => ({ ok: true, value: { - async stream() { - checkouts.push(channel.checkout); + async stream(prompt) { + prompts.push(prompt); opened.resolve(); return { fullStream: (async function*() { @@ -175,17 +261,16 @@ test("queued invocations retain their own checkout and durable message exactly o }, }); try { - await Chat.invoke({ ...context, checkout: "/tmp/first" }, user, "First"); + await Chat.invoke(context, user, "First"); await opened.promise; - await Chat.invoke({ ...context, checkout: "/tmp/second" }, user, "Second"); + await Chat.invoke(context, user, "Second"); await Chat.invoke(context, user, "Second"); expect(context.chat.waiting.map(item => item.text)).toEqual(["Second", "Second"]); expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); expect(new Set(context.chat.entries.map(entry => entry.id)).size).toBe(3); - expect(checkouts).toEqual(["/tmp/first"]); first.resolve(); await context.chat.running; - expect(checkouts).toEqual(["/tmp/first", "/tmp/second", undefined]); + expect(prompts).toEqual(["@ana: First", "@ana: Second", "@ana: Second"]); expect(context.chat.entries.map(entry => entry.text)).toEqual(["First", "Second", "Second"]); expect(context.chat.waiting).toEqual([]); expect(context.chat.busy).toBe(false); @@ -195,47 +280,132 @@ test("queued invocations retain their own checkout and durable message exactly o await Plan.close(restored); } finally { first.resolve(); - await fixture.close(); } }); -test("failed persistence, unavailable Planner, full queue and another owner start no invoked turn", async () => { +test("failed persistence, an unavailable Planner and a full queue start no invoked turn", async () => { + let { context, events, user } = await setup(); + context.config.agent = false; + expect(await Chat.invoke(context, user, "Review")).toBe("planner-unavailable"); + context.config.agent = true; + context.chat.busy = true; + context.chat.waiting = Array.from( + { length: 20 }, + (_, index) => ({ id: String(index), handle: "ana", text: "Waiting" }), + ); + expect(await Chat.invoke(context, user, "Review")).toBe("planner-queue-full"); + context.chat.waiting = []; + context.chat.busy = false; + context.persist = async () => { + throw new Error("storage failed"); + }; + await expect(Chat.invoke(context, user, "Review")).rejects.toThrow("storage failed"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(context.chat.running).toBeUndefined(); +}); + +test("an invocation runs under the channel's existing owner and is attributed to its caller", async () => { let fixture = await setup(); - let { context, user, events } = fixture; - try { - context.config.agent = false; - expect(await Chat.invoke(context, user, "Review")).toBe("planner-unavailable"); - context.config.agent = true; - context.chat.busy = true; - context.chat.waiting = Array.from( - { length: 20 }, - (_, index) => ({ id: String(index), handle: "ana", text: "Waiting" }), - ); - expect(await Chat.invoke(context, user, "Review")).toBe("planner-queue-full"); - context.chat.waiting = []; - context.persist = async () => { - throw new Error("storage failed"); - }; - await expect(Chat.invoke(context, user, "Review")).rejects.toThrow("storage failed"); - expect(context.chat.entries).toEqual([]); - expect(events).toEqual([]); - await fixture.storage.users.put({ id: "U_bob", login: "bob", avatarUrl: "", now: new Date() }); - let login = await context.auth.sessions.issue("U_bob", { - accessToken: "bob-token", - accessExpiresIn: 28_800, - refreshToken: "refresh", - refreshExpiresIn: 86_400, - }); - expect( - await Chat.invoke( - { ...context, claimantSessionId: login.id }, - { id: "U_bob", login: "bob" }, - "Review", - ), - ).toBe("planner-owner-unavailable"); - expect(context.chat.waiting).toEqual([]); - expect(context.chat.running).toBeUndefined(); - } finally { - await fixture.close(); + let { context, user, bob } = fixture; + let owner = await context.auth.sessions.issue(bob.id, grant("bob-token")); + await Chat.resolveOwner(context.auth, context.repository, context.room, owner.id); + let seen = observe(context); + for (let claimant of [undefined, fixture.login.id]) { + expect(await Chat.invoke({ ...context, claimantSessionId: claimant }, user, "Review")) + .toBeUndefined(); + await context.chat.running; + } + expect(seen.map(turn => ({ owner: turn.owner, prompt: turn.prompt }))).toEqual([ + { owner: owner.id, prompt: "@ana: Review" }, + { owner: owner.id, prompt: "@ana: Review" }, + ]); + expect(context.chat.entries.filter(entry => entry.author.kind === "member")).toMatchObject([ + { author: { kind: "member", handle: "ana" }, text: "Review" }, + { author: { kind: "member", handle: "ana" }, text: "Review" }, + ]); + expect(await fixture.owner()).toBe(owner.id); +}); + +test("without an owner the caller's live login claims the Planner, and without either nothing starts", async () => { + let fixture = await setup(); + let { context, events, user } = fixture; + let seen = observe(context); + expect(await Chat.invoke({ ...context, claimantSessionId: undefined }, user, "Review")) + .toBe("planner-owner-unavailable"); + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(context.chat.running).toBeUndefined(); + expect(await fixture.owner()).toBeUndefined(); + expect(await Chat.invoke(context, user, "Review")).toBeUndefined(); + await context.chat.running; + expect(await fixture.owner()).toBe(fixture.login.id); + expect(seen.map(turn => turn.owner)).toEqual([fixture.login.id]); +}); + +test("the atomic harness verifies a checkout before posting and every later session reuses it", async () => { + let fixture = await setup(configured(ATOMIC)); + let { context, events, user } = fixture; + let seen = observe(context); + let matching = await checkout("git@github.com:octo-org/score.git"); + let other = await checkout("https://github.com/octo-org/other.git"); + for (let path of [other, join(matching, "missing")]) { + expect(await Chat.invoke(context, user, "Review", path)).toBe("checkout-unverified"); + } + expect(context.chat.entries).toEqual([]); + expect(events).toEqual([]); + expect(await fixture.owner()).toBeUndefined(); + + expect(await Chat.invoke(context, user, "Review", matching)).toBeUndefined(); + await context.chat.running; + await browserTurn(context, "@chopin Continue"); + expect(await Chat.invoke(context, user, "Again", other)).toBe("checkout-unverified"); + await browserTurn(context, "@chopin Once more"); + expect(seen).toHaveLength(3); + for (let turn of seen) { + expect(turn.full?.cwd).toBe(matching); + expect(turn.full?.humanInput.questionnaire).toBeFunction(); + expect(turn.instructions).toContain(`${matching}, is a local checkout of octo-org/score`); + } +}); + +test("other harnesses ignore a checkout: it is neither verified, used, nor remembered", async () => { + let { context, user } = await setup(configured()); + let seen = observe(context); + let matching = await checkout("workgit:octo-org/score.git"); + for (let path of ["/does/not/exist", matching]) { + expect(await Chat.invoke(context, user, "Review", path)).toBeUndefined(); + await context.chat.running; + } + expect(seen.map(turn => turn.full)).toEqual([undefined, undefined]); + for (let turn of seen) expect(turn.instructions).toContain("You have no shell"); + context.config = configured(ATOMIC); + await browserTurn(context, "@chopin Continue"); + let atomic = seen.at(-1)!.full!; + expect(atomic.cwd).not.toBe(matching); + expect(await readdir(atomic.cwd)).toEqual([]); +}); + +test("HARNESS=atomic runs every Planner session full in hosted and local configuration", async () => { + for ( + let [overrides, full] of [ + [ATOMIC, true], + [{ ...ATOMIC, ...LOCAL }, true], + [{}, false], + [{ HARNESS: "pi", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model", ...LOCAL }, false], + ] as const + ) { + let { context, user } = await setup(configured(overrides)); + let seen = observe(context); + expect(await Chat.invoke(context, user, "Review")).toBeUndefined(); + await context.chat.running; + await browserTurn(context, "@chopin Continue"); + expect(seen).toHaveLength(2); + for (let turn of seen) { + let harness = context.config.harness; + expect({ harness, full: !!turn.full }).toEqual({ harness, full }); + expect(!!turn.full?.humanInput).toBe(full); + expect(turn.instructions.includes("This is a full Atomic session")).toBe(full); + } } }); diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 66fdfb96d..a7c983ce2 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -22,6 +22,8 @@ import { ulid } from "@chopin/dialect"; import { PLANNER_TOOL_NAMES } from "../harness/tool-names"; import type { PlannerSession } from "../harness/session"; +import { verifiedCheckout } from "../harness/atomic/checkout"; +import { rememberCheckout } from "../harness/atomic/workspace"; import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { DocumentRoom, ResearchWorkspaceRequest } from "../agent/tools"; @@ -92,7 +94,7 @@ export type Waiting = Wire.Waiting & { /** A queued background turn, resolved only after its scope is cleaned up. */ job?: JobTurn; posted?: boolean; - checkout?: string; + /** MCP caller whose instruction this is; it never claims ownership without their login. */ invokedBy?: string; }; @@ -101,7 +103,7 @@ export type ActiveMemberRequest = { userId: string; handle: string; text: string; - claimantSessionId: string; + claimantSessionId: string | undefined; turnId: string; lifecycle: number; }; @@ -421,14 +423,18 @@ export type Room = { room: string; config: Config; auth: HostedAuth; - claimantSessionId: string; + /** + * Login session that may claim Planner ownership for this room's turns. + * Absent for an MCP caller without a live browser login: their instruction + * runs only under an owner the channel already has. + */ + claimantSessionId: string | undefined; repository: HostedRepository; activeOwner?: () => Promise; persist: () => Promise; commitRoomMessage?: (entry: Wire.Entry) => Promise; roomMessagePublished?: () => void; openPlannerSession?: typeof import("../harness/session")["openPlannerSession"]; - checkout?: string; invokedBy?: string; ownerAvailable?: () => Promise; jobs?: JobService; @@ -463,25 +469,37 @@ export function send(context: Room, ws: Socket, msg: Request): Promis return completed; } -/** Accept a local MCP instruction without waiting for its Planner turn. */ +/** + * Accept an MCP instruction without waiting for its Planner turn. + * + * The caller is attributed on the posted message, and the turn runs under the + * channel's Planner owner. A checkout matters only to the atomic harness, which + * verifies it before anything is posted and remembers it for the channel. + */ export async function invoke( context: Room, user: { id: string; login: string }, text: string, -): Promise<"planner-unavailable" | "planner-owner-unavailable" | "planner-queue-full" | undefined> { + checkout?: string, +): Promise< + | "planner-unavailable" + | "planner-owner-unavailable" + | "planner-queue-full" + | "checkout-unverified" + | undefined +> { let { chat, server, room } = context; if (chat.pendingSends >= MAX_PENDING_SENDS) return "planner-queue-full"; chat.pendingSends++; let post = async () => { if (!context.config.agent || chat.closed) return "planner-unavailable" as const; + let verified: string | undefined; + if (checkout !== undefined && context.config.harness === "atomic") { + verified = await verifiedCheckout(context.repository, checkout); + if (!verified) return "checkout-unverified" as const; + } try { - let { owner } = await resolveOwner( - context.auth, - context.repository, - room, - context.claimantSessionId, - ); - if (owner.user.id !== user.id) return "planner-owner-unavailable" as const; + await resolveOwner(context.auth, context.repository, room, context.claimantSessionId); } catch { return "planner-owner-unavailable" as const; } @@ -501,6 +519,7 @@ export async function invoke( throw error; } if (chat.closed) return "planner-unavailable" as const; + if (verified) rememberCheckout(room, verified); announce(server, room, entry); if (chat.busy) { chat.waiting.push({ @@ -511,7 +530,6 @@ export async function invoke( posted: true, sessionId: context.claimantSessionId, userId: user.id, - checkout: context.checkout, invokedBy: user.id, }); queued(chat, server, room); @@ -811,7 +829,7 @@ function currentMemberRequest(chat: Chat): ActiveMemberRequest | undefined { let active = chat.activeRequest; if ( !active || chat.closed || !chat.busy || chat.lifecycle !== active.lifecycle - || chat.turn?.id !== active.turnId || !active.userId || !active.claimantSessionId + || chat.turn?.id !== active.turnId || !active.userId ) return undefined; let entry = chat.entries.find(value => value.id === active.entryId); if ( @@ -955,7 +973,7 @@ export function consumeBootstrapBackscroll(chat: Chat): void { async function repositorySession( context: Room, - claimantSessionId: string, + claimantSessionId: string | undefined, currentEntryId?: string, currentReferences: Wire.Reference[] = [], ): Promise<{ session: PlannerSession; binding: ActiveOwnerBinding }> { @@ -965,9 +983,6 @@ async function repositorySession( context.room, claimantSessionId, ); - if (context.invokedBy && owner.user.id !== context.invokedBy) { - throw new Error("The local Planner owner changed. Invoke the Planner again after signing in."); - } if (context.ownerAvailable) void context.ownerAvailable().catch(() => {}); let { chat, auth } = context; let lifecycle = chat.lifecycle; @@ -1011,15 +1026,14 @@ async function repositorySession( let result = await open(binding, { room: documentRoom(context), repository, - instructions: checkout => + instructions: workspace => plannerInstructions( `${repository.owner}/${repository.name}`, bootstrap, - checkout, + workspace, ), model: context.config.model, - atomicPlanner: context.config.atomicPlanner, - checkout: context.checkout, + harness: context.config.harness, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); opened = result.value; @@ -1051,19 +1065,23 @@ async function repositorySession( } } +/** + * The channel's Planner owner, claimed for the claimant when it has none. + * Without a claimant only an owner the channel already has will do. + */ export async function resolveOwner( auth: HostedAuth, repository: HostedRepository, channelId: string, - claimantSessionId: string, + claimantSessionId: string | undefined, ) { - let ownership = await auth.storage.channels.claimAgentOwner( - channelId, - claimantSessionId, - new Date(), - ); - let ownerSessionId = ownership.ownerSessionId; - if (!ownerSessionId) throw new Error("This channel's Copilot owner is unavailable."); + let ownership = claimantSessionId + ? await auth.storage.channels.claimAgentOwner(channelId, claimantSessionId, new Date()) + : (await auth.storage.channels.readAgent(channelId, new Date()))?.agent; + let ownerSessionId = ownership?.ownerSessionId; + if (!ownership || !ownerSessionId) { + throw new Error("This channel's Copilot owner is unavailable."); + } let owner = await auth.sessions.resolve(ownerSessionId); if (!owner) { throw new Error("The Copilot owner must sign in again or reset this channel's agent."); @@ -1104,7 +1122,7 @@ async function run( handle: string, text: string, thread: string | undefined, - claimantSessionId: string, + claimantSessionId: string | undefined, reserved = false, member?: MemberRequest, references: Wire.Reference[] = [], @@ -1309,11 +1327,11 @@ async function run( let entry = next.message ? chat.entries.find(item => item.id === nextId) : undefined; if (entry && !next.posted) announce(server, room, entry); await run( - { ...context, checkout: next.checkout, invokedBy: next.invokedBy }, + { ...context, invokedBy: next.invokedBy }, next.handle, next.text, next.thread, - next.sessionId ?? context.claimantSessionId, + next.invokedBy ? next.sessionId : next.sessionId ?? context.claimantSessionId, false, next.message && next.userId ? { entryId: next.id, userId: next.userId } : undefined, next.references, @@ -1332,7 +1350,7 @@ function startRun( handle: string, text: string, thread: string | undefined, - claimantSessionId: string, + claimantSessionId: string | undefined, reserved = false, member?: MemberRequest, references?: Wire.Reference[], diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index e2395d8db..614aebb66 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,55 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { delimiter } from "node:path"; -import { describe as description, load } from "./config"; - -const REQUIRED = { - STORAGE_DRIVER: "postgres", - DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", - APP_ORIGIN: "https://chopin.example", - GITHUB_APP_SLUG: "chopin-test", - GITHUB_APP_CLIENT_ID: "client-id", - GITHUB_APP_CLIENT_SECRET: "client-secret", - SESSION_ENCRYPTION_KEY: "11".repeat(32), -}; - -function configured(overrides: Record = {}) { - let env: Record = { - ...REQUIRED, - AGENT: undefined, - BACKGROUND_JOBS: undefined, - WEB_RESEARCH: undefined, - CONVERSATION_PLAN: undefined, - JEV_API_KEY: undefined, - JEV_MODEL: undefined, - JEV_TIMEOUT_MS: undefined, - TYPESAFE_API_KEY: undefined, - HARNESS: undefined, - HARNESS_AUTH: undefined, - AUTH_MODE: undefined, - ATOMIC_PLANNER: undefined, - ATOMIC_PLANNER_CHECKOUTS: undefined, - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: undefined, - SERVER_HOST: undefined, - PORT: undefined, - MODEL: undefined, - CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, - GITHUB_ALLOWED_USERS: undefined, - GITHUB_ALLOWED_ORGANIZATIONS: undefined, - ...overrides, - }; - let previous = { ...process.env }; - for (let [key, value] of Object.entries(env)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - try { - return load(); - } finally { - for (let key of Object.keys(env)) delete process.env[key]; - Object.assign(process.env, previous); - } -} +import { describe as description } from "./config"; +import { configured, LOCAL, REQUIRED } from "./testing/config"; describe("configuration", () => { it("loads the mandatory GitHub and PostgreSQL services without printing secrets", () => { @@ -135,87 +87,25 @@ describe("configuration", () => { }); }); - it("allows full Atomic Planner only in local atomic mode, with either auth mode", () => { - let full = { - ATOMIC_PLANNER: "full", - AUTH_MODE: "local", - HARNESS: "atomic", - MODEL: "stub/model", - APP_ORIGIN: "http://localhost:8790", - PORT: "8790", - }; - for (let auth of ["auto", "ai-gateway"]) { - expect(configured({ ...full, HARNESS_AUTH: auth }).atomicPlanner).toEqual({ - checkouts: [], - }); - } - expect(configured().atomicPlanner).toBeUndefined(); - for (let mode of ["isolated", "FULL", " "]) { - expect(() => configured({ ...full, ATOMIC_PLANNER: mode })).toThrow("ATOMIC_PLANNER"); - } - for (let auth of [undefined, "hosted"]) { - expect(() => configured({ ...full, AUTH_MODE: auth })).toThrow("AUTH_MODE=local"); - } - for (let harness of [undefined, "pi", "copilot-sdk"]) { - expect(() => configured({ ...full, HARNESS: harness })).toThrow("HARNESS=atomic"); - } - }); - - it("validates checkout paths and an optional per-request input timeout", () => { - let full = { - ATOMIC_PLANNER: "full", - AUTH_MODE: "local", - HARNESS: "atomic", - MODEL: "stub/model", - APP_ORIGIN: "http://localhost:8790", - PORT: "8790", - }; - expect( - configured({ - ...full, - ATOMIC_PLANNER_CHECKOUTS: ["/tmp/one", "/tmp/two", "/tmp/one"].join(delimiter), - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "1234", - }).atomicPlanner, - ).toEqual({ checkouts: ["/tmp/one", "/tmp/two", "/tmp/one"], inputTimeoutMs: 1234 }); - for (let paths of ["relative", ["/tmp/one", "relative"].join(delimiter)]) { - expect(() => configured({ ...full, ATOMIC_PLANNER_CHECKOUTS: paths })).toThrow("absolute"); - } - for (let timeout of ["0", "-1", "1.5", "oops", "10ms", "2147483648"]) { - expect(() => configured({ ...full, ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout })).toThrow( - "ATOMIC_PLANNER_INPUT_TIMEOUT_MS", + it("chooses a full Atomic Planner by harness alone, in hosted and local configuration", () => { + let atomic = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + for (let mode of [{}, LOCAL]) { + let config = configured({ ...atomic, ...mode }); + expect(config.harness).toBe("atomic"); + expect(description(config)).toContain( + "Planner: full Atomic session (shell and filesystem access as this process's user)", ); } - }); - - it("ignores Atomic Planner settings outside full mode and treats empty values as unset", () => { - for (let planner of [undefined, ""]) { - for (let [checkouts, timeout] of [["", ""], ["relative", "oops"]]) { - expect( - configured({ - ATOMIC_PLANNER: planner, - ATOMIC_PLANNER_CHECKOUTS: checkouts, - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: timeout, - }).atomicPlanner, - ).toBeUndefined(); + for ( + let isolated of [ + {}, + { HARNESS: "pi", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }, + ] + ) { + for (let mode of [{}, LOCAL]) { + expect(description(configured({ ...isolated, ...mode }))).not.toContain("full Atomic"); } } - let full = { - ATOMIC_PLANNER: "full", - AUTH_MODE: "local", - HARNESS: "atomic", - MODEL: "stub/model", - APP_ORIGIN: "http://localhost:8790", - PORT: "8790", - }; - for (let checkouts of [undefined, ""]) { - expect( - configured({ - ...full, - ATOMIC_PLANNER_CHECKOUTS: checkouts, - ATOMIC_PLANNER_INPUT_TIMEOUT_MS: "", - }).atomicPlanner, - ).toEqual({ checkouts: [] }); - } }); it("requires a valid PostgreSQL URL", () => { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3d9037069..537b235f7 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,7 +6,6 @@ * than a puzzling behaviour three screens later. */ -import { delimiter, isAbsolute } from "node:path"; import { loadAuth } from "./auth/config"; import type { AuthConfig } from "./auth/config"; @@ -19,7 +18,6 @@ export type Config = { model: string; harness: string; harnessAuth: string | undefined; - atomicPlanner?: { checkouts: string[]; inputTimeoutMs?: number }; /** * Whether to run the agent at all. * @@ -65,40 +63,12 @@ function model(harness: string): string { } return DEFAULT_MODEL; } -export function harnessSelection(): Pick< - Config, - "host" | "harness" | "harnessAuth" | "atomicPlanner" -> { - let full = process.env.ATOMIC_PLANNER; - if (full && full !== "full") throw new Error("ATOMIC_PLANNER must be full or unset"); - if (full && (process.env.AUTH_MODE !== "local" || process.env.HARNESS !== "atomic")) { - throw new Error("ATOMIC_PLANNER=full requires AUTH_MODE=local and HARNESS=atomic"); - } - let selection = { +export function harnessSelection(): Pick { + return { host: process.env.SERVER_HOST || "127.0.0.1", harness: process.env.HARNESS || "copilot-sdk", harnessAuth: process.env.HARNESS_AUTH || undefined, }; - if (!full) return selection; - let checkouts = (process.env.ATOMIC_PLANNER_CHECKOUTS || undefined)?.split(delimiter) ?? []; - if (checkouts.some(path => !isAbsolute(path))) { - throw new Error( - "ATOMIC_PLANNER_CHECKOUTS must contain absolute paths separated by the platform path delimiter", - ); - } - let rawTimeout = process.env.ATOMIC_PLANNER_INPUT_TIMEOUT_MS || undefined; - let inputTimeoutMs = rawTimeout === undefined ? undefined : Number(rawTimeout); - if ( - inputTimeoutMs !== undefined - && (!Number.isSafeInteger(inputTimeoutMs) || inputTimeoutMs < 1 - || inputTimeoutMs > 2_147_483_647) - ) { - throw new Error("ATOMIC_PLANNER_INPUT_TIMEOUT_MS must be an integer between 1 and 2147483647"); - } - return { - ...selection, - atomicPlanner: { checkouts, ...(inputTimeoutMs === undefined ? {} : { inputTimeoutMs }) }, - }; } function port(): number { @@ -187,7 +157,9 @@ export function describe(config: Config): string { config.devClient ? `client: vite (${config.devClient})` : "client: built", config.agent ? `agent: ${config.model} (on demand)` : "agent: off", `harness: ${config.harness}`, - ...(config.atomicPlanner ? ["Atomic Planner: full (local shell and filesystem access)"] : []), + ...(config.harness === "atomic" + ? ["Planner: full Atomic session (shell and filesystem access as this process's user)"] + : []), config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", config.conversationPlan diff --git a/apps/server/src/conversation-plan/job-context.ts b/apps/server/src/conversation-plan/job-context.ts index b7515af54..4bd7d7138 100644 --- a/apps/server/src/conversation-plan/job-context.ts +++ b/apps/server/src/conversation-plan/job-context.ts @@ -154,7 +154,8 @@ export function createJobContexts() { ?? [...(byMessage?.values() ?? [])].findLast(item => item.claimantSessionId === owner); return context ? { context, claimantSessionId: owner } : undefined; } - return triggered + // An MCP instruction without a live login has no session to claim ownership with. + return triggered?.claimantSessionId ? { context: triggered, claimantSessionId: triggered.claimantSessionId } : undefined; }, diff --git a/apps/server/src/harness/atomic.contract.test.ts b/apps/server/src/harness/atomic.contract.test.ts index ea7514edb..1e3509d43 100644 --- a/apps/server/src/harness/atomic.contract.test.ts +++ b/apps/server/src/harness/atomic.contract.test.ts @@ -66,6 +66,10 @@ function createStubAtomic(overrides: Partial = {}) { harnessContract("atomic", createStubAtomic, createJustBashNetworkSandboxSession); +/** + * Sessions here are never registered as Planner sessions, so each is the + * isolated session the summary and research workers run in. + */ async function run(prompt: string, options: { structured?: boolean; instructions?: string; @@ -109,7 +113,7 @@ async function run(prompt: string, options: { } } -describe("atomic turns", () => { +describe("atomic worker turns", () => { it("answers a plain text turn with exactly the neutral system prompt and no tools", async () => { stub.requests.length = 0; let result = await run("plain"); @@ -351,7 +355,7 @@ describe("atomic HarnessV1 lifecycle", () => { }); }); -describe("atomic structured-output result tool", () => { +describe("atomic worker structured-output result tool", () => { it("ends a structured turn on the terminating tool without a follow-up model request", async () => { stub.requests.length = 0; let result = await run("output", { structured: true }); @@ -387,7 +391,7 @@ describe("atomic structured-output result tool", () => { }); }); -describe("atomic host isolation and model resolution", () => { +describe("atomic worker host isolation and model resolution", () => { async function snapshot(root: string): Promise> { let files = new Map(); for (let entry of await readdir(root, { recursive: true })) { diff --git a/apps/server/src/harness/atomic/adapter.test.ts b/apps/server/src/harness/atomic/adapter.test.ts index 0e4fd3a52..8e35994eb 100644 --- a/apps/server/src/harness/atomic/adapter.test.ts +++ b/apps/server/src/harness/atomic/adapter.test.ts @@ -37,7 +37,8 @@ function fakeExtensionApi(policy: TurnPolicy) { get active() { return active; }, - start: () => handlers.get("before_agent_start")!({ type: "before_agent_start" }), + start: (systemPrompt?: string) => + handlers.get("before_agent_start")!({ type: "before_agent_start", systemPrompt }), call: (toolName: string) => handlers.get("tool_call")!({ type: "tool_call", toolName }), }; } @@ -51,7 +52,7 @@ test("registers one result tool that terminates the turn and records its argumen expect(policy.result).toEqual({ value: { answer: "yes" } }); }); -test("applies the turn's tools and exact system prompt when the agent starts", () => { +test("applies a worker turn's tools and exact system prompt when the agent starts", () => { let policy: TurnPolicy = { hostNames: ["first_host"], structured: true, systemPrompt: "Plan." }; let atomic = fakeExtensionApi(policy); expect(atomic.start()).toEqual({ systemPrompt: "Plan." }); @@ -63,7 +64,7 @@ test("applies the turn's tools and exact system prompt when the agent starts", ( expect(atomic.active).toEqual(["first_host"]); }); -test("blocks the result tool off structured turns and anything outside the turn", () => { +test("blocks, for a worker, the result tool off structured turns and anything outside the turn", () => { let policy: TurnPolicy = { hostNames: ["first_host"], structured: false, systemPrompt: "S." }; let atomic = fakeExtensionApi(policy); expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toMatchObject({ block: true }); @@ -73,6 +74,23 @@ test("blocks the result tool off structured turns and anything outside the turn" expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toBeUndefined(); }); +test("a Planner turn keeps Atomic's tools and prompt but still refuses the result tool on plain turns", () => { + let policy: TurnPolicy = { + full: true, + hostNames: ["first_host"], + structured: false, + systemPrompt: "Plan.", + }; + let atomic = fakeExtensionApi(policy); + expect(atomic.start("Atomic.")).toEqual({ systemPrompt: "Atomic.\n\nPlan." }); + expect(atomic.active).toEqual([]); + expect(atomic.call("bash")).toBeUndefined(); + expect(atomic.call("first_host")).toBeUndefined(); + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toMatchObject({ block: true }); + policy.structured = true; + expect(atomic.call(ATOMIC_RESULT_TOOL_NAME)).toBeUndefined(); +}); + test("builds the system prompt from instructions and the structured response format", () => { expect(turnSystemPrompt(undefined, undefined)).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); expect(turnSystemPrompt("", { type: "text" })).toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); @@ -118,7 +136,7 @@ test("resolves provider/model exactly and never substitutes a default", () => { .toThrow("HARNESS_AUTH ai-gateway allows only vercel-ai-gateway models, not anthropic/claude"); }); -test("reports every way a built session exceeds the turn", () => { +test("reports every way a built worker session exceeds the turn", () => { function session(options: { tools?: string[]; agentsFiles?: number; diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index 9ea63be7c..3df1c50c0 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -1,9 +1,11 @@ /** * Chopin's Atomic `HarnessV1` adapter. * - * Sessions are isolated by default: only Chopin host tools, no host resources, - * and a checked per-turn tool boundary. A registered local full Planner uses - * its verified checkout and Atomic resources instead, with Chopin as HostInput. + * A Planner session that Chopin registers by session ID runs as a full Atomic + * session: Atomic builtins and coding tools, the operator's Atomic resources, a + * channel working directory, and Chopin as HostInput. Every other session (the + * summary and research workers) stays isolated: only Chopin host tools, no host + * resources, and a checked per-turn tool boundary. */ import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; @@ -90,7 +92,6 @@ export type AtomicSettings = { model?: string; /** Providers registered by code rather than discovered from the host. */ providers?: Record; - fullPlanner?: boolean; }; export class ToolBoundaryError extends Error { @@ -319,7 +320,7 @@ export function createAtomicAdapter( throw unsupported("resuming an in-memory session"); } let directory: string | undefined; - let full = settings.fullPlanner ? fullPlanner(startOptions.sessionId) : undefined; + let full = fullPlanner(startOptions.sessionId); let sessionManager: SessionManager | undefined; let settingsManager = SettingsManager.inMemory(SETTINGS); let policy: TurnPolicy = { diff --git a/apps/server/src/harness/atomic/checkout.test.ts b/apps/server/src/harness/atomic/checkout.test.ts index 8c90c900f..22b788f28 100644 --- a/apps/server/src/harness/atomic/checkout.test.ts +++ b/apps/server/src/harness/atomic/checkout.test.ts @@ -18,8 +18,7 @@ async function checkout(name: string, origin: string) { return path; } -test("verifies origin owner/name for URL, SSH and alias checkouts, preserving candidate priority", async () => { - let paths = []; +test("verifies origin owner/name for URL, SSH and alias checkouts, and nothing else", async () => { for ( let [index, origin] of [ "https://github.com/octo-org/score.git", @@ -29,12 +28,10 @@ test("verifies origin owner/name for URL, SSH and alias checkouts, preserving ca ].entries() ) { let path = await checkout(String(index), origin); - paths.push(path); - expect(await verifiedCheckout(repository, [path])).toBe(await realpath(path)); + expect(await verifiedCheckout(repository, path)).toBe(await realpath(path)); } - expect(await verifiedCheckout(repository, paths, paths[3])).toBe(paths[3]); let mismatch = await checkout("mismatch", "https://github.com/other/score.git"); - expect(await verifiedCheckout(repository, [mismatch, paths[1]!], mismatch)).toBe(paths[1]); - expect(await verifiedCheckout(repository, [mismatch, root, join(root, "missing")])) - .toBeUndefined(); + for (let path of [mismatch, root, join(root, "missing")]) { + expect(await verifiedCheckout(repository, path)).toBeUndefined(); + } }); diff --git a/apps/server/src/harness/atomic/checkout.ts b/apps/server/src/harness/atomic/checkout.ts index f8589a630..0a6f710b8 100644 --- a/apps/server/src/harness/atomic/checkout.ts +++ b/apps/server/src/harness/atomic/checkout.ts @@ -3,35 +3,30 @@ import { realpath } from "node:fs/promises"; /** Origin host names may be SSH aliases; the repository coordinates must match. */ export async function verifiedCheckout( repository: { owner: string; name: string }, - checkouts: string[], - preferred?: string, + candidate: string, ): Promise { - for (let candidate of preferred === undefined ? checkouts : [preferred, ...checkouts]) { - try { - let cwd = await realpath(candidate); - let git = async (...args: string[]) => { - let process = Bun.spawn(["git", "-C", cwd, ...args], { - stdout: "pipe", - stderr: "pipe", - }); - let output = await new Response(process.stdout).text(); - if (await process.exited !== 0) throw new Error("not a checkout"); - return output.trim(); - }; - if (await git("rev-parse", "--is-inside-work-tree") !== "true") continue; - let origin = await git("remote", "get-url", "origin"); - let path = origin.includes("://") - ? new URL(origin).pathname.replace(/^\//, "") - : /^[^/:]+:(.+)$/.exec(origin)?.[1]; - if ( - path?.replace(/\.git$/, "").toLowerCase() - === `${repository.owner}/${repository.name}`.toLowerCase() - ) { - return cwd; - } - } catch { - // A missing or unrelated checkout retains the isolated Planner boundary. - } + try { + let cwd = await realpath(candidate); + let git = async (...args: string[]) => { + let process = Bun.spawn(["git", "-C", cwd, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + let output = await new Response(process.stdout).text(); + if (await process.exited !== 0) throw new Error("not a checkout"); + return output.trim(); + }; + if (await git("rev-parse", "--is-inside-work-tree") !== "true") return undefined; + let origin = await git("remote", "get-url", "origin"); + let path = origin.includes("://") + ? new URL(origin).pathname.replace(/^\//, "") + : /^[^/:]+:(.+)$/.exec(origin)?.[1]; + return path?.replace(/\.git$/, "").toLowerCase() + === `${repository.owner}/${repository.name}`.toLowerCase() + ? cwd + : undefined; + } catch { + // A missing path or an unrelated checkout is simply not verified. + return undefined; } - return undefined; } diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 7dd8aa518..ace21b686 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -61,15 +61,24 @@ let stub = startStubModelServer((prompt, prior) => ); afterAll(stub.stop); -async function run(full: boolean, registered: boolean, prompt = "plain", host?: HostInput) { +async function run( + registered: boolean, + prompt = "plain", + { host, checkout = true, worker = false }: { + host?: HostInput; + /** False runs the Planner in an empty directory, as a channel without a checkout does. */ + checkout?: boolean; + /** Also run an unregistered worker session on the same harness while the Planner is open. */ + worker?: boolean; + } = {}, +) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); let agentDir = join(root, "agent"); - let cwd = join(root, "checkout"); + let cwd = join(root, checkout ? "checkout" : "empty"); let previous = process.env.ATOMIC_CODING_AGENT_DIR; let received: QuestionParams[] = []; let harness = createAtomicAdapter({ auth: "ai-gateway", - fullPlanner: full, model: "stub/model", providers: { stub: { @@ -109,8 +118,13 @@ async function run(full: boolean, registered: boolean, prompt = "plain", host?: join(agentDir, "extensions", "marker.ts"), `export default api => api.registerTool({name: "operator_tool", label: "Operator", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"marker"}], details:{}}; }});`, ); - let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { stdout: "pipe", stderr: "pipe" }); - expect(await git.exited).toBe(0); + if (checkout) { + let git = Bun.spawn(["git", "-C", cwd, "init", "--quiet"], { + stdout: "pipe", + stderr: "pipe", + }); + expect(await git.exited).toBe(0); + } process.env.ATOMIC_CODING_AGENT_DIR = agentDir; let humanInput: HostInput = host ?? { confirm: async () => false, @@ -151,7 +165,17 @@ async function run(full: boolean, registered: boolean, prompt = "plain", host?: let result = await agent.stream({ session, prompt }); await result.consumeStream(); await result.text; - return { cwd, received, requests: [...stub.requests] }; + let requests = [...stub.requests]; + if (!worker) return { cwd, received, requests, workerRequests: [] }; + let workerSession = await agent.createSession({ sandboxSession: sandbox }); + try { + stub.requests.length = 0; + let output = await agent.stream({ session: workerSession, prompt: "plain" }); + await output.consumeStream(); + return { cwd, received, requests, workerRequests: [...stub.requests] }; + } finally { + await workerSession.destroy(); + } } finally { await session.destroy(); } @@ -165,24 +189,24 @@ async function run(full: boolean, registered: boolean, prompt = "plain", host?: } } -test("verified full Planner sessions load operator resources and expose Atomic tools beside host tools", async () => { - let result = await run(true, true); +const FULL_TOOLS = [ + "read", + "bash", + "edit", + "write", + "ask_user_question", + "workflow", + "subagent", + "intercom", + "web_search", + "operator_tool", + "host_tool", +]; + +test("registered Planner sessions load operator resources and expose Atomic tools beside host tools", async () => { + let result = await run(true); let request = result.requests[0]!; - for ( - let name of [ - "read", - "bash", - "edit", - "write", - "ask_user_question", - "workflow", - "subagent", - "intercom", - "web_search", - "operator_tool", - "host_tool", - ] - ) expect(request.toolNames).toContain(name); + for (let name of FULL_TOOLS) expect(request.toolNames).toContain(name); expect(request.toolNames).not.toContain(ATOMIC_RESULT_TOOL_NAME); for ( let marker of [ @@ -195,21 +219,28 @@ test("verified full Planner sessions load operator resources and expose Atomic t expect(request.system).not.toBe(ATOMIC_DEFAULT_SYSTEM_PROMPT); }); -test("full sessions use the checkout cwd and route ask_user_question through HostInput", async () => { - let cwd = await run(true, true, "cwd"); +test("Planner sessions use their checkout cwd and route ask_user_question through HostInput", async () => { + let cwd = await run(true, "cwd"); expect(cwd.requests.at(-1)!.toolResults.join("\n")).toContain(cwd.cwd); - let question = await run(true, true, "question"); + let question = await run(true, "question"); expect(question.received).toEqual([params]); expect(question.requests.at(-1)!.toolResults.join("\n")).toContain("Second"); - let prompt = await run(true, true, "/marker"); + let prompt = await run(true, "/marker"); expect(prompt.requests[0]!.prompt).toBe("OPERATOR-PROMPT-MARKER"); }); +test("a Planner session without a checkout is just as full in its empty working directory", async () => { + let result = await run(true, "cwd", { checkout: false }); + for (let name of FULL_TOOLS) expect(result.requests[0]!.toolNames).toContain(name); + expect(result.requests[0]!.system).toContain(result.cwd); + expect(result.requests.at(-1)!.toolResults.join("\n")).toContain(result.cwd); +}); + test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { let room = await hostInputRoom(); try { for (let prompt of ["multiple", "preview"]) { - let turn = run(true, true, prompt, room.input); + let turn = run(true, prompt, { host: room.input }); let [card] = await room.cards(1); await room.answer(card!.id, ` typed ${prompt}\n`); let output = (await turn).requests.at(-1)!.toolResults.join("\n"); @@ -221,10 +252,13 @@ test("free-text Decisions answers to multi-select and preview questions reach th } }); -test("workers without a verified registration and default-mode sessions stay isolated", async () => { - for (let [full, registered] of [[true, false], [false, true]]) { - let result = await run(full!, registered!); - expect(result.requests[0]!.toolNames).toEqual(["host_tool"]); - expect(result.requests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); - } +test("worker sessions stay isolated, even beside a full Planner session on the same harness", async () => { + let alone = await run(false); + expect(alone.requests[0]!.toolNames).toEqual(["host_tool"]); + expect(alone.requests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); + let beside = await run(true, "plain", { worker: true }); + expect(beside.requests[0]!.toolNames).toContain("bash"); + expect(beside.workerRequests).toHaveLength(1); + expect(beside.workerRequests[0]!.toolNames).toEqual(["host_tool"]); + expect(beside.workerRequests[0]!.system).toBe("CHOPIN-INSTRUCTIONS-MARKER"); }); diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts index 458a84cb8..fc79c5a56 100644 --- a/apps/server/src/harness/atomic/full.ts +++ b/apps/server/src/harness/atomic/full.ts @@ -3,7 +3,7 @@ import type { HostInput } from "@bastani/atomic"; export type FullPlanner = { cwd: string; humanInput: HostInput }; let planners = new Map(); -/** Only a verified Planner session is registered; background workers are not. */ +/** Every Atomic Planner session is registered; background workers never are. */ export function registerFullPlanner(sessionId: string, planner: FullPlanner): () => void { if (planners.has(sessionId)) throw new Error("Atomic Planner session is already registered"); planners.set(sessionId, planner); diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index e7f3926c3..f7fadc767 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test } from "bun:test"; import { $nodesOfType } from "lexical"; -import { limits, QuestionnaireNode } from "@chopin/dialect"; +import { cardStatus, limits, QuestionnaireNode } from "@chopin/dialect"; +import { limits as questionLimits } from "@chopin/question"; import * as Questions from "../../questions/service"; import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; @@ -14,8 +15,8 @@ afterEach(async () => { for (let cleanup of cleanups.splice(0)) await cleanup(); }); -async function fixture(timeoutMs?: number) { - let f = await hostInputRoom(timeoutMs); +async function fixture(expiresInMs?: number) { + let f = await hostInputRoom(expiresInMs); cleanups.push(f.close); return f; } @@ -223,25 +224,90 @@ test("abort withdraws only its request's open cards, persists cancellation, and expect(saved.source).not.toContain(cards[1]!.id); }); -test("timeout withdraws unanswered dialogs and returns no answer for every HostInput method", async () => { +test("expiry keeps every unanswered card in Decisions, marked expired, and answers no HostInput method", async () => { let f = await fixture(30); for ( - let invoke of [ - () => f.input.questionnaire(params, f.options), - () => f.input.confirm("Confirm", "Proceed?", f.options), - () => f.input.select("Select", ["A", "B"], f.options), - () => f.input.input("Input", undefined, f.options), - () => f.input.editor("Editor", undefined, f.options), - ] + let [invoke, expected] of [ + [() => f.input.questionnaire(params, f.options), { answers: [], cancelled: true }], + [() => f.input.confirm("Confirm", "Proceed?", f.options), false], + [() => f.input.select("Select", ["A", "B"], f.options), undefined], + [() => f.input.input("Input", undefined, f.options), undefined], + [() => f.input.editor("Editor", undefined, f.options), undefined], + ] as Array<[() => Promise, unknown]> ) { - let result = await invoke(); - expect( - result === undefined || result === false - || (typeof result === "object" && result.cancelled && result.answers.length === 0), - ).toBe(true); + expect(await invoke()).toEqual(expected); expect(Store.outstanding(f.plan.questions)).toHaveLength(0); } - expect([...f.plan.records.values()].every(record => record.status === "cancelled")).toBe(true); + let records = [...f.plan.records.values()]; + expect(records).toHaveLength(7); + for (let record of records) { + expect(record).toMatchObject({ status: "expired", resolver: "chopin" }); + expect(record.at).toBeNumber(); + expect(f.frames).toContainEqual( + expect.objectContaining({ + kind: "question:resolved", + id: record.id, + status: "expired", + resolver: "chopin", + }), + ); + } + let saved = await Plan.readStored((await f.storage.collaboration.load(f.room.id, new Date()))!); + let cards = await documentCards(saved.source); + expect(cards.map(card => card.id)).toEqual(records.map(record => record.id)); + for (let card of cards) { + expect(card.status).toBe("expired"); + expect(card.at).toBeString(); + expect(card.by).toBeUndefined(); + expect(cardStatus(card)).toBe("expired"); + } + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + ts: 0, + rid: "late", + id: records[0]!.id, + revision: 0, + }); + expect(f.frames.findLast(frame => frame.rid === "late")).toMatchObject({ + ok: false, + reason: "resolved", + status: "expired", + resolver: "chopin", + }); + expect(f.plan.records.get(records[0]!.id)?.status).toBe("expired"); +}); + +test("an abort still withdraws its cards rather than expiring them", async () => { + let f = await fixture(60_000); + let response = f.input.confirm("Confirm", "Proceed?", f.options); + let [card] = await f.cards(1); + f.controller.abort(); + expect(await response).toBe(false); + expect(f.plan.records.get(card!.id)).toMatchObject({ status: "cancelled", resolver: "chopin" }); + expect(Plan.source(f.plan)).not.toContain(card!.id); + expect(f.frames.filter(frame => frame.kind === "question:resolved")).toMatchObject([ + { kind: "question:resolved", id: card!.id, status: "cancelled", resolver: "chopin" }, + ]); +}); + +test("host input waits the shared 30-minute limit before expiring by default", async () => { + let f = await fixture(); + let delays: number[] = []; + let original = globalThis.setTimeout; + globalThis.setTimeout = ((handler: () => void, delay?: number, ...rest: unknown[]) => { + if (delay !== undefined) delays.push(delay); + return original(handler, delay, ...rest); + }) as typeof setTimeout; + try { + let response = f.input.confirm("Confirm", "Proceed?", f.options); + await f.cards(1); + expect(questionLimits.INPUT_EXPIRY_MS).toBe(30 * 60 * 1_000); + expect(delays).toContain(questionLimits.INPUT_EXPIRY_MS); + f.controller.abort(); + expect(await response).toBe(false); + } finally { + globalThis.setTimeout = original; + } }); test("workflow identity labels each card while returned question text remains verbatim", async () => { @@ -303,17 +369,21 @@ test("blank dialog titles and supplied empty choices remain valid verbatim input }); /** Re-import the canonical document, proving the dialect accepts every card it holds. */ -async function documentQuestionnaires(source: string) { +async function documentCards(source: string) { let document = await Room.create(source); try { return document.editor.getEditorState().read(() => - $nodesOfType(QuestionnaireNode).map(node => node.getQuestionnaire().questions[0]!) + $nodesOfType(QuestionnaireNode).map(node => node.getQuestionnaire()) ); } finally { document.doc.destroy(); } } +async function documentQuestionnaires(source: string) { + return (await documentCards(source)).map(card => card.questions[0]!); +} + test("dialogs beyond the Planner's per-field limits become verbatim cards and answers", async () => { let f = await fixture(); let initial = "Keep this line of the plan.\n".repeat(180); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index 1f02910d0..23de5d998 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -1,3 +1,5 @@ +import { limits } from "@chopin/question"; + import * as Questions from "../../questions/service"; import type { @@ -9,8 +11,16 @@ import type { } from "@bastani/atomic"; import type { DocumentRoom } from "../../agent/tools"; -/** Atomic owns input requests; Chopin owns their shared decision records. */ -export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostInput { +/** + * Atomic owns input requests; Chopin owns their shared decision records. + * + * A request nobody answers within `expiresInMs` expires: its cards stay in + * Decisions marked expired, and Atomic gets no answer. + */ +export function createHumanInput( + room: DocumentRoom, + expiresInMs = limits.INPUT_EXPIRY_MS, +): HostInput { async function questionnaire( params: QuestionParams, options: HostInputOptions, @@ -31,24 +41,19 @@ export function createHumanInput(room: DocumentRoom, timeoutMs?: number): HostIn multiple: question.multiSelect ?? false, })), }, { verbatim: true }); - let deadline = new AbortController(); - let signal = AbortSignal.any([options.signal, deadline.signal]); - let timer = timeoutMs === undefined ? undefined : setTimeout(() => deadline.abort(), timeoutMs); - let ended; - try { - ended = await Questions.ask( - room.plan, - room.server, - room.id, - definition, - undefined, - room.anchors, - signal, - ); - } finally { - clearTimeout(timer); + let ended = await Questions.ask( + room.plan, + room.server, + room.id, + definition, + undefined, + room.anchors, + options.signal, + expiresInMs, + ); + if (options.signal.aborted || ended.some(outcome => outcome.status === "expired")) { + return { answers: [], cancelled: true }; } - if (signal.aborted) return { answers: [], cancelled: true }; let answers: QuestionAnswer[] = []; ended.forEach((outcome, questionIndex) => { if (outcome.status !== "answered") return; diff --git a/apps/server/src/harness/atomic/workspace.ts b/apps/server/src/harness/atomic/workspace.ts new file mode 100644 index 000000000..a7e657726 --- /dev/null +++ b/apps/server/src/harness/atomic/workspace.ts @@ -0,0 +1,67 @@ +/** + * Where a channel's Atomic Planner works. + * + * A checkout is only ever supplied through `invoke_planner`, verified against + * the channel's repository, and remembered for the channel until the process + * exits. Every later Planner session for that channel re-verifies it before use. + * Without one, the session gets a directory Chopin created empty for that + * channel alone; it keeps whatever the Planner writes there until shutdown. + */ + +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { verifiedCheckout } from "./checkout"; + +export type PlannerWorkspace = { + cwd: string; + /** True when `cwd` is a verified checkout rather than the empty channel directory. */ + checkout: boolean; +}; + +let checkouts = new Map(); +let directories = new Map>(); + +export function rememberCheckout(channelId: string, checkout: string): void { + checkouts.set(channelId, checkout); +} + +export async function plannerWorkspace( + channelId: string, + repository: { owner: string; name: string }, +): Promise { + let remembered = checkouts.get(channelId); + let checkout = remembered === undefined + ? undefined + : await verifiedCheckout(repository, remembered); + if (checkout) return { cwd: checkout, checkout: true }; + return { cwd: await directory(channelId), checkout: false }; +} + +/** + * Chained per channel so concurrent sessions share one directory. `mkdtemp` + * creates it with mode 0700; one removed from under a running server is + * replaced rather than recreated at a name somebody else could have taken. + */ +function directory(channelId: string): Promise { + let previous = directories.get(channelId); + let next = (async () => { + let path = await previous?.catch(() => undefined); + if (path && await stat(path).then(found => found.isDirectory(), () => false)) return path; + return mkdtemp(join(tmpdir(), "chopin-planner-")); + })(); + directories.set(channelId, next); + return next; +} + +export async function removeWorkspaces(): Promise { + let paths = await Promise.all( + [...directories.values()].map(path => path.catch(() => undefined)), + ); + directories.clear(); + checkouts.clear(); + await Promise.all( + paths.map(path => path && rm(path, { recursive: true, force: true }).catch(() => {})), + ); +} diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index 54ca0c19f..44228f29b 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -1,10 +1,10 @@ import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; +import { removeWorkspaces } from "./atomic/workspace"; import { createCopilotSdk } from "./copilot-sdk/adapter"; import { createPiAdapter } from "./pi/adapter"; import type { HarnessV1 } from "@ai-sdk/harness"; import type { AtomicAuthMode } from "./atomic/adapter"; -import type { Config } from "../config"; const credentials = new Map string | undefined; @@ -18,13 +18,10 @@ function createPiHarness(settings: { auth?: string }): HarnessV1 & { shutdown(): } function createAtomicHarness( - settings: { auth?: string; fullPlanner?: boolean }, + settings: { auth?: string }, ): HarnessV1 & { shutdown(): Promise } { // Like Pi, Atomic takes neither a GitHub credential nor a credit limit. - return createAtomicAdapter({ - auth: settings.auth as AtomicAuthMode, - fullPlanner: settings.fullPlanner, - }); + return createAtomicAdapter({ auth: settings.auth as AtomicAuthMode }); } export const harnesses = { @@ -37,7 +34,6 @@ export const harnesses = { credentials: (id: string) => string | undefined; limits: (id: string) => { maxAiCredits: number } | undefined; auth?: string; - fullPlanner?: boolean; }) => HarnessV1 >; @@ -67,7 +63,6 @@ export function harnessFor(config: { harness: string; harnessAuth?: string; host?: string; - atomicPlanner?: Config["atomicPlanner"]; }): HarnessV1 { if (!Object.hasOwn(harnesses, config.harness)) { throw new Error(`Unknown harness: ${config.harness}`); @@ -99,7 +94,6 @@ export function harnessFor(config: { return maxAiCredits === undefined ? undefined : { maxAiCredits }; }, auth, - fullPlanner: !!config.atomicPlanner, }); } @@ -119,4 +113,5 @@ export async function shutdownHarnesses(): Promise { credentials.clear(); await selected?.shutdown(); selected = undefined; + await removeWorkspaces(); } diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index 35b6e3533..0b198d26e 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from "bun:test"; -import { openPlannerSession } from "./session"; -import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readdir, realpath, rm, stat } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { openPlannerSession } from "./session"; import { fullPlanner } from "./atomic/full"; +import { rememberCheckout, removeWorkspaces } from "./atomic/workspace"; import { plannerInstructions } from "../agent/planner"; import type { ActiveOwnerBinding } from "../agent/active-owner"; @@ -115,52 +116,105 @@ describe("openPlannerSession", () => { expect(result).toMatchObject({ ok: false, error: { kind: "Timeout" } }); expect(destroyed).toEqual({ sandbox: 1, session: 0, unregistered: 1 }); }); - it("registers full Planner input only for verified checkouts and releases it with the session", async () => { - let cwd = await mkdtemp(join(tmpdir(), "chopin-planner-session-")); - try { - for ( - let args of [["init", "--quiet"], ["remote", "add", "origin", "workgit:owner/repo.git"]] - ) { - expect( - await Bun.spawn(["git", "-C", cwd, ...args], { stdout: "pipe", stderr: "pipe" }).exited, - ).toBe(0); - } - for (let checkouts of [[cwd], []]) { - let { owner, channel, deps } = fixture(); - let sessionId = ""; - let instructions = ""; - deps.agent = { - createSession: async (options: { sessionId: string }) => { - sessionId = options.sessionId; - return { destroy: async () => {} }; - }, - stream: async (call: { options: { instructions: string } }) => { - instructions = call.options.instructions; - }, - } as never; - let opened = await openPlannerSession(owner, { - ...channel, - atomicPlanner: { checkouts }, - instructions: checkout => plannerInstructions("owner/repo", "BOOTSTRAP", checkout), - }, deps); - expect(opened.ok).toBe(true); - if (!opened.ok) throw new Error("Planner unavailable"); - await opened.value.stream("prompt", new AbortController().signal); - expect(instructions).toContain("BOOTSTRAP"); - if (checkouts.length) { - expect(fullPlanner(sessionId)?.cwd).toBe(cwd); - expect(fullPlanner(sessionId)?.humanInput.questionnaire).toBeFunction(); - expect(instructions).toContain(cwd); - expect(instructions).not.toContain("You have no shell"); - } else { - expect(fullPlanner(sessionId)).toBeUndefined(); - expect(instructions).toContain("You have no shell"); - } - await opened.value.destroy(); - expect(fullPlanner(sessionId)).toBeUndefined(); - } - } finally { - await rm(cwd, { recursive: true, force: true }); +}); + +describe("Planner workspaces", () => { + let roots: string[] = []; + afterEach(async () => { + await removeWorkspaces(); + for (let root of roots.splice(0)) await rm(root, { recursive: true, force: true }); + }); + + async function checkout(origin: string): Promise { + let root = await mkdtemp(join(tmpdir(), "chopin-planner-checkout-")); + roots.push(root); + for (let args of [["init", "--quiet"], ["remote", "add", "origin", origin]]) { + expect( + await Bun.spawn(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }).exited, + ).toBe(0); + } + return realpath(root); + } + + /** Open and stream one Planner session, observing what the harness would see. */ + async function open(channelId: string, harness?: string) { + let { owner, channel, deps } = fixture(); + let sessionId = ""; + let instructions = ""; + let registered: ReturnType; + deps.agent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + registered = fullPlanner(sessionId); + }, + } as never; + let opened = await openPlannerSession(owner, { + ...channel, + room: { id: channelId, plan: {}, server: {} } as never, + harness, + instructions: workspace => plannerInstructions("owner/repo", "BOOTSTRAP", workspace), + }, deps); + if (!opened.ok) throw new Error("Planner unavailable"); + await opened.value.stream("prompt", new AbortController().signal); + await opened.value.destroy(); + expect(fullPlanner(sessionId)).toBeUndefined(); + expect(instructions).toContain("BOOTSTRAP"); + return { registered: registered!, instructions }; + } + + it("reuses a channel's remembered checkout for every later atomic session while it verifies", async () => { + let path = await checkout("workgit:owner/repo.git"); + rememberCheckout("channel", path); + for (let attempt of [1, 2]) { + let { registered, instructions } = await open("channel", "atomic"); + expect({ attempt, cwd: registered?.cwd }).toEqual({ attempt, cwd: path }); + expect(registered?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(`${path}, is a local checkout of owner/repo`); + expect(instructions).toContain("This is a full Atomic session"); + expect(instructions).not.toContain("You have no shell"); + } + let git = Bun.spawn(["git", "-C", path, "remote", "set-url", "origin", "workgit:other/repo"], { + stdout: "pipe", + stderr: "pipe", + }); + expect(await git.exited).toBe(0); + let fallback = await open("channel", "atomic"); + expect(fallback.registered?.cwd).not.toBe(path); + expect(await readdir(fallback.registered!.cwd)).toEqual([]); + expect(fallback.instructions).toContain("scratch directory Chopin created"); + }); + + it("gives each atomic channel without a checkout its own empty, private directory", async () => { + let first = await open("first", "atomic"); + let again = await open("first", "atomic"); + let second = await open("second", "atomic"); + expect(again.registered?.cwd).toBe(first.registered!.cwd); + expect(second.registered?.cwd).not.toBe(first.registered!.cwd); + for (let { registered, instructions } of [first, second]) { + let cwd = registered!.cwd; + expect((await stat(cwd)).mode & 0o777).toBe(0o700); + expect(await readdir(cwd)).toEqual([]); + expect(registered?.humanInput.questionnaire).toBeFunction(); + expect(instructions).toContain(`${cwd}, is a scratch directory Chopin created`); + expect(instructions).toContain("holds no repository files"); + expect(instructions).toContain("`read_repository_file`"); + expect(instructions).toContain("This is a full Atomic session"); + } + await removeWorkspaces(); + expect(await stat(first.registered!.cwd).catch(() => undefined)).toBeUndefined(); + }); + + it("keeps copilot-sdk and pi Planner sessions isolated, whatever the channel remembers", async () => { + rememberCheckout("channel", await checkout("workgit:owner/repo.git")); + for (let harness of ["copilot-sdk", "pi", undefined]) { + let { registered, instructions } = await open("channel", harness); + expect(registered).toBeUndefined(); + expect(instructions).toContain("You have no shell"); + expect(instructions).not.toContain("full Atomic session"); } }); }); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index 572d3d7e4..d9ff4431c 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -3,15 +3,14 @@ import { createJustBashNetworkSandboxSession } from "@ai-sdk/sandbox-just-bash"; import { headingPlannerAgent, plannerAgent, prosePlannerAgent, refinePlannerAgent } from "./agents"; import { githubTools, type GitHubToolsError, type Result } from "./github-tools"; import { registerCredential } from "./harnesses"; -import { verifiedCheckout } from "./atomic/checkout"; import { registerFullPlanner } from "./atomic/full"; import { createHumanInput } from "./atomic/human-input"; +import { type PlannerWorkspace, plannerWorkspace } from "./atomic/workspace"; import type { HarnessAgent } from "@ai-sdk/harness/agent"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { HostedRepository } from "../agent/repository"; import type { DocumentRoom } from "../agent/tools"; -import type { Config } from "../config"; export type OpenError = | GitHubToolsError @@ -26,10 +25,10 @@ type PlannerAgent = typeof plannerAgent; export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string | ((checkout?: string) => string); + instructions: string | ((workspace?: PlannerWorkspace) => string); model?: string; - atomicPlanner?: Config["atomicPlanner"]; - checkout?: string; + /** `atomic` runs the session as a full Atomic session in the channel's workspace. */ + harness?: string; }; export type PlannerSession = { @@ -97,21 +96,17 @@ export async function openPlannerSession( let sessionId = crypto.randomUUID(); unregister = (deps.registerCredential ?? registerCredential)(sessionId, owner.currentToken); // Background jobs keep the isolated boundary; only a Planner turn may run as a full session. - let cwd = channel.atomicPlanner && !job - ? await verifiedCheckout( - channel.repository, - channel.atomicPlanner.checkouts, - channel.checkout, - ) + let workspace = channel.harness === "atomic" && !job + ? await plannerWorkspace(channel.room.id, channel.repository) : undefined; - if (cwd) { + if (workspace) { unregisterFull = registerFullPlanner(sessionId, { - cwd, - humanInput: createHumanInput(channel.room, channel.atomicPlanner?.inputTimeoutMs), + cwd: workspace.cwd, + humanInput: createHumanInput(channel.room), }); } let instructions = typeof channel.instructions === "function" - ? channel.instructions(cwd) + ? channel.instructions(workspace) : channel.instructions; let agent = deps.agent ?? (job?.kind === "heading" ? deps.headingAgent ?? headingPlannerAgent diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 882690090..dc78c075c 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -249,10 +249,10 @@ function chat(room: Rooms.Room, ws: Socket): Chat.Room { }); } -/** A room's Chat, shared by browser messages and local MCP handoffs. */ +/** A room's Chat, shared by browser messages and MCP handoffs. */ function conversation( room: Rooms.Room, - claimantSessionId: string, + claimantSessionId: string | undefined, repository: Chat.Room["repository"], ): Chat.Room { let opened = room.plan!; @@ -1350,34 +1350,31 @@ registerMcpRoutes(router, hostedAuth, { return heldLease; }, }, { - invokePlanner: config.atomicPlanner - ? input => - withDocumentTransition(input.channel.id, async () => { - let channel = await storage.channels.get(input.channel.id); - if (!channel || deletingChannels.has(channel.id)) return "document-unavailable"; - if (channel.archivedAt) return "document-archived"; - await Rooms.get(channel.id)?.closing; - let held = Rooms.hold(channel.id); - let release = () => { - held.release(); - evict(held.room); - }; - let running = false; - try { - await plan(held.room, server); - let context = conversation(held.room, input.session.session.id, input.repository); - context.checkout = input.checkout; - let code = await Chat.invoke(context, input.session.user, input.instruction); - if (!code && context.chat.running) { - running = true; - void context.chat.running.finally(release).catch(() => {}); - } - return code; - } finally { - if (!running) release(); + invokePlanner: input => + withDocumentTransition(input.channel.id, async () => { + let channel = await storage.channels.get(input.channel.id); + if (!channel || deletingChannels.has(channel.id)) return "document-unavailable"; + if (channel.archivedAt) return "document-archived"; + await Rooms.get(channel.id)?.closing; + let held = Rooms.hold(channel.id); + let release = () => { + held.release(); + evict(held.room); + }; + let running = false; + try { + await plan(held.room, server); + let context = conversation(held.room, input.session?.session.id, input.repository); + let code = await Chat.invoke(context, input.user, input.instruction, input.checkout); + if (!code && context.chat.running) { + running = true; + void context.chat.running.finally(release).catch(() => {}); } - }) - : undefined, + return code; + } finally { + if (!running) release(); + } + }), archiveChannel, isChannelDeleting: channelId => deletingChannels.has(channelId), onChannelRenamed: announceChannel, diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index e4484ab33..d0ec91a98 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -486,9 +486,10 @@ export const TOOLS: Tool[] = [ }, { name: "invoke_planner", - description: "Post an instruction to a document's Planner as the locally signed-in user and " - + "return its URL without waiting for the turn. Available only in full Atomic mode; " - + "a supplied checkout must match the document repository. Questions appear in Decisions.", + description: "Post an instruction to a document's Planner, attributed to the caller, and " + + "return its URL without waiting for the turn. The turn runs under the document's " + + "Planner owner; without one, the caller's live Chopin browser login becomes the " + + "owner, and otherwise the call is refused. Questions appear in Decisions.", inputSchema: { type: "object", properties: { @@ -507,7 +508,9 @@ export const TOOLS: Tool[] = [ }, checkout: { type: "string", - description: "Absolute path to a local checkout of this repository.", + description: "Absolute path, on the Chopin server's machine, to a checkout of this " + + "repository. Used only by HARNESS=atomic, which verifies its origin and " + + "remembers it as the document's Planner working directory; ignored otherwise.", }, }, required: ["id", "instruction"], diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 97ec274c2..236e9df00 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { mkdtemp, realpath, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { ulid } from "@chopin/dialect"; import { Sessions } from "../auth/session"; import { Admission } from "../auth/admission"; import { GitHubError } from "../github/client"; +import { handler } from "../mcp"; import * as Room from "../plan/room"; import * as Service from "../plan/service"; import * as Rooms from "../rooms"; @@ -208,75 +206,80 @@ async function plan(context: ReturnType) { } describe("the hosted MCP adapter", () => { - it("invokes only for a live local login of the MCP caller and verifies a supplied checkout", async () => { - let context = setup(); - context.auth.config.local = { - installation: "local", - credentialsDir: "/tmp/unused", - port: 8795, - }; - context.github.repositoryValue.permissions.push = true; - let opened = await plan(context); - let started: PlannerInvocation[] = []; - let adapter = hosted(context.auth, undefined, { - async invokePlanner(input) { - started.push(input); - return undefined; - }, - }); - let caller = (await adapter.caller(request("Bearer allowed")))!; - let input = { id: opened.channel.id, instruction: " Review this.\n" }; - try { - expect(hosted(context.auth).invoke).toBeUndefined(); - expect(await adapter.invoke!.invoke(caller, input)).toEqual({ - kind: "refused", - code: "planner-owner-unavailable", - }); - await context.storage.users.put({ - id: "U_other", - login: "other", - avatarUrl: "", - now: context.now, - }); - await context.auth.sessions.issue("U_other", grant("another-browser")); - expect(await adapter.invoke!.invoke(caller, input)).toEqual({ - kind: "refused", - code: "planner-owner-unavailable", - }); - let session = await context.auth.sessions.issue(caller.user.id, grant("browser-token")); - expect(await adapter.invoke!.invoke(caller, { ...input, checkout: "/does-not-exist" })) - .toEqual({ kind: "refused", code: "checkout-unverified" }); - expect(started).toEqual([]); - let result = await adapter.invoke!.invoke(caller, input); - expect(result).toEqual({ - kind: "invoked", - document: { - id: opened.channel.id, - title: opened.channel.title, - url: "https://chopin.test/documents/octo-org/score/release-readiness", + it("offers invoke_planner in hosted and local configuration, passing the caller and any live login", async () => { + for (let local of [false, true]) { + let context = setup(); + if (local) { + context.auth.config.local = { + installation: "local", + credentialsDir: "/tmp/unused", + port: 8795, + }; + } + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + let started: PlannerInvocation[] = []; + let refusal: "planner-owner-unavailable" | undefined; + let adapter = hosted(context.auth, undefined, { + async invokePlanner(input) { + started.push(input); + return refusal; }, }); - expect(started).toHaveLength(1); - expect(started[0]).toMatchObject({ - instruction: input.instruction, - session: { session: { id: session.id }, user: { id: caller.user.id } }, - }); - expect(started[0]!.checkout).toBeUndefined(); - } finally { - await Service.close(opened.plan); + let caller = (await adapter.caller(request("Bearer allowed")))!; + let input = { id: opened.channel.id, instruction: " Review this.\n" }; + try { + expect(hosted(context.auth).invoke).toBeUndefined(); + let listed = await handler(adapter)( + new Request("https://chopin.test/mcp", { + method: "POST", + headers: { authorization: "Bearer allowed" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + expect((await listed.json()).result.tools.map((tool: { name: string }) => tool.name)) + .toContain("invoke_planner"); + await context.storage.users.put({ + id: "U_other", + login: "other", + avatarUrl: "", + now: context.now, + }); + await context.auth.sessions.issue("U_other", grant("another-browser")); + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "invoked", + document: { + id: opened.channel.id, + title: opened.channel.title, + url: "https://chopin.test/documents/octo-org/score/release-readiness", + }, + }); + expect(started[0]).toMatchObject({ + instruction: input.instruction, + user: { id: caller.user.id, login: caller.user.login }, + channel: { id: opened.channel.id }, + }); + expect(started[0]!.session).toBeUndefined(); + expect(Object.hasOwn(started[0]!, "checkout")).toBe(false); + let session = await context.auth.sessions.issue(caller.user.id, grant("browser-token")); + refusal = "planner-owner-unavailable"; + expect(await adapter.invoke!.invoke(caller, input)).toEqual({ + kind: "refused", + code: "planner-owner-unavailable", + }); + expect(started[1]).toMatchObject({ + session: { session: { id: session.id }, user: { id: caller.user.id } }, + }); + } finally { + await Service.close(opened.plan); + } } }); - it("passes a matching checkout and local session through a URL invocation", async () => { + it("passes a URL invocation's checkout through unverified for the harness to judge", async () => { let context = setup(); - context.auth.config.local = { - installation: "local", - credentialsDir: "/tmp/unused", - port: 8795, - }; context.github.repositoryValue.permissions.admin = true; let opened = await plan(context); - let checkout = await mkdtemp(join(tmpdir(), "chopin-mcp-checkout-")); let started: PlannerInvocation[] = []; let adapter = hosted(context.auth, undefined, { async invokePlanner(input) { @@ -286,51 +289,23 @@ describe("the hosted MCP adapter", () => { }); try { let caller = (await adapter.caller(request("Bearer allowed")))!; - let session = await context.auth.sessions.issue(caller.user.id, grant("local-browser-token")); - for ( - let args of [["init", "--quiet"], [ - "remote", - "add", - "origin", - "workgit:other/repository.git", - ]] - ) { - let git = Bun.spawn(["git", "-C", checkout, ...args], { stdout: "pipe", stderr: "pipe" }); - expect(await git.exited).toBe(0); - } let input = { id: "https://chopin.test/documents/octo-org/score/release-readiness", instruction: "Review", - checkout, + checkout: "/does-not-exist", }; - expect(await adapter.invoke!.invoke(caller, input)).toEqual({ - kind: "refused", - code: "checkout-unverified", - }); - expect(started).toHaveLength(0); - let git = Bun.spawn([ - "git", - "-C", - checkout, - "remote", - "set-url", - "origin", - "workgit:octo-org/score.git", - ], { stdout: "pipe", stderr: "pipe" }); - expect(await git.exited).toBe(0); expect(await adapter.invoke!.invoke(caller, input)).toMatchObject({ kind: "invoked", document: { id: opened.channel.id, url: input.id }, }); expect(started).toHaveLength(1); expect(started[0]).toMatchObject({ - checkout: await realpath(checkout), + checkout: "/does-not-exist", instruction: input.instruction, - session: { session: { id: session.id } }, + repository: { id: "R_score", owner: "octo-org", name: "score" }, }); } finally { await Service.close(opened.plan); - await rm(checkout, { recursive: true, force: true }); } }); @@ -346,12 +321,6 @@ describe("the hosted MCP adapter", () => { }, isChannelDeleting: () => deleting, }; - expect(hosted(context.auth, undefined, callbacks).invoke).toBeUndefined(); - context.auth.config.local = { - installation: "local", - credentialsDir: "/tmp/unused", - port: 8795, - }; let adapter = hosted(context.auth, undefined, callbacks); let caller = (await adapter.caller(request("Bearer allowed")))!; let invoke = (id = opened.channel.id) => diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 9661b2477..29b979404 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -8,7 +8,6 @@ import * as Rooms from "../rooms"; import { claimImplementation, reportImplementationLifecycle } from "../tasks/plan-graphs"; import { StorageError } from "../storage/errors"; import { implementationLifecycle } from "../tasks/lifecycle"; -import { verifiedCheckout } from "../harness/atomic/checkout"; import type { Server } from "bun"; import type { HostedAuth } from "../auth/routes"; @@ -37,8 +36,11 @@ export type HostedCaller = { export type PlannerInvocation = { channel: ChannelRecord; repository: HostedRepository; - session: AuthenticatedSession; + user: GitHubUser; + /** The caller's live browser login, which may claim ownership for a channel without one. */ + session?: AuthenticatedSession; instruction: string; + /** Unverified; only the atomic harness reads it. */ checkout?: string; }; @@ -268,7 +270,7 @@ export function hosted( } return { - invoke: auth.config.local && callbacks.invokePlanner + invoke: callbacks.invokePlanner ? { async invoke(caller, input) { let located = await locatedChannel(caller, input.id); @@ -287,20 +289,13 @@ export function hosted( }; } if (channel.archivedAt) return { kind: "refused", code: "document-archived" }; - let session = await auth.sessions.forUser(caller.user.id); - if (!session) return { kind: "refused", code: "planner-owner-unavailable" }; - let checkout = input.checkout === undefined - ? undefined - : await verifiedCheckout(repository, [], input.checkout); - if (input.checkout !== undefined && !checkout) { - return { kind: "refused", code: "checkout-unverified" }; - } let code = await callbacks.invokePlanner!({ channel, repository, - session, + user: caller.user, + session: await auth.sessions.forUser(caller.user.id), instruction: input.instruction, - checkout, + ...(input.checkout === undefined ? {} : { checkout: input.checkout }), }); if (code) return { kind: "refused", code }; return { diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index ef59f2500..f57b672c7 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -601,6 +601,19 @@ export function appendQuestionOption( }); } +/** Mark host input nobody answered in time as expired, leaving the card where it is. */ +export function projectExpiry(target: Document, id: string, at: string): Mutation | undefined { + return mutate(target, () => { + let found = false; + for (let node of $nodesOfType(QuestionnaireNode)) { + if (node.getId() !== id) continue; + found = true; + node.setQuestionnaire({ ...node.getQuestionnaire(), status: "expired", at }); + } + return found; + }); +} + /** Take a questionnaire out of the plan, leaving its record as history. */ export function removeQuestionnaire(target: Document, id: string): Mutation | undefined { return mutate(target, () => { diff --git a/apps/server/src/questions/record-fields.ts b/apps/server/src/questions/record-fields.ts index cf601a7df..a0334fddc 100644 --- a/apps/server/src/questions/record-fields.ts +++ b/apps/server/src/questions/record-fields.ts @@ -1,4 +1,11 @@ -export const STATUSES = new Set(["open", "answered", "reopened", "discarded", "cancelled"]); +export const STATUSES = new Set([ + "open", + "answered", + "reopened", + "discarded", + "cancelled", + "expired", +]); export const ORIGINS = new Set(["chat", "planner", "human"]); export const MAX_UNIX_SECONDS = 253_402_300_799; @@ -28,4 +35,7 @@ export function unix(value: unknown): number { return value as number; } -export type Known = { questions: Map; options: Map }; +export type Known = { + questions: Map; + options: Map; +}; diff --git a/apps/server/src/questions/record-types.ts b/apps/server/src/questions/record-types.ts index b998b0c40..2fb580e9b 100644 --- a/apps/server/src/questions/record-types.ts +++ b/apps/server/src/questions/record-types.ts @@ -20,7 +20,7 @@ export type Record = { id: string; definition: Definition; /** "answered" is the stored status of a decided card. */ - status: "open" | "answered" | "reopened" | "discarded" | "cancelled"; + status: "open" | "answered" | "reopened" | "discarded" | "cancelled" | "expired"; answers?: { [question: string]: string }; resolver?: string; at?: number; diff --git a/apps/server/src/questions/record-validation.ts b/apps/server/src/questions/record-validation.ts index 5d569bfa6..107435340 100644 --- a/apps/server/src/questions/record-validation.ts +++ b/apps/server/src/questions/record-validation.ts @@ -10,21 +10,24 @@ export function options(definition: unknown, pending: boolean): Known { if ( !Array.isArray(source.questions) || source.questions.length === 0 || source.questions.length > limits.MAX_QUESTIONS + && !source.questions.every(question => object(question).verbatim === true) ) invalid(); let ids = new Map(); - let questions = new Map(); + let questions = new Map(); for (let candidate of source.questions) { let question = object(candidate); let id = text(question.id); if (questions.has(id)) invalid(); + // Host dialogs keep their raw text and counts, so only the Planner's questions are bounded. + let verbatim = question.verbatim === true; if ( typeof question.multiple !== "boolean" || !Array.isArray(question.options) - || question.options.length === 0 + || question.options.length === 0 && !verbatim && !(pending && source.questions.length === 1 && question.multiple === false) - || question.options.length > limits.MAX_OPTIONS + || !verbatim && question.options.length > limits.MAX_OPTIONS ) invalid(); - questions.set(id, { multiple: question.multiple as boolean }); + questions.set(id, { multiple: question.multiple as boolean, verbatim }); for (let candidate of question.options) { let option = object(candidate); let optionId = text(option.id); @@ -53,8 +56,11 @@ export function choices(value: unknown, known: Known): string[] { export function answers(value: unknown, known: Known): { [question: string]: string } { let entries = object(value); for (let [id, answer] of Object.entries(entries)) { - if (!known.questions.has(id)) invalid(); - text(answer, limits.MAX_CUSTOM); + let question = known.questions.get(id); + if (!question) invalid(); + if (question!.verbatim) { + if (typeof answer !== "string") invalid(); + } else text(answer, limits.MAX_CUSTOM); } return value as { [question: string]: string }; } diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts index 856336900..f806e3180 100644 --- a/apps/server/src/questions/service-ask.ts +++ b/apps/server/src/questions/service-ask.ts @@ -22,9 +22,15 @@ import type { Ended } from "./store"; import type { Record } from "./records"; import { announce } from "./card-notifications"; -import { withdraw } from "./service-cancel"; +import { expire, withdraw } from "./service-cancel"; import { type AskPlacement, validatePlacement } from "./service-definition"; +/** + * Ask each decision independently; register its record before publishing its node. + * + * An abort withdraws the cards still open. After `expiresInMs` without an answer + * they expire instead, and stay in the document marked as such. + */ export async function ask( plan: Plan, server: Server, @@ -33,6 +39,7 @@ export async function ask( placement?: AskPlacement, created?: () => void, signal?: AbortSignal, + expiresInMs?: number, ): Promise { if (signal?.aborted) return []; if (Service.implementationActive(plan)) throw new Error("implementation is active"); @@ -190,20 +197,29 @@ export async function ask( }); let failed = Promise.withResolvers(); - let withdrawing: Promise | undefined; - let abort = () => { - withdrawing ??= Promise.all( - asked.map(item => withdraw(plan, server, roomId, item.id, "chopin")), + let closing: Promise | undefined; + let close = (status: "cancelled" | "expired") => { + closing ??= Promise.all( + asked.map(item => + status === "expired" + ? expire(plan, server, roomId, item.id) + : withdraw(plan, server, roomId, item.id, "chopin") + ), ); - void withdrawing.catch(failed.reject); + void closing.catch(failed.reject); }; + let abort = () => close("cancelled"); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) abort(); + let timer = expiresInMs === undefined + ? undefined + : setTimeout(() => close("expired"), expiresInMs); try { let ended = await Promise.race([Promise.all(asked.map(item => item.waiting)), failed.promise]); - await withdrawing; + await closing; return ended; } finally { + clearTimeout(timer); signal?.removeEventListener("abort", abort); } } diff --git a/apps/server/src/questions/service-cancel.ts b/apps/server/src/questions/service-cancel.ts index 9858be397..4e393ab77 100644 --- a/apps/server/src/questions/service-cancel.ts +++ b/apps/server/src/questions/service-cancel.ts @@ -46,11 +46,13 @@ export async function cancel( if (!acknowledged) reply(ws, msg.rid, { kind: "question:cancel", ts: 0, id: msg.id, ...result }); } +type Closed = { status: "cancelled" | "expired"; resolver: string; at?: number }; + /** * Withdraw input on behalf of a member or the Planner, after its durable commit. * `acknowledge` runs first once the withdrawal is committed, before it is announced. */ -export async function withdraw( +export function withdraw( plan: Plan, server: Server, roomId: string, @@ -58,7 +60,51 @@ export async function withdraw( resolver: string, acknowledge?: () => void, ): Promise { - let claimed = Store.claimCancel(plan.questions, id, resolver); + return closeUnanswered( + plan, + server, + roomId, + id, + { status: "cancelled", resolver }, + document => room.removeQuestionnaire(document, id), + acknowledge, + ); +} + +/** + * Expire host input nobody answered in time, after its durable commit. + * + * Unlike a withdrawal the card stays in the document, marked expired, so the + * room can still see what was asked and that nobody answered it. + */ +export function expire( + plan: Plan, + server: Server, + roomId: string, + id: string, +): Promise { + let at = Math.floor(Date.now() / 1_000); + return closeUnanswered( + plan, + server, + roomId, + id, + { status: "expired", resolver: "chopin", at }, + document => room.projectExpiry(document, id, new Date(at * 1_000).toISOString()), + ); +} + +async function closeUnanswered( + plan: Plan, + server: Server, + roomId: string, + id: string, + closed: Closed, + project: (document: Parameters[0]) => room.Mutation | undefined, + acknowledge?: () => void, +): Promise { + let { status, resolver } = closed; + let claimed = Store.claimCancel(plan.questions, id, resolver, status); if (!claimed.ok) return claimed; let mutationError: unknown; let failure: unknown; @@ -79,7 +125,7 @@ export async function withdraw( try { let mutation: room.Mutation | undefined; try { - mutation = room.removeQuestionnaire(document, id); + mutation = project(document); } catch (err) { mutationError = err; return; @@ -87,7 +133,7 @@ export async function withdraw( let record = plan.records.get(id); let records = new Map(plan.records); if (record) { - records.set(id, { ...record, status: "cancelled", resolver }); + records.set(id, { ...record, ...closed }); } let questions: Store.Questions = { open: new Map(plan.questions.open), @@ -99,7 +145,7 @@ export async function withdraw( document, questions, records, - pendingCardActions: record + pendingCardActions: record && status === "cancelled" ? pending(plan, record, { kind: "discarded", id, @@ -132,17 +178,19 @@ export async function withdraw( kind: "question:resolved", ts: 0, id, - status: "cancelled", + status, resolver, }), () => announce(plan, server, roomId, id), - () => - emit(plan, { - kind: "discarded", - id, - ...(record?.threadId ? { threadId: record.threadId } : {}), - actor: resolver, - }), + ...(status === "cancelled" + ? [() => + emit(plan, { + kind: "discarded", + id, + ...(record?.threadId ? { threadId: record.threadId } : {}), + actor: resolver, + })] + : []), ] ) { try { diff --git a/apps/server/src/questions/service.ts b/apps/server/src/questions/service.ts index a11102554..4359e24b3 100644 --- a/apps/server/src/questions/service.ts +++ b/apps/server/src/questions/service.ts @@ -32,7 +32,7 @@ export { export type { DecisionEntry, OptionOrigin, Record } from "./records"; export { appendOption as addOption } from "./service-append-option"; export { ask } from "./service-ask"; -export { cancel, withdraw } from "./service-cancel"; +export { cancel, expire, withdraw } from "./service-cancel"; export { identify } from "./service-definition"; export type { AskPlacement } from "./service-definition"; export { discard } from "./service-discard"; diff --git a/apps/server/src/questions/store-types.ts b/apps/server/src/questions/store-types.ts index b821f11d8..9b137efc7 100644 --- a/apps/server/src/questions/store-types.ts +++ b/apps/server/src/questions/store-types.ts @@ -9,7 +9,7 @@ export type Collaborator = { export type Ended = | { status: "answered"; answers: Answer[]; resolver: string } - | { status: "cancelled"; resolver: string }; + | { status: "cancelled" | "expired"; resolver: string }; export type Open = { id: string; diff --git a/apps/server/src/questions/store.ts b/apps/server/src/questions/store.ts index 92dc00b28..b0f04240a 100644 --- a/apps/server/src/questions/store.ts +++ b/apps/server/src/questions/store.ts @@ -297,7 +297,7 @@ export function away(questions: Questions, client: string): string[] { export type Settled = { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Ended["status"]; resolver: string; answers?: Answer[]; }; @@ -379,10 +379,12 @@ export function claimSubmit( }; } +/** Reserve an unanswered close: a cancellation, or an expiry nobody answered in time. */ export function claimCancel( questions: Questions, id: string, resolver: string, + status: "cancelled" | "expired" = "cancelled", ): { ok: true; claim: Claim; widget?: string } | CancelRefusal { let ended = questions.closed.get(id); if (ended) return resolved(ended); @@ -397,7 +399,7 @@ export function claimCancel( return { ok: true, ...(entry.widget ? { widget: entry.widget } : {}), - claim: { id, entry, result: { status: "cancelled", resolver } }, + claim: { id, entry, result: { status, resolver } }, }; } diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts index a6340fe65..85424b20a 100644 --- a/apps/server/src/rooms.ts +++ b/apps/server/src/rooms.ts @@ -31,7 +31,7 @@ export type Room = { closing?: Promise; /** Pending eviction, cancelled if somebody comes back. */ eviction?: ReturnType; - /** Local MCP work keeps a memberless room alive until its turn and queue finish. */ + /** MCP work keeps a memberless room alive until its turn and queue finish. */ holds?: number; }; diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts new file mode 100644 index 000000000..011e99adf --- /dev/null +++ b/apps/server/src/testing/config.ts @@ -0,0 +1,54 @@ +import { load } from "../config"; + +export const REQUIRED = { + STORAGE_DRIVER: "postgres", + DATABASE_URL: "postgresql://chopin:secret@database.test/chopin", + APP_ORIGIN: "https://chopin.example", + GITHUB_APP_SLUG: "chopin-test", + GITHUB_APP_CLIENT_ID: "client-id", + GITHUB_APP_CLIENT_SECRET: "client-secret", + SESSION_ENCRYPTION_KEY: "11".repeat(32), +}; + +/** Local-mode overrides for a loopback instance. */ +export const LOCAL = { + AUTH_MODE: "local", + APP_ORIGIN: "http://localhost:8790", + PORT: "8790", +}; + +/** Load configuration from exactly these variables, restoring the environment afterwards. */ +export function configured(overrides: Record = {}) { + let env: Record = { + ...REQUIRED, + AGENT: undefined, + BACKGROUND_JOBS: undefined, + WEB_RESEARCH: undefined, + CONVERSATION_PLAN: undefined, + JEV_API_KEY: undefined, + JEV_MODEL: undefined, + JEV_TIMEOUT_MS: undefined, + TYPESAFE_API_KEY: undefined, + HARNESS: undefined, + HARNESS_AUTH: undefined, + AUTH_MODE: undefined, + SERVER_HOST: undefined, + PORT: undefined, + MODEL: undefined, + CHOPIN_LOCAL_CREDENTIALS_DIR: undefined, + GITHUB_ALLOWED_USERS: undefined, + GITHUB_ALLOWED_ORGANIZATIONS: undefined, + ...overrides, + }; + let previous = { ...process.env }; + for (let [key, value] of Object.entries(env)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return load(); + } finally { + for (let key of Object.keys(env)) delete process.env[key]; + Object.assign(process.env, previous); + } +} diff --git a/apps/server/src/testing/decisions.ts b/apps/server/src/testing/decisions.ts index 2897838b6..ba0879a53 100644 --- a/apps/server/src/testing/decisions.ts +++ b/apps/server/src/testing/decisions.ts @@ -12,7 +12,7 @@ import type { DocumentRoom } from "../agent/tools"; import type { Socket, SocketData } from "../wire"; /** A room whose Decisions answer Atomic HostInput requests, with a member to answer them. */ -export async function hostInputRoom(timeoutMs?: number) { +export async function hostInputRoom(expiresInMs?: number) { let storage = new MemoryStorage(); let now = new Date(); await storage.users.put({ id: "user", login: "reader", avatarUrl: "", now }); @@ -60,7 +60,7 @@ export async function hostInputRoom(timeoutMs?: number) { sessionId: "session", signal: controller.signal, }; - let input = createHumanInput(room, timeoutMs); + let input = createHumanInput(room, expiresInMs); async function cards(count: number) { for (let deadline = Date.now() + 4_000; Date.now() < deadline; await Bun.sleep(5)) { if (Store.outstanding(plan.questions).length === count) { diff --git a/docs/architecture.md b/docs/architecture.md index 1759f412f..8d1f2183f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,8 +119,11 @@ request becomes that channel's Planner owner for the lifetime of the process session. Permission callbacks recheck admission, session identity, credential revision, ownership generation, repository role, and App installation before execution. -The Planner has bounded, repository-fixed read tools and no ambient checkout, -shell, or host filesystem; no harness built-in is active. Model-backed +Under `copilot-sdk` and `pi` the Planner has bounded, repository-fixed read tools +and no ambient checkout, shell, or host filesystem; no harness built-in is active. +Under `atomic` every Planner session is a +[full Atomic session](hosted-agent.md#full-atomic-planner) with shell and +filesystem access as the server process's user. Model-backed `active-planner` workers use the same owner credential but fresh isolated harness sessions; see [Background jobs](background-jobs.md). diff --git a/docs/authentication.md b/docs/authentication.md index afd14dabd..21b991809 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -202,12 +202,10 @@ Each browser has its own binding; configured instance admission lists still apply to every authorized account. Loopback binding does not make this a mode for internet-facing or exposed multi-user deployments. -`ATOMIC_PLANNER=full` additionally enables local Atomic tools and resources for -Planner sessions with a verified repository checkout. It requires `HARNESS=atomic` -and grants shell and filesystem access as the operator; loopback is not a filesystem -sandbox. Both Atomic auth modes (`auto` and `ai-gateway`) are allowed. Keep this a -trusted single-operator instance, with no public proxy or tunnel. See -[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode). +The authentication mode does not change the Planner's tools. Under +`HARNESS=atomic`, every Planner session, local or hosted, has shell and +filesystem access as the server process's user; loopback binding is not a +filesystem sandbox. See [Full Atomic Planner](hosted-agent.md#full-atomic-planner). ## Instance admission @@ -261,6 +259,12 @@ lookup. An MCP-created document for a repository outside the App installation is not available through browser routes, WebSockets, or the hosted agent until the installation includes that repository. See [Local agent MCP](local-agent-mcp.md). +`invoke_planner` is the one MCP tool that starts a hosted agent turn. It never +lends the caller's bearer to the Planner: the turn runs under the channel's +existing Planner owner, and a channel without one is claimed only for the +caller's own live browser login, which must already have passed the +installation-gated owner checks. + The authorization-code flow uses state, S256 PKCE, the exact configured callback, and the App client secret. It does not request OAuth scopes; permissions come from the App registration and each installation. The setup diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index f94ad313e..182e8f332 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -3,8 +3,9 @@ Chopin's Copilot-backed document agent is currently named Planner. It can inspect one selected GitHub repository, co-author the shared document, ask the participants structured questions, and anchor decisions to prose. For documents -used as plans, it can also draft an implementation graph. By default it does not -implement code or change GitHub; the local full Atomic mode below changes its tool boundary. +used as plans, it can also draft an implementation graph. Under `copilot-sdk` and +`pi` it does not implement code or change GitHub; under `atomic` it runs as a full +Atomic session, described [below](#full-atomic-planner). The product role is document co-authoring. The current prompt and tool vocabulary remain optimized for planning and may structure another document type as a plan; @@ -15,9 +16,14 @@ from a code-owned map, defaulting to `copilot-sdk`, a host-process adapter over `@github/copilot-sdk`. `pi`, over `@ai-sdk/harness-pi`, and `atomic`, which embeds Atomic's headless SDK (`@bastani/atomic`) in the server process, are further reviewed adapters. Both require an explicit `HARNESS_AUTH`. The -harness owns the agent loop and model; by default Chopin owns every tool the Planner -can call. See [Self-hosting](self-hosting.md) for `HARNESS`/`HARNESS_AUTH` -selection and adapter trust. +harness owns the agent loop and model. Under `copilot-sdk` and `pi`, Chopin owns +every tool the Planner can call. + +**Choosing `HARNESS=atomic` gives the Planner shell and filesystem access as the +server process's user, on hosted instances as well as local ones.** No other flag +turns this on or off. Operators who do not want it should use `copilot-sdk` or +`pi`. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness) for +`HARNESS`/`HARNESS_AUTH` selection and adapter trust. ## Ownership @@ -36,6 +42,12 @@ user without Copilot entitlement sees the provider failure on the first model-backed action and remains owner until one of those release conditions occurs. +An MCP [`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) +call follows the same rules. Its instruction is posted as the caller's own +message; the turn runs under the channel's current owner, whoever that is. A +channel without one is claimed for the caller's live browser login, hosted or +local, and without such a login the call is refused and nothing is posted. + PostgreSQL stores the owner session ID only so durable ownership can refer to an active process session. The cookie verifier and GitHub credential remain in memory. Startup clears every browser-session registry row and owner reference, @@ -44,16 +56,16 @@ ownership generation. ## Runtime isolation -By default every harness receives only Chopin's host-executed tools: document, question, -relationship, implementation-graph, repository, and GitHub tools, each bound to -the channel's repository through `toolsContext`. No harness built-in is active -for the Planner. The default `copilot-sdk` adapter additionally runs the shared -Copilot runtime in SDK `mode: "empty"`: each disposable session receives its -owner's token when created and has no client-level service token or -logged-in-user fallback. That detail is specific to the Copilot SDK adapter, -not a property every harness in `HARNESS` shares. +Under `copilot-sdk` and `pi` the Planner receives only Chopin's host-executed +tools: document, question, relationship, implementation-graph, repository, and +GitHub tools, each bound to the channel's repository through `toolsContext`. No +harness built-in is active for the Planner. The default `copilot-sdk` adapter +additionally runs the shared Copilot runtime in SDK `mode: "empty"`: each +disposable session receives its owner's token when created and has no +client-level service token or logged-in-user fallback. That detail is specific to +the Copilot SDK adapter, not a property every harness in `HARNESS` shares. -The isolated Planner has no: +That isolated Planner has no: - checkout, shell, or host filesystem; - skills, plugins, or configuration discovery; @@ -65,20 +77,7 @@ Under `HARNESS=pi`, Chopin patches `@ai-sdk/harness-pi` 1.0.128 so Pi does not load `AGENTS.md` or `CLAUDE.md` context files from the host filesystem. See [Self-hosting](self-hosting.md#choose-and-trust-a-harness). -Under `HARNESS=atomic` without a verified full-mode checkout, each harness session -owns one in-process Atomic `AgentSession`. It is built with every shipped Atomic package disabled -(workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding -tools, and its resource loader discovers no extensions, skills, prompt -templates, themes, or context files. Its working and configuration directory is -an empty private temporary directory, and its session, settings, and -credentials stay in memory. A per-turn hook replaces the whole system prompt -with the turn's instructions, so the model never receives Atomic's -coding-agent preamble. The adapter fails the turn before any model request -when the live session reports an extension, tool, context file, skill, prompt -template, or system prompt beyond that set. It checks the tools offered to the -model again before every model request. - -Available capabilities are: +Chopin's tools available to the Planner under every harness are: - Chopin document, question, relationship, and implementation-graph tools (with current plan-oriented tool names); @@ -103,41 +102,59 @@ returned by Chopin with its checkout before claiming work. The server validates the shape of creation provenance but does not resolve its branch and commit against GitHub or independently inspect the coding agent's checkout. -## Full Atomic Planner in local mode - -`ATOMIC_PLANNER=full` is an explicit trust change, accepted only with -`AUTH_MODE=local` and `HARNESS=atomic`. Other combinations and unrecognized -nonempty values fail startup. Both `HARNESS_AUTH=auto` and `ai-gateway` work. -This gives the Planner shell and filesystem access **as the local operator**, -not a sandbox confined to a repository. Use it only on a trusted single-operator, -loopback-only instance; never expose that instance through a proxy or tunnel. - -Set `ATOMIC_PLANNER_CHECKOUTS` to absolute checkout paths separated by the -platform path delimiter (`:` on Unix, `;` on Windows). Before each Planner -session, Chopin checks candidate paths with Git and compares `origin`'s -owner/repository to the channel repository, case-insensitively. HTTPS, -`ssh://`, and scp-style remotes are accepted. Remote host spellings are ignored -because SSH aliases are common; this verifies repository coordinates, not the -authenticity of a remote host. A per-invocation candidate takes precedence over -the configured list. No verified checkout means the same isolated session as -before, with no Atomic resources or HostInput binding. - -A local coding agent can supply that candidate with -[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). -The tool requires a live browser login matching its MCP caller, posts a durable -member instruction, and returns the document URL without waiting for the turn. -An explicitly supplied but unverified checkout is refused before posting, rather -than falling back to another configured path. - -With a verified checkout, it becomes the session's working directory. Atomic's -workflows, subagents, MCP, web access, Intercom, and default coding tools run -alongside Chopin's document tools. The normal Atomic agent directory (including -`ATOMIC_CODING_AGENT_DIR` or the legacy `PI_CODING_AGENT_DIR` override) supplies -extensions, skills, prompt templates, context files, and a read-only copy of its -settings. Chopin appends its Planner instructions to Atomic's assembled prompt. -Session, settings, and model credentials remain in memory; Atomic's own enabled -tools, extensions, MCP servers, and workflow storage can perform local writes. -Background summary and research workers still use isolated sessions. +Under `HARNESS=atomic` the Planner is a [full Atomic session](#full-atomic-planner), +but the summary and research workers each still own one isolated in-process +Atomic `AgentSession`. It is built with every shipped Atomic package disabled +(workflows, subagents, MCP, web access, and Intercom). It has no Atomic coding +tools, and its resource loader discovers no extensions, skills, prompt +templates, themes, or context files. Its working and configuration directory is +an empty private temporary directory, and its session, settings, and +credentials stay in memory. A per-turn hook replaces the whole system prompt +with the turn's instructions, so the model never receives Atomic's +coding-agent preamble. The adapter fails the turn before any model request +when the live session reports an extension, tool, context file, skill, prompt +template, or system prompt beyond that set. It checks the tools offered to the +model again before every model request. + +## Full Atomic Planner + +Under `HARNESS=atomic` every Planner session is a full Atomic session, in local +and hosted deployments alike, with no separate flag. This gives the Planner +shell and filesystem access **as the server process's user**, not a sandbox +confined to a repository. Operators who do not want that should use +`copilot-sdk` or `pi`. Both `HARNESS_AUTH=auto` and `ai-gateway` work, under +their usual [bind rules](self-hosting.md#choose-and-trust-a-harness). + +Atomic's workflows, subagents, MCP, web access, Intercom, and default coding +tools run alongside Chopin's document and repository tools. The normal Atomic +agent directory (including `ATOMIC_CODING_AGENT_DIR` or the legacy +`PI_CODING_AGENT_DIR` override) supplies extensions, skills, prompt templates, +context files, and a read-only copy of its settings. Chopin appends its Planner +instructions to Atomic's assembled prompt. Session, settings, and model +credentials remain in memory; Atomic's own enabled tools, extensions, MCP +servers, and workflow storage can perform writes as the server process's user. +Background summary and research workers still use the isolated sessions described +above. + +The working directory comes only from the `checkout` argument of +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner). +Chopin checks the path with Git and compares `origin`'s owner/repository to the +document's repository, case-insensitively. HTTPS, `ssh://`, and scp-style +remotes are accepted. Remote host spellings are ignored because SSH aliases are +common; this verifies repository coordinates, not the authenticity of a remote +host. An unverified path refuses the invocation before anything is posted. A +verified one is remembered in memory for that document until the process exits, +and every later Planner session for the document, browser-started or MCP-started, +re-verifies it before using it as its working directory. + +Without a remembered checkout that still verifies, the session runs with the +same tools in an empty working directory Chopin creates for that document alone, +under the operating system's temporary directory with mode `0700`. It is never +shared between documents, keeps the Planner's own files for later sessions of the +same document, and is removed when the server shuts down cleanly. The Planner's +instructions state which case applies: a verified checkout, or an empty +directory with no repository files, in which case it reads the repository through +Chopin's repository tools. Chopin implements Atomic's `HostInput`, bound through `extensionBindings.humanInput`; it does not intercept tools. `ask_user_question`, @@ -160,11 +177,15 @@ extension dialogs, and workflow-stage input become ordinary shared Decisions: The label is added to the card only; Atomic receives its question text as asked. A request is appended as one adjacent batch at the end of the document. - An abort withdraws still-open cards, removes their document nodes, and records - cancellation by `@chopin` in Decisions. Member cancellations retain any answers - already given in that batch. Late submissions cannot approve withdrawn input. -- `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` optionally limits each request (a positive - integer in milliseconds, at most 2147483647). Unset waits indefinitely. Expiry - withdraws pending cards and returns no answer; confirm returns false. + cancellation by `@chopin`. Member cancellations retain any answers already + given in that batch. Late submissions cannot approve withdrawn input. +- A request nobody answers within 30 minutes expires. Its still-open cards stay + in the document and in Decisions, among the resolved cards, marked + `status="expired"`; each reads "Nobody answered within 30 minutes. The + Planner will use its best judgement for this decision." Nobody can answer an + expired card. Atomic receives no answer: a questionnaire comes back cancelled + with no answers, confirm returns false, and select, input, and editor return + nothing. The limit is fixed in code, not configured. Host input is not held to the Planner `ask` tool's per-field limits on question and option counts or header, question, label, description, and answer lengths. @@ -176,6 +197,12 @@ workflow approvals pending on withdrawal; Chopin does not automatically resume workflows or replay interrupted turns after a restart. A new Atomic session must explicitly resume a saved run. +A coding agent can hand an instruction to the Planner with +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) under +any harness. The instruction is posted as the MCP caller's own message and runs +under the document's Planner ownership rules (see [Ownership](#ownership)). +Harnesses other than `atomic` ignore its `checkout`. + ## Permission checks Before each Chopin host tool or its repository-bound GitHub MCP tool executes, callbacks recheck: @@ -190,8 +217,9 @@ Before each Chopin host tool or its repository-bound GitHub MCP tool executes, c Permission is decided before execution. A refusal therefore produces no normal tool start or completion event; the Chat service renders permission denials explicitly so the boundary remains visible. -These checks do not mediate full mode's Atomic coding tools, operator extensions, -or operator-configured MCP servers; those use the operator's local authority. +These checks do not mediate the atomic Planner's Atomic coding tools, operator +extensions, or operator-configured MCP servers; those act with the server +process's own authority. A harness session is bound to one credential revision. Before an eight-hour GitHub App token refresh, Chopin aborts and discards every Planner session diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index 9b6c24f3e..972961709 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -26,8 +26,8 @@ Chopin authenticates the supplied token, applies the instance admission policy, and asks GitHub directly for that token's repository permissions. The GitHub App for Chopin does not need to be installed for ordinary MCP document operations. Browser routes, WebSockets, and the Planner still require an active App -installation that includes the repository. The local-only `invoke_planner` -handoff uses an existing browser login for that Planner boundary, not the MCP bearer. +installation that includes the repository. The `invoke_planner` handoff never +lends the MCP bearer to the Planner; it runs under the document's Planner owner. ```bash export CHOPIN_URL="https://your-chopin-instance.example" @@ -53,9 +53,9 @@ The current MCP contract can: - rename, archive, and restore documents without deleting their durable state; - read an approved implementation graph and its document source; and - claim a graph and report task, pull-request, blocker, revision, and - verification lifecycle transitions. - -Local instances with `ATOMIC_PLANNER=full` also offer `invoke_planner`, described below. + verification lifecycle transitions; and +- hand an instruction to a document's Planner with `invoke_planner`, described + below. Chopin validates the shape of `baseBranch` and `baseCommit` during creation but does not resolve them against GitHub. The creating agent is responsible for @@ -72,21 +72,27 @@ graph, but the product has no user-facing way to approve the Planner's draft. See [Experimental implementation lifecycle](implementation-lifecycle.md). -## Hand an instruction to the local Planner +## Hand an instruction to the Planner + +`invoke_planner` is offered on every harness and in both hosted and local +authentication modes. It posts an instruction to an existing document's Planner +and returns without waiting for the turn. + +The instruction is posted as a Chat message under the MCP caller's own handle, +and the turn runs under the document's Planner ownership rules, exactly as a +browser `@chopin` message would: -`invoke_planner` is available only with `ATOMIC_PLANNER=full`, which requires -`AUTH_MODE=local` and `HARNESS=atomic`. See -[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) for setup -and the trust boundary. A coding agent using this tool can drive a Planner with -**shell and filesystem access as the local user**. Keep the instance loopback-only -and single-operator; do not expose it to untrusted clients. +- If the document already has a Planner owner, the turn runs under that owner, + even when the owner is someone other than the caller. +- Otherwise the caller's live Chopin browser login, hosted or local, claims + ownership through the ordinary claim path. Sign in to Chopin in a browser as + the same GitHub account as the MCP bearer first. +- Without either, the call is refused with `planner-owner-unavailable` and + nothing is posted. -Sign in to Chopin through the local device flow as the same GitHub account used -by the MCP bearer. The most recently issued live browser session for that account -supplies the Planner claimant. An existing Planner owner must belong to that same -account; the MCP bearer never becomes the owner or supplies its credentials. -Both the bearer and the browser login need repository write access, and the -browser login must pass the GitHub App installation check. +The MCP bearer never becomes the owner or supplies its credentials. The bearer +needs repository write access; an owner must also pass the GitHub App +installation check. Call the tool with an existing document's UUID or canonical URL, an instruction, and optionally an absolute checkout path: @@ -100,25 +106,36 @@ and optionally an absolute checkout path: ``` The instruction must contain non-whitespace text and fit within 64 KiB in UTF-8; -its text is preserved verbatim. A supplied checkout must have an `origin` matching -the document repository or the request is refused before posting. The verified -path applies to this turn only, including when queued. If omitted, the configured -`ATOMIC_PLANNER_CHECKOUTS` list applies; without a verified checkout the Planner -keeps its isolated behavior. +its text is preserved verbatim. + +`checkout` applies only when the server runs `HARNESS=atomic`, where the Planner +is a [full Atomic session](hosted-agent.md#full-atomic-planner) with **shell and +filesystem access as the server process's user**. The path is on the Chopin +server's machine. Chopin verifies that its `origin` matches the document +repository and refuses the request before posting if it does not. A verified +path is remembered for the document until the server restarts, and every later +Planner session for it, from the browser or through MCP, works there after +checking the path again. Without one, the Planner works in an empty directory +Chopin keeps for that document. Other harnesses ignore `checkout` entirely: it is +neither verified, used, nor remembered. The result contains `id`, `title`, and the canonical `url` (plus any generated `description`). It returns after the instruction is durably posted, not after the -Planner finishes. The message appears in Chat under the local user's handle and -joins the existing Planner queue if busy. Progress and results appear in the -document and Chat; Atomic questions appear in Decisions. The browser need not -already have the document open: Chopin keeps its room alive while the invoked -turn and queue run. An interrupted turn is not replayed automatically on restart. +Planner finishes. The message joins the existing Planner queue if busy. Progress +and results appear in the document and Chat; questions appear in Decisions. Under +`atomic`, Atomic input nobody answers within 30 minutes expires: the card stays in +Decisions marked expired and the Planner proceeds on its own judgement. The +browser need not already have the document open: Chopin keeps its room alive +while the invoked turn and queue run. An interrupted turn is not replayed +automatically on restart. Refusals return an object with `code`: -- `planner-owner-unavailable`: sign in locally as the MCP account and check the - existing Planner owner and browser repository access. -- `checkout-unverified`: correct the supplied path or its repository origin. +- `planner-owner-unavailable`: the document has no usable Planner owner and the + caller has no live browser login to claim it; sign in to Chopin in a browser + as the MCP account, or ask the owner to sign in again. +- `checkout-unverified`: under `atomic`, correct the supplied path or its + repository origin. - `planner-unavailable`: the Planner is disabled or the room is closing. - `planner-queue-full`: wait for queued work to drain before invoking again. - `document-unavailable`, `document-archived`, and `repository-forbidden` retain @@ -133,7 +150,7 @@ The `url` returned by `create_document` is the readable canonical route: ``` `read_document`, `read_implementation`, `archive_document`, `restore_document`, -`update_document`, and (in full mode) `invoke_planner` accept either a document UUID +`update_document`, and `invoke_planner` accept either a document UUID or that canonical URL in their `id` input. The URL may be passed back exactly as returned; an absolute URL must use the configured Chopin origin. Both reads return the stable UUID as the document `id`. @@ -278,8 +295,8 @@ lacks the operation's repository permission; it does not mean the GitHub App for Chopin must be installed. Pull access is enough for `list_documents`, `read_document`, and `read_implementation`. Pull plus push or admin access is required for create, update, rename, archive, restore, invoke, start, and report -lifecycle operations. `invoke_planner` additionally requires the local browser -login described above. Use an account with the required access or ask a repository +lifecycle operations. `invoke_planner` additionally needs a Planner owner or the +caller's live browser login, as described above. Use an account with the required access or ask a repository owner to grant it. Use the optional diff --git a/docs/self-hosting.md b/docs/self-hosting.md index fb2816523..8bdae75f0 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -117,9 +117,18 @@ covers this against the real Pi agent loop; run it before bumping `HARNESS=atomic` runs Atomic 0.9.25 in the Chopin server process through its headless SDK (`createAgentSession()`). It does not spawn the `atomic` CLI, use -RPC mode, or substitute Atomic for Pi's runtime. By default each harness session owns -one in-memory Atomic `AgentSession` whose only tools are Chopin's host tools; see -[Hosted agent](hosted-agent.md#runtime-isolation) for isolation and its local full-mode exception. +RPC mode, or substitute Atomic for Pi's runtime. + +**Choosing `HARNESS=atomic` gives the Planner shell and filesystem access as the +server process's user, on hosted instances as well as local ones.** Every Planner +session is a full Atomic session: Atomic's workflows, subagents, MCP, web access, +Intercom, and default coding tools run beside Chopin's document tools, with the +operator's Atomic extensions, skills, prompt templates, and context files. There is +no separate flag; choosing the harness is the choice. Operators who do not want that +should use `copilot-sdk` or `pi`, whose Planner keeps the isolated, Chopin-tools-only +boundary. Only the summary and research workers still run isolated Atomic sessions. +See [The atomic Planner](#the-atomic-planner) below and +[Hosted agent](hosted-agent.md#full-atomic-planner) for the details. `HARNESS=atomic` requires an explicit `HARNESS_AUTH`, either `auto` or `ai-gateway`. Unset, `direct`, and every other value are refused at startup. @@ -164,15 +173,16 @@ Atomic caveats: enables its shipped packages, including a mandatory Intercom, and its coding tools. It discovers host resources, gives turns without instructions a coding-agent system prompt, and saves tool results over 50,000 characters to a - temporary file. The isolated adapter overrides each default and fails closed on the - ones it can observe, but a new Atomic release can add a default the suite does - not check. Local full mode deliberately enables those capabilities. + temporary file. For the isolated worker sessions the adapter overrides each + default and fails closed on the ones it can observe, but a new Atomic release + can add a default the suite does not check. Planner sessions deliberately keep + those capabilities. - Sessions live only in memory. `doStop` returns a state the adapter refuses to resume, so a session never survives a restart. Chopin does not resume harness sessions. - Harness-level compaction, suspending a turn, and supplied harness skills are - unsupported. Full-mode resource discovery does load Atomic skills. Atomic's - automatic retries remain on. + unsupported. A Planner session's own resource discovery does load Atomic + skills. Atomic's automatic retries remain on. - Under `auto`, refreshing an OAuth token in memory can rotate the refresh token stored by the host CLI, which may then ask the operator to sign in again. `auto` also runs `!command` API-key entries in `auth.json` to resolve them. @@ -191,6 +201,44 @@ usage alerts. A Copilot credit limit applies to one harness session, including a turns of its job stage, not as a platform-wide budget; see [Background jobs](background-jobs.md#executor-owned-limits). +### The atomic Planner + +A Planner session's working directory comes from one place: a `checkout` path +supplied through the MCP +[`invoke_planner`](local-agent-mcp.md#hand-an-instruction-to-the-planner) tool. +Chopin checks it with Git and compares `origin`'s owner/repository to the +document's repository, ignoring case and the remote host spelling (so SSH host +aliases work). A mismatching or missing path refuses the invocation before +anything is posted. A verified path is remembered for that document until the +server process exits; nothing is written to storage. Every later Planner session +for the document, whether started from the browser or through MCP, re-verifies +the remembered path before using it. The check establishes repository +coordinates, not remote-host authenticity, and it does not confine the shell. + +Without a remembered checkout that still verifies, the session runs in an empty +working directory that Chopin creates for that document alone under the operating +system's temporary directory, with mode `0700`. It is never shared with another +document, keeps what the Planner writes there for later sessions of the same +document, and is removed when the server shuts down cleanly. The session has the +same tools either way; the Planner is told whether it is in a checkout or in an +empty directory without repository files, and its repository tools remain +available. + +Atomic input (`ask_user_question`, extension dialogs, and workflow-stage +questions) appears as shared Decisions instead of terminal dialogs, including +workflow run and stage labels. Unanchored batches append at the document's end. +Dialog text and written answers stay verbatim, bounded only by the document's +256 KiB source limit and the shared-draft limits rather than the Planner's own +question limits. A request nobody answers within 30 minutes expires: its cards +stay in Decisions marked as expired, and Atomic receives no answer. Cancellation +withdraws pending cards; neither ever approves an action. See +[Full Atomic Planner](hosted-agent.md#full-atomic-planner) for the mapping, +resource loading, persistence, and workflow restart boundaries. + +`invoke_planner` itself is offered on every harness and in both authentication +modes. Other harnesses ignore its `checkout`: they neither verify nor use nor +remember it. + ## Prerequisites - Docker for the application image, or Bun 1.4.2 for a source deployment. @@ -242,35 +290,32 @@ Store production values in the deployment's secret manager or an owner-readable environment file outside the source tree. Do not bake `.env` or credentials into the image. -| Variable | Default | Meaning | -| --------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | -| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | -| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | -| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | -| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | -| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | -| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | -| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | -| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | -| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | -| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | -| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | -| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | -| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | -| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. | -| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | -| `ATOMIC_PLANNER` | unset | Set exactly `full` to allow full Atomic Planner sessions. Requires `AUTH_MODE=local` and `HARNESS=atomic`; every other nonempty value or combination fails startup. Both Atomic auth modes are supported. | -| `ATOMIC_PLANNER_CHECKOUTS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Absolute candidate checkout paths, separated by the platform path delimiter (`:` on Unix, `;` on Windows). The first verified repository origin supplies the full Planner's cwd; without a match the session stays isolated. With no list, only an `invoke_planner` checkout can enable a full session. | -| `ATOMIC_PLANNER_INPUT_TIMEOUT_MS` | unset | Read only when `ATOMIC_PLANNER=full`; empty means unset. Optional positive integer timeout per Atomic human-input request, at most 2147483647 milliseconds. Unset waits indefinitely; expiry withdraws pending Decisions and returns no answer. | -| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | -| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | -| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | -| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | -| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | -| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | -| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | -| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on`; never send it to the browser. | +| Variable | Default | Meaning | +| ------------------------------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_DRIVER` | `postgres` | Storage adapter. `postgres` is currently the only accepted value. | +| `DATABASE_URL` | required | `postgres:` or `postgresql:` connection URL. It is not printed by Chopin. | +| `APP_ORIGIN` | required | Exact public origin, without credentials, path, query, fragment, or trailing slash. HTTPS is required unless the host is loopback. | +| `GITHUB_APP_SLUG` | required | Lowercase slug from the App's public URL. | +| `GITHUB_APP_CLIENT_ID` | required | OAuth client ID, not the numeric GitHub App ID. | +| `GITHUB_APP_CLIENT_SECRET` | required (hosted) | OAuth client secret used for user-token exchange and refresh. Unused when `AUTH_MODE=local`. | +| `GITHUB_ALLOWED_USERS` | empty | Comma-separated admitted GitHub logins. | +| `GITHUB_ALLOWED_ORGANIZATIONS` | empty | Comma-separated organizations whose active members are admitted. | +| `SESSION_ENCRYPTION_KEY` | required | Exactly 64 hexadecimal characters. Encrypts the OAuth attempt cookie in hosted mode; local mode requires the configured key but uses separate unpredictable, HttpOnly attempt and browser-binding cookies. | +| `AUTH_MODE` | `hosted` | Set `local` for loopback device-flow sign-in with persisted credentials (see [Authentication](authentication.md#local-device-flow-sign-in)). Any other value fails startup. | +| `CHOPIN_LOCAL_CREDENTIALS_DIR` | platform default | Local mode only. Overrides the plaintext-fallback credential directory (default `~/.config/chopin` on Linux, `~/Library/Application Support/Chopin` on macOS, `%APPDATA%\Chopin` on Windows). Must resolve outside the repository and process working directory. | +| `SERVER_HOST` | `127.0.0.1` | Source-process bind address. The image sets `0.0.0.0`, which local mode refuses. A `HARNESS_AUTH` mode that falls back to a host-logged-in subscription is refused unless this stays loopback-only. | +| `PORT` | `8787` | Source-process HTTP and WebSocket port. The supplied image and health check expect internal port 8787. | +| `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. `atomic` gives every Planner session shell and filesystem access as the server process's user, hosted instances included; see [Choose and trust a harness](#choose-and-trust-a-harness). | +| `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | +| `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | +| `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | +| `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | +| `COPILOT_CLI_PATH` | automatic | Advanced override for the Copilot CLI executable. Applies only to the `copilot-sdk` adapter. | +| `CONVERSATION_PLAN` | off | Set exactly `on` to enable experimental conversation-derived cards. Interpretation runs independently of `AGENT`; Planner jobs still obey the agent and job settings. | +| `JEV_MODEL` | `jev-latest` | Model alias for conversation interpretation. Independent of the hosted Planner's `MODEL`. | +| `JEV_TIMEOUT_MS` | `30000` | Per-request interpretation timeout in milliseconds, an integer between 100 and 60000. | +| `JEV_API_KEY` | required when enabled | Server-side Jev API key. Required at startup when `CONVERSATION_PLAN=on`; never send it to the browser. | See [Background jobs and workers](background-jobs.md) for the combined `AGENT`, `BACKGROUND_JOBS`, and `WEB_RESEARCH` behavior and recovery model. @@ -438,49 +483,6 @@ App authorization, because device-issued refresh tokens can be refreshed without the client secret but GitHub's revocation endpoint requires it. Remove the App from **Settings > Applications** on GitHub to revoke it there. -### Opt into a full Atomic Planner - -Add these settings to the local-mode source deployment above, with an explicit -Atomic `MODEL` and either `HARNESS_AUTH=auto` or `HARNESS_AUTH=ai-gateway`: - -```bash -export HARNESS=atomic -export HARNESS_AUTH=auto -export MODEL="github-copilot/gpt-6-luna" -export ATOMIC_PLANNER=full -export ATOMIC_PLANNER_CHECKOUTS="/absolute/path/to/checkout" -# Optional: withdraw a request after ten minutes without a complete answer. -export ATOMIC_PLANNER_INPUT_TIMEOUT_MS=600000 -``` - -This is not the hosted tool boundary: verified Planner sessions enable Atomic's -shell, filesystem, builtins, and the operator's normal extensions, skills, -templates, and context files. The process runs with the operator's local -privileges. Local mode's loopback restriction and single-operator trust are -essential; never expose it publicly or forward it to untrusted users. - -Before a session starts, Chopin verifies that its cwd is a Git work tree whose -`origin` owner/repository matches the channel, ignoring case and remote host -spelling (so SSH host aliases work). A mismatching or unavailable checkout -falls back to the default isolated behavior, not to the server's cwd. -The check does not establish remote-host authenticity or confine shell access. - -Atomic input appears as shared Decisions instead of terminal dialogs, including -workflow run/stage labels. Unanchored batches append at the document's end. -Dialog text and written answers stay verbatim, bounded only by the document's -256 KiB source limit and the shared-draft limits rather than the Planner's own -question limits. Cancellation or timeout withdraws pending cards and never -approves an action. See -[Full Atomic Planner](hosted-agent.md#full-atomic-planner-in-local-mode) -for mapping, resource loading, persistence, and workflow restart boundaries. - -This mode also exposes the MCP -[`invoke_planner` handoff](local-agent-mcp.md#hand-an-instruction-to-the-local-planner). -Sign in locally as the MCP caller before using it. A supplied `checkout` is verified -before the instruction is posted; an invalid explicit path refuses the invocation. -The tool returns a document URL immediately while the Planner continues in Chat -and asks questions in Decisions. - ## First-start smoke test Startup validates configuration, database connectivity, migration history, the diff --git a/packages/dialect/src/convert.test.ts b/packages/dialect/src/convert.test.ts index 72aa6d48b..d1c90f999 100644 --- a/packages/dialect/src/convert.test.ts +++ b/packages/dialect/src/convert.test.ts @@ -5,10 +5,13 @@ import { $createParagraphNode, $getRoot, $isElementNode } from "lexical"; import { $createPlanNodes, exportPlan, importPlan } from "./convert"; import { $isCodeBlockNode } from "./nodes/content"; +import { cardStatus, toElement } from "./nodes/questionnaire"; import { parse } from "./parse"; import { registry } from "./registry"; import { serialize } from "./serialize"; +import type { QuestionnaireNode } from "./nodes/questionnaire"; + import type { ElementNode, LexicalEditor, RootNode } from "lexical"; const REGISTRY = registry(); @@ -106,6 +109,34 @@ describe("conversion", () => { expect(instance.getEditorState().read(() => $getRoot().getFirstChild()!.getKey())).toBe(key); }); + it("round-trips an expired questionnaire's marker through its Lexical node", () => { + let value = { + id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", + status: "expired" as const, + at: "2026-09-28T10:30:00.000Z", + questions: [{ + id: "01K0N4V4E7Y6P4MJ5WD8XZF3B2", + header: "Confirm", + prompt: "Ship it?", + multiple: false, + options: [{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Yes" }], + }], + }; + let source = serialize({ type: "root", children: [toElement(value)] }); + expect(source).toStartWith( + ``, + ); + expect(through(source)).toBe(source); + let instance = editor(); + importPlan(instance, source, { registry: REGISTRY }); + let imported = instance.getEditorState().read(() => + ($getRoot().getFirstChild() as QuestionnaireNode).getQuestionnaire() + ); + expect(imported).toEqual(value); + expect(cardStatus(imported)).toBe("expired"); + expect(cardStatus({ ...imported, status: undefined })).toBe("open"); + }); + it("round-trips tables through native nodes", () => { let source = "| Name | Status |\n| ---- | ------ |\n| API | Ready |\n"; expect(through(source)).toBe(source); diff --git a/packages/dialect/src/dialect.test.ts b/packages/dialect/src/dialect.test.ts index 9cc9537ca..21368484c 100644 --- a/packages/dialect/src/dialect.test.ts +++ b/packages/dialect/src/dialect.test.ts @@ -174,6 +174,19 @@ describe("components", () => { ); }); + it("accepts `expired` and rejects unknown questionnaire statuses", () => { + let questionnaire = (status: string) => + `\n` + + `\n` + + `\n` + + ``; + accepts(questionnaire("expired")); + for (let status of ["cancelled", "answered", "Expired"]) { + expect(codes(questionnaire(status))).toContain("bad-attribute-value"); + } + }); + it("accepts an accepted comment thread", () => { accepts( `\n` diff --git a/packages/dialect/src/dialect.ts b/packages/dialect/src/dialect.ts index 1e4409024..60813b855 100644 --- a/packages/dialect/src/dialect.ts +++ b/packages/dialect/src/dialect.ts @@ -92,6 +92,9 @@ export const COMPONENTS: Readonly> = Object.freeze({ * * Both are optional: a questionnaire has neither until it is answered, and * one settled before this was recorded has neither for good. + * + * `status="expired"` marks host input nobody answered within its time limit. + * It stays in the document, unanswered, with `at` saying when it expired. */ Questionnaire: component({ name: "Questionnaire", @@ -103,7 +106,7 @@ export const COMPONENTS: Readonly> = Object.freeze({ status: { type: "enum", required: false, - values: ["open", "decided", "reopened", "discarded"], + values: ["open", "decided", "reopened", "discarded", "expired"], }, by: { type: "text", required: false, max: limits.MAX_HANDLE }, at: { type: "text", required: false, max: limits.MAX_TIMESTAMP }, diff --git a/packages/dialect/src/nodes/questionnaire-fields.ts b/packages/dialect/src/nodes/questionnaire-fields.ts index 9863e8734..f323297e2 100644 --- a/packages/dialect/src/nodes/questionnaire-fields.ts +++ b/packages/dialect/src/nodes/questionnaire-fields.ts @@ -8,7 +8,8 @@ export type Option = { description?: string; }; -export type CardStatus = "open" | "decided" | "reopened" | "discarded"; +/** `expired` is host input nobody answered in time: settled, but with no answer. */ +export type CardStatus = "open" | "decided" | "reopened" | "discarded" | "expired"; export type Previous = { choices: string[]; value?: string; by: string; at: string }; @@ -47,7 +48,13 @@ export type Questionnaire = { export const EMPTY: Questionnaire = { id: "", questions: [] }; -const STATUSES: ReadonlySet = new Set(["open", "decided", "reopened", "discarded"]); +const STATUSES: ReadonlySet = new Set([ + "open", + "decided", + "reopened", + "discarded", + "expired", +]); export function cardStatus(value: Questionnaire): CardStatus { if (value.status !== undefined) { diff --git a/packages/dialect/src/validate.ts b/packages/dialect/src/validate.ts index ba503105c..b186605a4 100644 --- a/packages/dialect/src/validate.ts +++ b/packages/dialect/src/validate.ts @@ -407,10 +407,11 @@ class Validator { stringAttribute(parent, "status") ?? "", ) && stringAttribute(node, "multiple") === "false"; - // A free-text question, from a card with no thread or status, has no options - // until its answer is projected. + // A free-text question, from a card with no thread that is open or expired, + // has no options until its answer is projected. let freeTextQuestion = spec.name === "Question" && parent?.name === "Questionnaire" - && !stringAttribute(parent, "thread") && stringAttribute(parent, "status") === undefined; + && !stringAttribute(parent, "thread") + && [undefined, "expired"].includes(stringAttribute(parent, "status")); if (found === 0 && !pendingQuestion && !freeTextQuestion) { this.add( "missing-children", diff --git a/packages/editor/src/decision-state.test.ts b/packages/editor/src/decision-state.test.ts index 0a3760bf6..d7b5a7109 100644 --- a/packages/editor/src/decision-state.test.ts +++ b/packages/editor/src/decision-state.test.ts @@ -98,6 +98,14 @@ describe("decision attention", () => { ])).toBe(2); }); + it("never waits on an expired questionnaire", () => { + let waiting = entry([undefined]); + let expired = { ...waiting, value: { ...waiting.value, status: "expired" as const } }; + expect(countUnanswered([expired, entry([undefined, undefined])])).toBe(2); + expect(firstOpenDecision([expired])).toBeUndefined(); + expect(firstOpenDecision([waiting])).toBe(waiting); + }); + it("forces only a questionnaire-only opening document into Decisions", () => { expect(visibleDecisionView({ phase: "initial", preferred: "plan" }, false, 2)).toBe( "decisions", diff --git a/packages/editor/src/decision-state.ts b/packages/editor/src/decision-state.ts index cf241c0d8..fc58d9126 100644 --- a/packages/editor/src/decision-state.ts +++ b/packages/editor/src/decision-state.ts @@ -18,7 +18,7 @@ export function countUnanswered( return entries.reduce( (total, entry) => { let status = cardMeta?.get(entry.id)?.status ?? cardStatus(entry.value); - if (status === "decided" || status === "discarded") return total; + if (status === "decided" || status === "discarded" || status === "expired") return total; if (status === "reopened") return total + entry.value.questions.length; return total + entry.value.questions.filter(question => question.answer === undefined).length; diff --git a/packages/editor/src/decisions.test.tsx b/packages/editor/src/decisions.test.tsx index 46e2c68df..76d909940 100644 --- a/packages/editor/src/decisions.test.tsx +++ b/packages/editor/src/decisions.test.tsx @@ -6,15 +6,31 @@ import { Decisions } from "./decisions"; import { QuestionnaireStore } from "./questionnaires"; import type { MotionDisclosureContract } from "./disclosure-motion"; +import type { QuestionnaireEntry } from "./questionnaires"; let original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const RESOLVED: QuestionnaireEntry = { + id: "questionnaire", + value: { + id: "questionnaire", + questions: [{ + id: "question", + header: "Question", + prompt: "What did the room decide?", + multiple: false, + options: [], + answer: "Done", + }], + }, +}; + afterEach(() => { if (original) Object.defineProperty(globalThis, "localStorage", original); else delete (globalThis as { localStorage?: unknown }).localStorage; }); -function markup(stored?: string): string { +function markup(stored?: string, entries = [RESOLVED]): string { let values = new Map(); if (stored) values.set("chopin:decisions:resolved", stored); Object.defineProperty(globalThis, "localStorage", { @@ -26,23 +42,7 @@ function markup(stored?: string): string { }); let store = new QuestionnaireStore(); - store.set({ - entries: [{ - id: "questionnaire", - value: { - id: "questionnaire", - questions: [{ - id: "question", - header: "Question", - prompt: "What did the room decide?", - multiple: false, - options: [], - answer: "Done", - }], - }, - }], - hasPlanContent: true, - }); + store.set({ entries, hasPlanContent: true }); let motion: MotionDisclosureContract = { className: "motion-collapse", closeDuration: 250, @@ -64,3 +64,31 @@ test("resolved history starts collapsed and restores an explicit open preference expect(restored).toContain('data-feedback-icon="open"'); expect(restored).toContain('data-motion-feedback="icon"'); }); + +test("an expired card is listed with the resolved cards and says nobody answered", () => { + let expired: QuestionnaireEntry = { + id: "expired", + value: { + id: "expired", + status: "expired", + at: "2026-09-28T10:30:00.000Z", + questions: [{ + id: "confirm", + header: "Confirm", + prompt: "Ship the migration?", + multiple: false, + options: [{ id: "yes", label: "Yes" }], + }], + }, + }; + let history = markup("true", [expired, RESOLVED]); + expect(history).toMatch(/2<\/span>resolved<\/span>/); + expect(history).toContain('data-plan-sidecar-questionnaire="expired"'); + expect(history).toContain("Ship the migration?"); + expect(history).toContain( + "Nobody answered within 30 minutes. The Planner will use its best judgement for this decision.", + ); + for (let control of ["Save answer", "; let resolved = answers(value); let current = meta?.status ?? cardStatus(value); let pointing = { places, onQuestionEnter, onQuestionLeave, onQuestionSelect }; @@ -513,6 +514,20 @@ export function carriedByMarkers( ); } +/** Host input nobody answered in time: settled like an answer, with nothing to submit. */ +function Expired({ value }: { value: Questionnaire }) { + return ( + + + + ); +} + function InlineQuestionnaire({ value }: { value: Questionnaire }) { let options = useCellValue(widgets$); let meta = useCardMeta(options.cardMeta, value.id); diff --git a/packages/protocol/question.d.ts b/packages/protocol/question.d.ts index aa7a8116e..9f183b42d 100644 --- a/packages/protocol/question.d.ts +++ b/packages/protocol/question.d.ts @@ -64,7 +64,7 @@ export declare namespace Question { questions: Item[]; }; - export type CardStatus = "open" | "decided" | "reopened" | "discarded"; + export type CardStatus = "open" | "decided" | "reopened" | "discarded" | "expired"; export type OptionOrigin = { origin: "chat" | "planner" | "human"; @@ -242,7 +242,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -262,7 +262,7 @@ export declare namespace Question { | { ok: false; reason: "resolved"; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; } @@ -358,10 +358,20 @@ export declare namespace Question { by: string; }; + /** + * How a questionnaire closed. + * + * `cancelled` takes the card out of the document: a member declined it or + * its asker withdrew it. `expired` is host input nobody answered within its + * time limit; the card stays in the document, marked expired, and its asker + * proceeds without an answer. + */ + export type Status = "answered" | "cancelled" | "expired"; + /** The questionnaire is closed. Nobody may answer it further. */ export type Resolved = KIND<"question:resolved"> & { id: string; - status: "answered" | "cancelled"; + status: Status; resolver: string; answers?: Answer[]; }; diff --git a/packages/question/src/limits.ts b/packages/question/src/limits.ts index 61b35c128..f273cdbe9 100644 --- a/packages/question/src/limits.ts +++ b/packages/question/src/limits.ts @@ -30,3 +30,6 @@ export const MAX_MODEL_BYTES = 256 * 1024; /** Longest a tool call id may be, since it identifies the request. */ export const MAX_CALL_ID = 256; + +/** How long host input waits for an answer before its card expires unanswered. */ +export const INPUT_EXPIRY_MS = 30 * 60 * 1_000; diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index d2825dd00..6bd6c7045 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -3,7 +3,7 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { currentQuestion, QuestionView } from "./question-view"; -import { create, normalize, read } from "../index"; +import { create, limits, normalize, read } from "../index"; test("a replacement definition falls back before rendering when its active question disappears", () => { let storage = { @@ -263,3 +263,38 @@ test("only the Planner's own questions cap the custom answer textarea", () => { expect(markup.includes('maxLength="4000"')).toBe(cap); } }); + +test("an expired card keeps its question and says the Planner will proceed; a withdrawn one does not", () => { + let definition = normalize({ + questions: [{ + header: "Confirm", + question: "Ship the migration?", + options: [{ label: "Yes", description: "" }], + multiple: false, + }], + }); + let render = (status: "expired" | "cancelled") => + renderToStaticMarkup(createElement(QuestionView, { + definition, + drafts: {}, + status, + resolver: "chopin", + onChange() {}, + onSubmit() {}, + onCancel() {}, + })); + let note = `Nobody answered within ${limits.INPUT_EXPIRY_MS / 60_000} minutes. ` + + "The Planner will use its best judgement for this decision."; + let expired = render("expired"); + expect(limits.INPUT_EXPIRY_MS).toBe(30 * 60 * 1_000); + expect(expired).toContain( + "Nobody answered within 30 minutes. The Planner will use its best judgement for this decision.", + ); + expect(expired).toContain(note); + expect(expired).toContain("Ship the migration?"); + expect(expired).not.toContain("Cancelled"); + for (let control of [" Promise; disabled?: boolean; submitting?: boolean; - status?: "open" | "answered" | "cancelled" | "discarded"; + status?: "open" | Question.Status | "discarded"; /** Shown instead of controls once the questionnaire has resolved. */ answers?: Answer[]; resolver?: string; @@ -559,6 +560,25 @@ function Callout( ); } +const EXPIRED_NOTE = `Nobody answered within ${ + INPUT_EXPIRY_MS / 60_000 +} minutes. The Planner will use its best judgement for this decision.`; + +/** + * Host input whose time ran out. Unlike a cancelled card it stays in the + * document, so it keeps what was asked beside the note saying nobody answered. + */ +function Expired({ definition }: { definition: Definition }) { + return ( +
+ {definition.questions.map(question => ( +

{question.question}

+ ))} +

{EXPIRED_NOTE}

+
+ ); +} + export function QuestionView(props: QuestionViewProps) { let { definition, @@ -661,14 +681,17 @@ export function QuestionView(props: QuestionViewProps) { ); } - // A cancelled questionnaire has no answers, so it must be matched on status - // alone — falling through would offer an editable form for a dead question. - if (status === "cancelled") { + // A cancelled or expired questionnaire has no answers, so it must be matched + // on status alone — falling through would offer an editable form for a dead + // question. + if (status === "cancelled" || status === "expired") { return (
{single && } {aside} - + {status === "expired" + ? + : }
); } From 5517c1826b0c9af92768a41421a16361728f0683 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Tue, 29 Sep 2026 08:13:29 -0700 Subject: [PATCH 07/12] Load a verified checkout's project settings in the full Planner The full Planner kept the operator's settings in an in-memory manager with no project scope, so packages a repository installs for itself (`atomic install -l`, recorded in `.atomic/settings.json`) never loaded, and their workflows and tools were missing from Planner turns run in that checkout. Seed the in-memory project scope from the working directory's `.atomic/settings.json` as trusted project settings, keep both files unwritten, and reapply Chopin's compaction, summary, and cache overrides. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: 1749 tests, 2 PostgreSQL skips, 0 fail, including a new full-Planner test that a checkout's project package contributes its tool and skill without writing either settings file Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, type scale, Impeccable baseline Assistant-verification: local E2E failed before this fix: invoke_planner reached a full Planner whose tools lacked a package installed through the checkout's .atomic/settings.json User-preference: HARNESS=atomic runs the Planner as a full Atomic session, so a checkout should behave like a local Atomic session in that repository Co-authored-by: Alex Lavaee --- apps/server/src/harness/atomic/adapter.ts | 40 +++++++++++++---- apps/server/src/harness/atomic/full.test.ts | 48 ++++++++++++++++++++- docs/hosted-agent.md | 6 ++- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index 3df1c50c0..81f1f31f9 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -72,6 +72,37 @@ const SETTINGS = { sessionSummary: { enabled: false }, cacheWarming: "off" as const, }; + +async function optionalFile(path: string): Promise { + return readFile(path, "utf8").catch(error => { + if (error.code === "ENOENT") return undefined; + throw error; + }); +} + +/** + * The operator's settings plus the working directory's project settings, held in + * memory so the session never writes either file. A verified checkout's + * `.atomic/settings.json` can add project packages the way it does for a local + * Atomic session; the empty per-channel directory has none. + */ +async function fullSettings(agentDir: string, cwd: string): Promise { + let scopes: Record<"global" | "project", string | undefined> = { + global: JSON.stringify({ + ...JSON.parse(await optionalFile(join(agentDir, "settings.json")) ?? "{}"), + ...SETTINGS, + }), + project: await optionalFile(join(cwd, ".atomic", "settings.json")), + }; + let manager = SettingsManager.fromStorage({ + withLock(scope, fn) { + let next = fn(scopes[scope]); + if (next !== undefined) scopes[scope] = next; + }, + }, { projectTrusted: true }); + manager.applyOverrides(SETTINGS); + return manager; +} const ZERO_USAGE = { inputTokens: { total: undefined, @@ -438,14 +469,7 @@ export function createAtomicAdapter( if (!full) directory ??= await mkdtemp(join(tmpdir(), "chopin-atomic-planner-")); let cwd = full?.cwd ?? directory!; let agentDir = full ? getAgentDir() : directory!; - if (full) { - let file = join(agentDir, "settings.json"); - let saved = await readFile(file, "utf8").catch(error => { - if (error.code === "ENOENT") return "{}"; - throw error; - }); - settingsManager = SettingsManager.inMemory({ ...JSON.parse(saved), ...SETTINGS }); - } + if (full) settingsManager = await fullSettings(agentDir, cwd); sessionManager ??= SessionManager.inMemory(cwd); let loader = new DefaultResourceLoader({ cwd, diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index ace21b686..dd31ba080 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -64,12 +64,14 @@ afterAll(stub.stop); async function run( registered: boolean, prompt = "plain", - { host, checkout = true, worker = false }: { + { host, checkout = true, worker = false, projectPackage = false }: { host?: HostInput; /** False runs the Planner in an empty directory, as a channel without a checkout does. */ checkout?: boolean; /** Also run an unregistered worker session on the same harness while the Planner is open. */ worker?: boolean; + /** Install a local package through the checkout's `.atomic/settings.json`. */ + projectPackage?: boolean; } = {}, ) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); @@ -125,6 +127,28 @@ async function run( }); expect(await git.exited).toBe(0); } + if (projectPackage) { + let pkg = join(root, "project-package"); + await mkdir(join(pkg, "skills", "project-marker"), { recursive: true }); + await writeFile( + join(pkg, "package.json"), + JSON.stringify({ + name: "project-package", + type: "module", + atomic: { extensions: ["./extension.ts"], skills: ["./skills"] }, + }), + ); + await writeFile( + join(pkg, "extension.ts"), + `export default api => api.registerTool({name: "project_tool", label: "Project", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"project"}], details:{}}; }});`, + ); + await writeFile( + join(pkg, "skills", "project-marker", "SKILL.md"), + "---\nname: project-marker\ndescription: PROJECT-SKILL-MARKER\n---\nProject skill.\n", + ); + await mkdir(join(cwd, ".atomic"), { recursive: true }); + await writeFile(join(cwd, ".atomic", "settings.json"), JSON.stringify({ packages: [pkg] })); + } process.env.ATOMIC_CODING_AGENT_DIR = agentDir; let humanInput: HostInput = host ?? { confirm: async () => false, @@ -166,7 +190,15 @@ async function run( await result.consumeStream(); await result.text; let requests = [...stub.requests]; - if (!worker) return { cwd, received, requests, workerRequests: [] }; + if (!worker) { + let files = { + operator: await Bun.file(join(agentDir, "settings.json")).exists(), + project: projectPackage + ? await Bun.file(join(cwd, ".atomic", "settings.json")).text() + : undefined, + }; + return { cwd, received, requests, workerRequests: [], files }; + } let workerSession = await agent.createSession({ sandboxSession: sandbox }); try { stub.requests.length = 0; @@ -236,6 +268,18 @@ test("a Planner session without a checkout is just as full in its empty working expect(result.requests.at(-1)!.toolResults.join("\n")).toContain(result.cwd); }); +test("a checkout's project settings add its packages without writing either settings file", async () => { + let result = await run(true, "plain", { projectPackage: true }); + let request = result.requests[0]!; + expect(request.toolNames).toContain("project_tool"); + expect(request.system).toContain("PROJECT-SKILL-MARKER"); + for (let name of FULL_TOOLS) expect(request.toolNames).toContain(name); + expect(result.files!.operator).toBe(false); + expect(JSON.parse(result.files!.project!).packages).toHaveLength(1); + let plain = await run(true); + expect(plain.requests[0]!.toolNames).not.toContain("project_tool"); +}); + test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { let room = await hostInputRoom(); try { diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 182e8f332..e69e329c3 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -129,7 +129,11 @@ Atomic's workflows, subagents, MCP, web access, Intercom, and default coding tools run alongside Chopin's document and repository tools. The normal Atomic agent directory (including `ATOMIC_CODING_AGENT_DIR` or the legacy `PI_CODING_AGENT_DIR` override) supplies extensions, skills, prompt templates, -context files, and a read-only copy of its settings. Chopin appends its Planner +context files, and a read-only copy of its settings. A verified checkout's +`.atomic/settings.json` is read the same way, as trusted project settings, so +packages installed for that project load too; the empty per-channel directory +has none. Chopin's compaction, summary, and cache overrides still apply on top. +Chopin appends its Planner instructions to Atomic's assembled prompt. Session, settings, and model credentials remain in memory; Atomic's own enabled tools, extensions, MCP servers, and workflow storage can perform writes as the server process's user. From 2d42f30019b3528b3068568902ad4033856fc22e Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Tue, 29 Sep 2026 23:05:13 -0700 Subject: [PATCH 08/12] Keep the Planner's workspace instructions neutral Drop the full Atomic session framing and the tool inventory from the Planner's workspace instructions; the system prompt already lists its tools. Keep only what the model cannot infer: questions from ask_user_question and workflows appear as Decisions, and an expired question means proceeding on best judgement. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-duration: 12m converged, estimated 10m Assistant-verification: bun test passed: apps/server/src/harness, chat and agent, 302 pass, 0 fail Assistant-verification: bun run types passed: all workspaces and E2E Assistant-verification: bun run ci passed: dprint, oxlint, tokens, type scale and design checks User-preference: Planner prompts stay neutral; no "full Atomic session" meta framing, name tools only where it adds information the system prompt lacks Co-authored-by: Alex Lavaee --- apps/server/src/agent/planner.ts | 9 +++------ apps/server/src/chat/invoke.test.ts | 2 +- apps/server/src/harness/session.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index 1f646f34f..5373f16a1 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -252,10 +252,7 @@ repository tools read.` : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created empty for this document. It is not a checkout and holds no repository files, so read ${repository} through the repository tools.`; - let full = `This is a full Atomic session: you have the operator's Atomic coding tools, workflows, -subagents and resources, with shell and filesystem access as the server process's user. -Atomic human input, including workflow questions, goes to Chopin Decisions. Input nobody -answers in time expires with no answer; then proceed on your best judgement and say what -you assumed. Chopin's document tools remain fixed to this document.`; - return [PROMPT, reading, place, full, bootstrap].filter(Boolean).join("\n\n"); + let questions = `\`ask_user_question\` and \`workflow\` questions appear to the document's members as +Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`; + return [PROMPT, reading, place, questions, bootstrap].filter(Boolean).join("\n\n"); } diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts index 78fd750e6..a20c02a57 100644 --- a/apps/server/src/chat/invoke.test.ts +++ b/apps/server/src/chat/invoke.test.ts @@ -405,7 +405,7 @@ test("HARNESS=atomic runs every Planner session full in hosted and local configu let harness = context.config.harness; expect({ harness, full: !!turn.full }).toEqual({ harness, full }); expect(!!turn.full?.humanInput).toBe(full); - expect(turn.instructions.includes("This is a full Atomic session")).toBe(full); + expect(turn.instructions.includes("proceed on your best judgement")).toBe(full); } } }); diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index 0b198d26e..f9573744b 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -174,7 +174,7 @@ describe("Planner workspaces", () => { expect({ attempt, cwd: registered?.cwd }).toEqual({ attempt, cwd: path }); expect(registered?.humanInput.questionnaire).toBeFunction(); expect(instructions).toContain(`${path}, is a local checkout of owner/repo`); - expect(instructions).toContain("This is a full Atomic session"); + expect(instructions).toContain("proceed on your best judgement"); expect(instructions).not.toContain("You have no shell"); } let git = Bun.spawn(["git", "-C", path, "remote", "set-url", "origin", "workgit:other/repo"], { @@ -202,7 +202,7 @@ describe("Planner workspaces", () => { expect(instructions).toContain(`${cwd}, is a scratch directory Chopin created`); expect(instructions).toContain("holds no repository files"); expect(instructions).toContain("`read_repository_file`"); - expect(instructions).toContain("This is a full Atomic session"); + expect(instructions).toContain("proceed on your best judgement"); } await removeWorkspaces(); expect(await stat(first.registered!.cwd).catch(() => undefined)).toBeUndefined(); @@ -214,7 +214,7 @@ describe("Planner workspaces", () => { let { registered, instructions } = await open("channel", harness); expect(registered).toBeUndefined(); expect(instructions).toContain("You have no shell"); - expect(instructions).not.toContain("full Atomic session"); + expect(instructions).not.toContain("proceed on your best judgement"); } }); }); From 8f3631d2c1fbe593531c96b7db9d1be8341f6fd9 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Wed, 30 Sep 2026 01:34:22 -0700 Subject: [PATCH 09/12] Format the Planner's workspace instructions Apply dprint's formatting to the Planner workspace instructions, which the format check flagged. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun run ci passed: dprint check clean after formatting Co-authored-by: Alex Lavaee --- apps/server/src/agent/planner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index 5373f16a1..bb2aba7d4 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -252,7 +252,8 @@ repository tools read.` : `Your working directory, ${workspace.cwd}, is a scratch directory Chopin created empty for this document. It is not a checkout and holds no repository files, so read ${repository} through the repository tools.`; - let questions = `\`ask_user_question\` and \`workflow\` questions appear to the document's members as + let questions = + `\`ask_user_question\` and \`workflow\` questions appear to the document's members as Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`; return [PROMPT, reading, place, questions, bootstrap].filter(Boolean).join("\n\n"); } From 851bcfe030a847ff77e5267a65e039c18c17250c Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 08:23:17 -0700 Subject: [PATCH 10/12] Answer Atomic host input through added options in the redesigned Decisions Decisions on main no longer offers a free-text custom answer: a member writes one by adding an option. The host input bridge now returns an added option as a written answer, as custom where Atomic's own dialog accepts typed text and as chat otherwise, so Atomic never receives a label it did not offer. An input or editor dialog, which has no options, is answered by the option a member adds. The test fixture adds options the way the client does, and the view tests check that a no-option host card offers "Add an option" rather than a text box. The rebase added the expired-card view to question-view.tsx and the editor questionnaire widget. Their reviewed dynamic class and pointing expressions are unchanged and take no data from the new code, so both review hashes are renewed. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: 1882 pass, 2 PostgreSQL skips, 0 fail, including added options returned as custom, chat, and input/editor answers Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record, Impeccable (no new findings) User-preference: Rebase PRs onto the latest main and adapt to its code rather than keeping the old design User-preference: Fit agent questions to Decisions' options format rather than adding a free-text field Co-authored-by: Alex Lavaee --- .../src/harness/atomic/human-input.test.ts | 25 ++++++++++++--- apps/server/src/harness/atomic/human-input.ts | 17 ++++++---- apps/server/src/testing/decisions.ts | 32 +++++++++++++++++-- .../question/src/react/question-view.test.ts | 30 +++-------------- .../exceptions/dynamic-editor.json | 4 +-- .../exceptions/dynamic-packages.json | 2 +- 6 files changed, 69 insertions(+), 41 deletions(-) diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index f7fadc767..43440bea1 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -150,7 +150,7 @@ test("maps confirm/select dialogs without treating custom text as approval or a expect(await custom).toBeUndefined(); }); -test("input and editor are editable free-text cards that round-trip through the document", async () => { +test("input and editor are cards with no options, answered by an option a member adds", async () => { let f = await fixture(); for (let method of ["input", "editor"] as const) { let response = f.input[method]("Title", " initial or hint\n", f.options); @@ -163,13 +163,30 @@ test("input and editor are editable free-text cards that round-trip through the expect(Room.project(roundTrip)).toContain( `header="${method === "input" ? "Input" : "Editor"}"`, ); - expect(Room.project(roundTrip)).not.toContain(""))).not.toContain(" { + let f = await fixture(); + let response = f.input.questionnaire(params, f.options); + let cards = await f.cards(3); + await f.answer(cards[0]!.id, [await f.addOption(cards[0]!.id, "Neither")]); + await f.answer(cards[1]!.id, [0, await f.addOption(cards[1]!.id, "G")]); + await f.answer(cards[2]!.id, [await f.addOption(cards[2]!.id, "Something else")]); + expect((await response).answers).toEqual([ + { questionIndex: 0, question: " Which? ", kind: "chat", answer: "Neither" }, + { questionIndex: 1, question: "Select several", kind: "chat", answer: "C, G" }, + { questionIndex: 2, question: "Or free text?", kind: "custom", answer: "Something else" }, + ]); +}); + test("member cancellation retains answered questions and marks the batch cancelled", async () => { let f = await fixture(); let response = f.input.questionnaire(params, f.options); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index 23de5d998..c04da6071 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -60,14 +60,19 @@ export function createHumanInput( let answer = outcome.answers[0]!; let question = params.questions[questionIndex]!; let identity = { questionIndex, question: question.question }; - if (answer.custom !== undefined) { - // Atomic accepts `custom` only where its own dialog offers a typed row. - let typed = !question.multiSelect && !question.options.some(option => option.preview); - answers.push({ ...identity, kind: typed ? "custom" : "chat", answer: answer.custom }); + // Atomic accepts `custom` only where its own dialog offers a typed row. + let typed = !question.multiSelect && !question.options.some(option => option.preview); + let offered = new Set(question.options.map(option => option.label)); + let choices = answer.choices ?? []; + // A member writes an answer by adding an option, which Atomic never offered. + let written = answer.custom + || (choices.some(label => !offered.has(label)) ? choices.join(", ") : undefined); + if (written !== undefined) { + answers.push({ ...identity, kind: typed ? "custom" : "chat", answer: written }); } else if (question.multiSelect) { - answers.push({ ...identity, kind: "multi", answer: null, selected: answer.choices ?? [] }); + answers.push({ ...identity, kind: "multi", answer: null, selected: choices }); } else { - let label = answer.choices![0]!; + let label = choices[0]!; let preview = question.options.find(option => option.label === label)?.preview; answers.push({ ...identity, diff --git a/apps/server/src/testing/decisions.ts b/apps/server/src/testing/decisions.ts index ba0879a53..c8fe60820 100644 --- a/apps/server/src/testing/decisions.ts +++ b/apps/server/src/testing/decisions.ts @@ -75,6 +75,21 @@ export async function hostInputRoom(expiresInMs?: number) { throw new Error(frame.message ?? frame.reason); } } + /** A member adds an option to the card's first question, the way Decisions offers written answers. */ + async function addOption(id: string, label: string) { + let question = Store.get(plan.questions, id)!.definition.questions[0]!; + await Questions.addOption(plan, server, channel.id, ws, { + kind: "question:option", + rid: "option", + ts: 0, + id, + question: question.id, + key: crypto.randomUUID(), + label, + }); + refused("option"); + return Store.get(plan.questions, id)!.definition.questions[0]!.options.length - 1; + } async function answer(id: string, value: number[] | string) { let opened = Store.snapshot(plan.questions, id); if (!opened.open) throw new Error("question closed"); @@ -85,10 +100,22 @@ export async function hostInputRoom(expiresInMs?: number) { model.api.val([question.id, "mode"]).set("custom"); if (value) model.api.str([question.id, "custom"]).ins(0, value); } else if (question.multiple) { + model.api.val([question.id, "mode"]).set("choices"); + let held = + (model.view() as Record }>)[question.id] + ?.options ?? {}; for (let index of value) { - model.api.val([question.id, "options", question.options[index]!.id]).set(true); + let option = question.options[index]!.id; + // An option appended after the draft began has no register yet, as in the client. + if (Object.hasOwn(held, option)) model.api.val([question.id, "options", option]).set(true); + else {model.api.obj([question.id, "options"]).set({ + [option]: Question.crdt.schema.val(Question.crdt.schema.con(true)), + });} } - } else model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + } else { + model.api.val([question.id, "mode"]).set("choices"); + model.api.val([question.id, "choice"]).set(question.options[value[0]!]!.id); + } let patch = model.api.flush(); if (patch) { await Questions.edit(plan, ws, { @@ -120,6 +147,7 @@ export async function hostInputRoom(expiresInMs?: number) { options, cards, answer, + addOption, ws, server, room, diff --git a/packages/question/src/react/question-view.test.ts b/packages/question/src/react/question-view.test.ts index 6bd6c7045..36f9574f8 100644 --- a/packages/question/src/react/question-view.test.ts +++ b/packages/question/src/react/question-view.test.ts @@ -227,7 +227,7 @@ test("Next waits for an answer to the current question, but not in a read-only v )).not.toContain("disabled"); }); -test("a free-text host dialog renders an editable textarea without inventing a choice", () => { +test("a host dialog with no options is answered by adding one, without a free-text box", () => { let definition = normalize({ questions: [{ header: "Input", question: "Write the brief", options: [], multiple: false }], }, { verbatim: true }); @@ -235,33 +235,11 @@ test("a free-text host dialog renders an editable textarea without inventing a c definition, drafts: read(create(definition), definition), onChange() {}, + onAddOption: async () => ({ ok: true as const }), })); - expect(markup).toContain(" { - let question = { header: "Input", question: "Write the brief", multiple: false }; - for ( - let [definition, cap] of [ - [normalize({ questions: [{ ...question, options: [] }] }, { verbatim: true }), false], - [ - normalize({ questions: [{ ...question, options: [{ label: "A", description: "" }] }] }), - true, - ], - ] as const - ) { - let drafts = read(create(definition), definition); - drafts.q0!.mode = "custom"; - let markup = renderToStaticMarkup(createElement(QuestionView, { - definition, - drafts, - onChange() {}, - })); - expect(markup).toContain(" { diff --git a/scripts/design-contract/exceptions/dynamic-editor.json b/scripts/design-contract/exceptions/dynamic-editor.json index 090f2017a..53aa415f9 100644 --- a/scripts/design-contract/exceptions/dynamic-editor.json +++ b/scripts/design-contract/exceptions/dynamic-editor.json @@ -389,7 +389,7 @@ 1 ] ], - "sourceHash": "a4b50f4686c6d44d1e034183504093a8c486ec5d848e964db121d4d587f0ab20" + "sourceHash": "190cce4ad05296adc8c945d21755b3b26804b7542b7b30cda9667ba1e77e98cb" }, { "file": "packages/editor/src/widgets/code-view.tsx", @@ -452,7 +452,7 @@ 1 ] ], - "sourceHash": "a4b50f4686c6d44d1e034183504093a8c486ec5d848e964db121d4d587f0ab20" + "sourceHash": "190cce4ad05296adc8c945d21755b3b26804b7542b7b30cda9667ba1e77e98cb" }, { "file": "packages/editor/src/resolved-layer.tsx", diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json index 8ca79a380..94afedaf6 100644 --- a/scripts/design-contract/exceptions/dynamic-packages.json +++ b/scripts/design-contract/exceptions/dynamic-packages.json @@ -32,7 +32,7 @@ 1 ] ], - "sourceHash": "44f865f2e4bef65df792e5731cd26161197067f1b57e80c99b24ddd9ed161983" + "sourceHash": "6ffb7d6df2fc865f4eb0803a0e79fa348663f78f57e7040567d5b45146037a2c" }, { "file": "packages/question/src/react/resolved-actions.tsx", From 39ec9f3d3d797945e91a8389894c9181996589f6 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 08:41:03 -0700 Subject: [PATCH 11/12] Refuse an answer that would outgrow the document, and show option previews Edits under the per-edit limit could build an answer large enough to push the saved document past its size limit, after which the Planner could no longer edit it. Project the answer onto a copy and validate it first; when it does not fit, the document stays unchanged and the question stays open. Decisions has no preview pane, so an Atomic option's preview (a mockup or code) now travels in its description, and people see it before choosing without the label or the returned answer changing. Both reported by Maggie Appleton in review. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: fail-before/pass-after passed: with about 100 KB of prose, four 50 KB edits built an answer that saved a 302 KB document past the 256 KiB limit; with the check the submit is refused, the document is unchanged and the question stays open Assistant-verification: bun test passed: 1883 pass, 2 PostgreSQL skips, 0 fail Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record, Impeccable (no new findings) Co-authored-by: Alex Lavaee --- .../src/harness/atomic/human-input.test.ts | 58 ++++++++++++++++++- apps/server/src/harness/atomic/human-input.ts | 6 +- apps/server/src/questions/service-submit.ts | 8 +++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index 43440bea1..4236c7ebb 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test"; import { $nodesOfType } from "lexical"; import { cardStatus, limits, QuestionnaireNode } from "@chopin/dialect"; import { limits as questionLimits } from "@chopin/question"; +import * as QuestionModel from "@chopin/question"; import * as Questions from "../../questions/service"; import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; @@ -68,7 +69,11 @@ test("HostInput appends one ordered questionnaire batch and returns typed answer ]); expect(cards[0]!.definition.questions[0].options[0]).toMatchObject({ label: " A ", - description: " first ", + description: " first \n\npreview A", + }); + expect(cards[0]!.definition.questions[0].options[1]).toMatchObject({ + label: "B", + description: "second", }); let source = Plan.source(f.plan); expect(source.indexOf("Last paragraph.")).toBeLessThan(source.indexOf(" { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Plan\n\n" + ("Prose paragraph. ".repeat(60) + "\n\n").repeat(100), + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let response = f.input.questionnaire({ + questions: [{ header: "Input", question: "Write the brief", options: [] }], + }, f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]!; + // Each edit stays under the per-edit limit; together they outgrow the document. + for (let chunk = 0; chunk < 4; chunk++) { + let opened = Store.snapshot(f.plan.questions, card!.id); + if (!opened.open) throw new Error("question closed"); + let model = QuestionModel.restore( + opened.model, + Store.get(f.plan.questions, card!.id)!.definition, + ); + if (chunk === 0) model.api.val([question.id, "mode"]).set("custom"); + let text = model.api.str([question.id, "custom"]); + text.ins(text.length(), "x".repeat(50_000)); + await Questions.edit(f.plan, f.ws, { + kind: "question:edit", + rid: `edit-${chunk}`, + ts: 0, + id: card!.id, + patch: [...model.api.flush()!.toBinary()], + }); + } + let before = Plan.source(f.plan); + let current = Store.snapshot(f.plan.questions, card!.id); + if (!current.open) throw new Error("question closed"); + await Questions.submit(f.plan, f.server, f.room.id, f.ws, { + kind: "question:submit", + rid: "submit", + ts: 0, + id: card!.id, + revision: current.revision, + }); + expect(Plan.source(f.plan)).toBe(before); + expect(new TextEncoder().encode(Plan.source(f.plan)).length).toBeLessThanOrEqual( + limits.MAX_SOURCE_BYTES, + ); + expect(Store.outstanding(f.plan.questions).map(open => open.id)).toEqual([card!.id]); + f.controller.abort(); + expect((await response).cancelled).toBe(true); +}); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index c04da6071..f64ed743c 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -36,7 +36,11 @@ export function createHumanInput( question: workflow ? `${question.question}\n\n${workflow}` : question.question, options: question.options.map(option => ({ label: option.label, - description: option.description, + // Decisions shows no preview pane, so the mockup or code a member + // needs to see before choosing travels in the description. + description: [option.description, option.preview] + .filter(value => value !== undefined) + .join("\n\n"), })), multiple: question.multiSelect ?? false, })), diff --git a/apps/server/src/questions/service-submit.ts b/apps/server/src/questions/service-submit.ts index 35029e2c9..c728242df 100644 --- a/apps/server/src/questions/service-submit.ts +++ b/apps/server/src/questions/service-submit.ts @@ -98,6 +98,14 @@ export async function submit( 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); + // A long answer can push the document past its size limit, after which the + // Planner can no longer edit it; check the staged copy so a refusal changes nothing. + try { + room.validate(room.project(stagedDocument)); + } catch (err) { + invalid = err instanceof Error ? err.message : "could not record the answer"; + return; + } let records = new Map(plan.records); let answered: Record = { ...record, From 2f68ffa6d35dd8a010cffc8f831d76c220676596 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Sun, 4 Oct 2026 10:02:33 -0700 Subject: [PATCH 12/12] Keep room for every open question to expire A question that fit when it was asked could push the document past its size limit when it expired, because expiry adds a status and a date to its card. The Planner then refused every edit, even a normal one. Reserve that room instead. Asking a question measures it, and every question already open, as if expired. The same reserve now bounds each later change: a Planner edit or replacement, a person's text edit, an added option, an accepted comment's decision, and an answer that settles one question while the rest stay open. A change that would leave too little room is refused and the document stays as it was. Asking no longer applies the check only to verbatim input. Only growth is refused. A change that shrinks or keeps the document's size is always accepted, even when the document is already inside the reserve, so deleting or trimming text still works there. A person's text edit, a Planner edit or replacement, an added option, an answer and an accepted comment all share one rule for this. Reported by Maggie Appleton in review. The combined test now walks every reserve in one sequence: Planner edit and replacement, added option, answer, accepted comment and a further question, each driven to just under the limit and asserted refused. It then sends a person's growing text edit through the room's update path, asserts it is refused with the document unchanged and the room rebuilt, and asserts that a shrinking edit and a fitting one are accepted. Finally every question expires within the limit and a normal edit succeeds. Reverting any one of the seven guards fails it. The headless Lexical client used to drive the person's edit is shared by the room tests and this one. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 6091839b-867c-4965-aec9-b583f62d4b1f) Assistant-verification: fail-before/pass-after passed: the two Planner shrink tests (block edit, replacement) failed on the unfixed code with the shrinking edit refused for no room to expire, and pass with growth-only refusal while the growing edit stays refused Assistant-verification: fail-before/pass-after passed: the combined test (now including a person's growing, shrinking and fitting text edit via Plan.submit) fails with the room.apply growth guard replaced by false and passes restored, about 5 to 6.5 s Assistant-verification: comment test fail-before/pass-after passed: the accepted-comment test fails with only its guard replaced by false and passes restored Assistant-verification: per-guard revert probes, 7 of 7 fail the combined test: room.apply text edit, ask, answer, addOption, comment accept, Edit.apply, Edit.replace; each verified applied with git diff and restored; the room.apply probe over human-input, room and edit tests gave 78 pass 2 fail (combined plus the dedicated room test) Assistant-verification: bun test 1894 pass 2 skip 0 fail; bun run types passed in every package; bun run ci passed with 3 oxlint warnings, equal to the parent baseline of 3, and 0 errors Co-authored-by: Alex Lavaee --- apps/server/src/comments/service.ts | 20 +- .../src/harness/atomic/human-input.test.ts | 655 +++++++++++++++++- apps/server/src/plan/anchors.test.ts | 8 +- apps/server/src/plan/edit.test.ts | 4 +- apps/server/src/plan/edit.ts | 8 + apps/server/src/plan/passages.test.ts | 8 +- apps/server/src/plan/room.test.ts | 109 ++- apps/server/src/plan/room.ts | 51 +- apps/server/src/plan/service.ts | 1 + apps/server/src/questions/prose-1.test.ts | 8 +- .../src/questions/service-append-option.ts | 11 + apps/server/src/questions/service-ask.ts | 9 +- apps/server/src/questions/service-submit.ts | 8 +- apps/server/src/testing/peer.ts | 59 ++ 14 files changed, 884 insertions(+), 75 deletions(-) create mode 100644 apps/server/src/testing/peer.ts diff --git a/apps/server/src/comments/service.ts b/apps/server/src/comments/service.ts index bf4e941e9..1c75b32f8 100644 --- a/apps/server/src/comments/service.ts +++ b/apps/server/src/comments/service.ts @@ -396,13 +396,29 @@ async function settle( let mutation: { update: Uint8Array; source: string } | undefined; if (kind === "accept") { try { - mutation = room.insertDecision(plan.document, { + let decision = { id: msg.id, quote: quote.slice(0, limits.MAX_QUOTE), by: ws.data.handle, at: new Date(claimed.claim.result.at * 1_000).toISOString(), notes: claimed.thread.notes.map(note => ({ by: note.handle, text: note.text })), - }); + }; + let preview = await room.create(room.project(plan.document)); + try { + room.insertDecision(preview, decision); + if ( + !room.fitsOrShrinks( + room.project(plan.document), + room.project(preview), + plan.questions.open.size, + ) + ) { + throw new Error("The decision would leave no room for the open questions to expire"); + } + } finally { + preview.doc.destroy(); + } + mutation = room.insertDecision(plan.document, decision); } catch (err) { mutationError = err; return; diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index 4236c7ebb..890f92196 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test } from "bun:test"; -import { $nodesOfType } from "lexical"; -import { cardStatus, limits, QuestionnaireNode } from "@chopin/dialect"; +import { $createParagraphNode, $createTextNode, $getRoot, $nodesOfType } from "lexical"; +import * as Y from "yjs"; +import { cardStatus, limits, QuestionnaireNode, ulid } from "@chopin/dialect"; import { limits as questionLimits } from "@chopin/question"; import * as QuestionModel from "@chopin/question"; import * as Questions from "../../questions/service"; @@ -8,7 +9,9 @@ import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; import * as Room from "../../plan/room"; import * as Edit from "../../plan/edit"; +import * as Comments from "../../comments/service"; import { hostInputRoom } from "../../testing/decisions"; +import { peer } from "../../testing/peer"; import type { QuestionParams } from "@bastani/atomic"; let cleanups: Array<() => Promise> = []; @@ -554,3 +557,651 @@ test("an answer that would make the document too large leaves it unchanged and t f.controller.abort(); expect((await response).cancelled).toBe(true); }); + +test( + "accepting a comment cannot leave too little room for an open question to expire", + async () => { + let f = await fixture(); + let size = () => new TextEncoder().encode(Plan.source(f.plan)).length; + let quote = "caches tiles for 60 seconds"; + let seeded = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: `# Plan\n\nThe renderer ${quote}.\n`, + }]); + if (!seeded.ok || !seeded.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, seeded.mutation); + + let response = f.input.questionnaire({ + questions: [{ + header: "Choice", + question: "Which?", + options: [{ label: "A", description: "" }, { label: "B", description: "" }], + }], + }, f.options); + await f.cards(1); + + let replies: any[] = []; + let ana = { + data: { handle: "ana", client: "client-ana", room: f.room.id }, + send: (raw: string) => replies.push(JSON.parse(raw)), + publish() {}, + } as unknown as Parameters[3]; + await Comments.start(f.plan, f.server, f.room.id, ana, { + kind: "comment:start", + rid: "start", + ts: 0, + blocks: [1], + quote, + offset: 0, + length: quote.length, + text: "Too long.", + }); + let id = replies.findLast(frame => frame.kind === "comment:start")?.thread?.id as string; + expect(id).toBeString(); + + let scratch = await Room.create(Plan.source(f.plan)); + let before = size(); + Room.insertDecision(scratch, { + id, + quote, + by: "ana", + at: new Date().toISOString(), + notes: [{ by: "ana", text: "Too long." }], + }); + let decision = new TextEncoder().encode(Room.project(scratch)).length - before; + scratch.doc.destroy(); + + let target = limits.MAX_SOURCE_BYTES - decision - 10; + for (let fill = 1; fill > 0;) { + fill = Math.min(40_000, target - size() - 2); + if (fill < 1) break; + let edited = Edit.replace( + f.plan, + f.plan.revision, + `${Plan.source(f.plan)}\n${"x".repeat(fill)}\n`, + ); + if (!edited.ok) throw new Error(`edit refused: ${edited.reason}`); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + } + + let context = { + chat: f.plan.chat, + config: { agent: false }, + plan: f.plan, + room: f.room.id, + server: f.server, + auth: {}, + claimantSessionId: "session", + repository: { id: "repo", owner: "owner", name: "repo", defaultBranch: "main" }, + persist: () => Plan.persist(f.plan), + } as unknown as Parameters[0]; + let complain = console.error; + console.error = () => {}; + try { + await Comments.accept(context, ana, { kind: "comment:accept", rid: "accept", ts: 0, id }); + } finally { + console.error = complain; + } + expect(replies.findLast(frame => frame.kind === "comment:accept")).toMatchObject({ + ok: false, + reason: "invalid", + }); + expect(f.plan.threads.get(id)?.status).toBe("open"); + + for (let { id: open } of Store.outstanding(f.plan.questions)) { + await Questions.expire(f.plan, f.server, f.room.id, open); + } + await response; + + expect(size()).toBeLessThanOrEqual(limits.MAX_SOURCE_BYTES); + let retitled = Edit.replace( + f.plan, + f.plan.revision, + Plan.source(f.plan).replace("# Plan", "# Pla2"), + ); + expect(retitled.ok).toBe(true); + }, + 30_000, +); + +type Fixture = Awaited>; + +async function seedDocument(f: Fixture) { + let seeded = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Plan\n\nStart.\n", + }]); + if (!seeded.ok || !seeded.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, seeded.mutation); +} + +/** Fill the document until `fits` just holds, leaving a margin smaller than one card's expiry. */ +async function crowd(f: Fixture, fits: (scratch: Room.Document) => boolean) { + let size = () => new TextEncoder().encode(Plan.source(f.plan)).length; + let base = Plan.source(f.plan); + let low = 0; + for (let high = limits.MAX_SOURCE_BYTES; low < high;) { + let mid = Math.ceil((low + high) / 2); + let scratch = await Room.create(`${base}\n${"x".repeat(mid)}\n`).catch(() => undefined); + let ok = scratch ? fits(scratch) : false; + scratch?.doc.destroy(); + if (ok) low = mid; + else high = mid - 1; + } + let target = size() + low - 10; + for (let fill = 1; fill > 0;) { + fill = Math.min(40_000, target - size() - 2); + if (fill < 1) break; + let edited = Edit.replace( + f.plan, + f.plan.revision, + `${Plan.source(f.plan)}\n${"x".repeat(fill)}\n`, + ); + if (!edited.ok) throw new Error(`edit refused: ${edited.reason}`); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + } +} + +const single = (header: string): QuestionParams => ({ + questions: [{ + header, + question: "Which?", + options: [{ label: "A", description: "" }, { label: "B", description: "" }], + }], +}); + +const bytes = (value: string) => new TextEncoder().encode(value).length; +const FILLER = /\n\nq{10,}(?=\n)/g; + +/** Resize the Planner's filler paragraphs until the document is exactly `target` bytes. */ +async function fillTo(f: Fixture, target: number) { + let size = () => bytes(Plan.source(f.plan)); + let aim = target; + for (let pass = 0; pass < 3 && size() !== target; pass++) { + let base = Plan.source(f.plan).replace(FILLER, ""); + let paragraphs: string[] = []; + let room = aim - bytes(base); + for (; room > 40_002; room -= 40_002) paragraphs.push("q".repeat(40_000)); + if (room > 0) { + if (room < 12) throw new Error("filler would be too small"); + paragraphs.push("q".repeat(room - 2)); + } + let edited = Edit.replace( + f.plan, + f.plan.revision, + base + paragraphs.map(text => `\n${text}\n`).join(""), + ); + if (!edited.ok) { + throw new Error(`fill refused: ${"message" in edited ? edited.message : edited.reason}`); + } + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + aim += target - size(); + } + expect(size()).toBe(target); +} + +/** What one step adds to the document, measured on a copy without the filler. */ +async function growth(f: Fixture, step: (scratch: Room.Document) => void) { + let scratch = await Room.create(Plan.source(f.plan).replace(FILLER, "")); + let before = bytes(Room.project(scratch)); + step(scratch); + let after = bytes(Room.project(scratch)); + scratch.doc.destroy(); + return after - before; +} + +test( + "questions that fit when asked still fit when they expire, however the document changed since", + async () => { + let f = await fixture(); + let max = limits.MAX_SOURCE_BYTES; + let low = 0; + for (let high = max; low < high;) { + let mid = Math.ceil((low + high) / 2); + if (Room.fitsExpiry("a".repeat(mid), 1)) low = mid; + else high = mid - 1; + } + let expiry = max - low; + let quote = "caches tiles for 60 seconds"; + let seeded = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: `# Plan\n\nThe renderer ${quote}.\n`, + }]); + if (!seeded.ok || !seeded.mutation) throw new Error("fixture edit failed"); + await Plan.publish(f.plan, f.server, f.room.id, seeded.mutation); + + let responses = [ + f.input.questionnaire(single("First"), f.options), + f.input.questionnaire(single("Second"), { ...f.options, requestId: "second" }), + ]; + let [first, second] = await f.cards(2); + + let replies: any[] = []; + let ana = { + data: { handle: "ana", client: "client-ana", room: f.room.id }, + send: (raw: string) => replies.push(JSON.parse(raw)), + publish() {}, + } as unknown as Parameters[3]; + await Comments.start(f.plan, f.server, f.room.id, ana, { + kind: "comment:start", + rid: "start", + ts: 0, + blocks: [1], + quote, + offset: 0, + length: quote.length, + text: "Too long.", + }); + let thread = replies.findLast(frame => frame.kind === "comment:start")?.thread?.id as string; + expect(thread).toBeString(); + let context = { + chat: f.plan.chat, + config: { agent: false }, + plan: f.plan, + room: f.room.id, + server: f.server, + auth: {}, + claimantSessionId: "session", + repository: { id: "repo", owner: "owner", name: "repo", defaultBranch: "main" }, + persist: () => Plan.persist(f.plan), + } as unknown as Parameters[0]; + let accept = async () => { + let complain = console.error; + console.error = () => {}; + try { + await Comments.accept(context, ana, { + kind: "comment:accept", + rid: `accept-${replies.length}`, + ts: 0, + id: thread, + }); + } finally { + console.error = complain; + } + return replies.findLast(frame => frame.kind === "comment:accept"); + }; + + // Planner text edits: two questions are open, so growth stops at their reserve. + await fillTo(f, max - 2 * expiry); + let source = Plan.source(f.plan); + expect(Edit.apply(f.plan, f.plan.revision, [{ op: "insert", index: 0, source: "y" }])) + .toMatchObject({ ok: false, message: expect.stringContaining("expire") }); + expect(Edit.replace(f.plan, f.plan.revision, `${source}\ny\n`)) + .toMatchObject({ ok: false, message: expect.stringContaining("expire") }); + expect(Plan.source(f.plan)).toBe(source); + + // An option that would take the document into the reserve. + let label = "Written ".repeat(25); + let question = first!.definition.questions[0]!; + let option = await growth( + f, + scratch => + Room.appendQuestionOption(scratch, first!.id, question.id, { + id: ulid(), + label, + description: "", + }), + ); + await fillTo(f, max - 10 - option); + await expect(f.addOption(first!.id, label)).rejects.toThrow("no room"); + await fillTo(f, max - option - 2 * expiry - 100); + await f.addOption(first!.id, label); + + // An answer that would do the same, then one that fits. + let text = "a".repeat(1_500); + let answered = await growth( + f, + scratch => + Room.projectAnswer(scratch, first!.id, { [question.id]: text }, { + by: "reader", + at: new Date().toISOString(), + }), + ); + await fillTo(f, max - 10 - answered); + await expect(f.answer(first!.id, text)).rejects.toThrow("no room"); + expect(Store.outstanding(f.plan.questions)).toHaveLength(2); + await fillTo(f, max - answered - expiry - 100); + await f.answer(first!.id, ""); + expect(Store.outstanding(f.plan.questions).map(open => open.id)).toEqual([second!.id]); + + // An accepted comment becomes a Decision in the document. + let decision = await growth(f, scratch => + Room.insertDecision(scratch, { + id: thread, + quote, + by: "ana", + at: new Date().toISOString(), + notes: [{ by: "ana", text: "Too long." }], + })); + await fillTo(f, max - 10 - decision); + expect(await accept()).toMatchObject({ ok: false, reason: "invalid" }); + expect(f.plan.threads.get(thread)?.status).toBe("open"); + await fillTo(f, max - decision - expiry - 100); + expect(await accept()).toMatchObject({ ok: true }); + expect(f.plan.threads.get(thread)?.status).not.toBe("open"); + + // A question that would take the document into the reserve, then one that fits. + let probe = { + id: ulid(), + questions: [{ + id: ulid(), + header: "Third", + prompt: "Which?", + multiple: false, + options: [{ id: ulid(), label: "A" }, { id: ulid(), label: "B" }], + }], + }; + let asked = await growth(f, scratch => + Room.insertQuestionnaire(scratch, { + ...probe, + status: "expired", + at: new Date().toISOString(), + })); + await fillTo(f, max - 10 - asked); + let refused = f.input.questionnaire(single("Third"), { ...f.options, requestId: "third" }); + expect( + await Promise.race([ + refused.then(() => "asked", (err: Error) => err.message), + Bun.sleep(300).then(() => "asked"), + ]), + ).toContain("KiB limit"); + expect(Store.outstanding(f.plan.questions)).toHaveLength(1); + await fillTo(f, max - asked - expiry - 100); + responses.push( + f.input.questionnaire(single("Third"), { ...f.options, requestId: "third-again" }), + ); + await f.cards(2); + + // A person's text edit: growth into the reserve is refused, shrinking or fitting is not. + await fillTo(f, max - 2 * expiry); + let sent: any[] = []; + let ben = { + data: { handle: "ben", client: "client-ben", room: f.room.id }, + send: (raw: string) => sent.push(JSON.parse(raw)), + close() {}, + publish() {}, + } as unknown as Parameters[1]; + let type = async (client: ReturnType, change: () => void) => { + let before = Y.encodeStateVector(client.doc); + client.editor.update(change, { discrete: true }); + let rid = `ben-${sent.length}`; + Plan.submit( + f.plan, + ben, + { + kind: "plan:update", + rid, + ts: 0, + epoch: f.plan.document.epoch, + id: rid, + update: Buffer.from(Y.encodeStateAsUpdate(client.doc, before)).toString("base64"), + } as Parameters[2], + ); + await Bun.sleep(30); + await Plan.drain(f.plan); + return sent.findLast(frame => frame.kind === "plan:ack" && frame.id === rid); + }; + let grow = () => { + let paragraph = $createParagraphNode(); + paragraph.append($createTextNode("More text.")); + $getRoot().append(paragraph); + }; + let epoch = f.plan.document.epoch; + let before = Plan.source(f.plan); + let ben1 = peer(); + Y.applyUpdate(ben1.doc, Room.sync(f.plan.document), "remote"); + await Room.settle(); + expect(await type(ben1, grow)).toBeUndefined(); + expect(Plan.source(f.plan)).toBe(before); + expect(f.plan.document.epoch).not.toBe(epoch); + + let ben2 = peer(); + Y.applyUpdate(ben2.doc, Room.sync(f.plan.document), "remote"); + await Room.settle(); + let shrunk = await type(ben2, () => { + $getRoot().getChildren().find(node => node.getTextContent().startsWith("qq"))?.remove(); + }); + expect(shrunk).toBeDefined(); + expect(bytes(Plan.source(f.plan))).toBeLessThan(bytes(before)); + let fitted = await type(ben2, grow); + expect(fitted).toBeDefined(); + expect(Plan.source(f.plan)).toContain("More text."); + + // Both questions expire from a document with exactly their reserve left. + await fillTo(f, max - 2 * expiry); + for (let { id } of Store.outstanding(f.plan.questions)) { + await Questions.expire(f.plan, f.server, f.room.id, id); + } + await Promise.allSettled(responses); + + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(bytes(Plan.source(f.plan))).toBeLessThanOrEqual(max); + let retitled = Edit.replace( + f.plan, + f.plan.revision, + Plan.source(f.plan).replace("# Plan", "# Pla2"), + ); + expect(retitled.ok).toBe(true); + }, + 30_000, +); + +test( + "a new question is refused when the questions already open would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let first = f.input.questionnaire(single("First"), f.options); + await f.cards(1); + let probe = { + id: ulid(), + questions: [{ + id: ulid(), + header: "Second", + prompt: "Which?", + multiple: false, + options: [{ id: ulid(), label: "A" }, { id: ulid(), label: "B" }], + }], + }; + await crowd(f, scratch => Room.fitsQuestionnaires(scratch, [probe], 0)); + + let second = f.input.questionnaire(single("Second"), { ...f.options, requestId: "second" }); + let outcome = await Promise.race([ + second.then(() => "asked", (err: Error) => err.message), + Bun.sleep(500).then(() => "asked"), + ]); + expect(outcome).toContain("KiB limit"); + expect(Store.outstanding(f.plan.questions)).toHaveLength(1); + f.controller.abort(); + await first; + }, + 30_000, +); + +test( + "an option is refused when the questions open would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let response = f.input.questionnaire({ + questions: [{ + header: "Choice", + question: "Which?", + options: [{ label: "A", description: "" }, { label: "B", description: "" }], + }], + }, f.options); + let [card] = await f.cards(1); + let question = card!.definition.questions[0]!; + await crowd(f, scratch => { + Room.appendQuestionOption(scratch, card!.id, question.id, { + id: ulid(), + label: "Another", + description: "", + }); + return Room.fitsExpiry(Room.project(scratch), 0); + }); + + await expect(f.addOption(card!.id, "Another")).rejects.toThrow("no room"); + expect(Store.get(f.plan.questions, card!.id)!.definition.questions[0]!.options).toHaveLength(2); + f.controller.abort(); + await response; + }, + 30_000, +); + +test( + "an answer is refused when the questions still open would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let first = f.input.questionnaire(single("First"), f.options); + let second = f.input.questionnaire(single("Second"), { ...f.options, requestId: "second" }); + let [card] = await f.cards(2); + let answer = "x".repeat(300); + let question = card!.definition.questions[0]!; + await crowd(f, scratch => { + Room.projectAnswer(scratch, card!.id, { [question.id]: answer }, { + by: "reader", + at: new Date().toISOString(), + }); + return Room.fitsExpiry(Room.project(scratch), 0); + }); + + let before = Plan.source(f.plan); + await expect(f.answer(card!.id, answer)).rejects.toThrow("no room"); + expect(Plan.source(f.plan)).toBe(before); + expect(Store.outstanding(f.plan.questions)).toHaveLength(2); + f.controller.abort(); + await Promise.allSettled([first, second]); + }, + 30_000, +); + +test( + "a Planner edit is refused when the open question would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let response = f.input.questionnaire(single("First"), f.options); + await f.cards(1); + await crowd(f, scratch => Room.fitsExpiry(Room.project(scratch), 1)); + + let before = Plan.source(f.plan); + let grown = Edit.apply(f.plan, f.plan.revision, [{ + op: "insert", + index: 0, + source: "y".repeat(30), + }]); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + expect(Plan.source(f.plan)).toBe(before); + + let retitled = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace", + index: 0, + source: "# Pla2", + }]); + expect(retitled.ok).toBe(true); + f.controller.abort(); + await response; + }, + 30_000, +); + +test( + "a replaced plan is refused when the open question would no longer have room to expire", + async () => { + let f = await fixture(); + await seedDocument(f); + let response = f.input.questionnaire(single("First"), f.options); + await f.cards(1); + await crowd(f, scratch => Room.fitsExpiry(Room.project(scratch), 1)); + + let before = Plan.source(f.plan); + let grown = Edit.replace(f.plan, f.plan.revision, `${before}\n${"y".repeat(30)}\n`); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + expect(Plan.source(f.plan)).toBe(before); + + let retitled = Edit.replace(f.plan, f.plan.revision, before.replace("# Plan", "# Pla2")); + expect(retitled.ok).toBe(true); + f.controller.abort(); + await response; + }, + 30_000, +); + +/** A document already inside the reserve of its open questions, as one that has two open but room for one. */ +async function overcommitted(f: Fixture) { + await seedDocument(f); + let response = f.input.questionnaire(single("First"), f.options); + let [card] = await f.cards(1); + await crowd(f, scratch => Room.fitsExpiry(Room.project(scratch), 1)); + void Store.ask(f.plan.questions, ulid(), Store.get(f.plan.questions, card!.id)!.definition); + expect(Room.fitsExpiry(Plan.source(f.plan), f.plan.questions.open.size)).toBe(false); + return { response }; +} + +test( + "a Planner block edit that shrinks the document is accepted even inside the expiry reserve", + async () => { + let f = await fixture(); + let { response } = await overcommitted(f); + let before = new TextEncoder().encode(Plan.source(f.plan)).length; + + let grown = Edit.apply(f.plan, f.plan.revision, [{ + op: "insert", + index: 0, + source: "y".repeat(30), + }]); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + + let shrunk = Edit.apply(f.plan, f.plan.revision, [{ op: "delete", index: 1 }]); + expect(shrunk.ok).toBe(true); + let trimmed = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace", + index: 0, + source: "# P", + }]); + expect(trimmed.ok).toBe(true); + if (trimmed.ok && trimmed.mutation) { + await Plan.publish(f.plan, f.server, f.room.id, trimmed.mutation); + } + expect(new TextEncoder().encode(Plan.source(f.plan)).length).toBeLessThan(before); + f.controller.abort(); + await response; + }, + 30_000, +); + +test( + "a replaced plan that shrinks the document is accepted even inside the expiry reserve", + async () => { + let f = await fixture(); + let { response } = await overcommitted(f); + let before = Plan.source(f.plan); + + let grown = Edit.replace(f.plan, f.plan.revision, `${before}\n${"y".repeat(30)}\n`); + expect(grown).toMatchObject({ + ok: false, + reason: "invalid", + message: expect.stringContaining("expire"), + }); + + let shrunk = Edit.replace(f.plan, f.plan.revision, before.replace("Start.", "S")); + expect(shrunk.ok).toBe(true); + f.controller.abort(); + await response; + }, + 30_000, +); diff --git a/apps/server/src/plan/anchors.test.ts b/apps/server/src/plan/anchors.test.ts index b28564bf3..a54b06075 100644 --- a/apps/server/src/plan/anchors.test.ts +++ b/apps/server/src/plan/anchors.test.ts @@ -8,6 +8,7 @@ * block edited, the block deleted, and two blocks that look identical. */ +import * as Questions from "../questions/store"; import { describe, expect, it } from "bun:test"; import { $getAnchorAndFocusForUserState } from "@lexical/yjs"; import { $getNodeByKey } from "lexical"; @@ -30,7 +31,12 @@ The third paragraph. `; async function plan(source = SOURCE): Promise { - return { document: await room.create(source), revision: 1, outlines: new Map() } as Room; + return { + document: await room.create(source), + revision: 1, + outlines: new Map(), + questions: Questions.create(), + } as Room; } /** Anchor the block at `index`, the way the agent's tool does. */ diff --git a/apps/server/src/plan/edit.test.ts b/apps/server/src/plan/edit.test.ts index 400c86e52..c07a17d87 100644 --- a/apps/server/src/plan/edit.test.ts +++ b/apps/server/src/plan/edit.test.ts @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import * as edit from "./edit"; import * as room from "./room"; +import * as Questions from "../questions/store"; import type { Plan } from "./service"; const SOURCE = `# Title @@ -21,12 +22,13 @@ First paragraph. Second paragraph. `; -/** Enough of a plan for the edit engine, which only needs these three fields. */ +/** Enough of a plan for the edit engine, which only needs these fields. */ async function plan(source = SOURCE): Promise { return { document: await room.create(source), revision: 1, outlines: new Map(), + questions: Questions.create(), } as Plan; } diff --git a/apps/server/src/plan/edit.ts b/apps/server/src/plan/edit.ts index c81bff686..5441c17d9 100644 --- a/apps/server/src/plan/edit.ts +++ b/apps/server/src/plan/edit.ts @@ -26,6 +26,8 @@ import * as room from "./room"; import type { Nodes, Root, RootContent } from "mdast"; import type { Plan } from "./service"; +const EXPIRY_ROOM = "The plan would leave no room for the open questions to expire."; + /** Enough leading text to tell two blocks apart without resending the source. */ const PREVIEW = 120; @@ -199,6 +201,9 @@ export function apply(plan: Plan, revision: number, operations: Operation[]): Re // to write — a rejected batch is recoverable, a poisoned room is not. let parsed = parse(next); assert(parsed, { bytes: new TextEncoder().encode(next).byteLength }); + if (!room.fitsOrShrinks(source(plan), next, plan.questions.open.size)) { + throw new Error(EXPIRY_ROOM); + } if (parsed.children.length !== children.length) { throw new Error("plan blocks merge or split during Markdown normalisation"); } @@ -274,6 +279,9 @@ export function replace(plan: Plan, revision: number, nextSource: string): Resul let next = serialize({ ...root, children }); let parsed = parse(next); assert(parsed, { bytes: new TextEncoder().encode(next).byteLength }); + if (!room.fitsOrShrinks(source(plan), next, plan.questions.open.size)) { + throw new Error(EXPIRY_ROOM); + } if (parsed.children.length !== children.length) { throw new Error("plan blocks merge or split during Markdown normalisation"); } diff --git a/apps/server/src/plan/passages.test.ts b/apps/server/src/plan/passages.test.ts index 93afac268..8b209fe89 100644 --- a/apps/server/src/plan/passages.test.ts +++ b/apps/server/src/plan/passages.test.ts @@ -13,6 +13,7 @@ * these fail rather than the highlight quietly landing a word to the left. */ +import * as Questions from "../questions/store"; import { describe, expect, it } from "bun:test"; import { $getNodeByKey, $getRoot, $isElementNode, $isParagraphNode } from "lexical"; @@ -32,7 +33,12 @@ The third paragraph. `; async function plan(source = SOURCE): Promise { - return { document: await room.create(source), revision: 1, outlines: new Map() } as Room; + return { + document: await room.create(source), + revision: 1, + outlines: new Map(), + questions: Questions.create(), + } as Room; } /** diff --git a/apps/server/src/plan/room.test.ts b/apps/server/src/plan/room.test.ts index e610ab201..9f249c9f9 100644 --- a/apps/server/src/plan/room.test.ts +++ b/apps/server/src/plan/room.test.ts @@ -7,69 +7,20 @@ */ import { describe, expect, it, spyOn } from "bun:test"; -import { createHeadlessEditor } from "@lexical/headless"; -import { createYjsBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; -import { $createParagraphNode, $getRoot, $isElementNode, $isParagraphNode } from "lexical"; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + $isParagraphNode, +} from "lexical"; import * as Y from "yjs"; -import { $importPlan, registry } from "@chopin/dialect"; +import { $importPlan, limits } from "@chopin/dialect"; +import { peer, REGISTRY } from "../testing/peer"; import * as room from "./room"; -import type { Binding, Provider } from "@lexical/yjs"; -import type { LexicalEditor } from "lexical"; - -const REGISTRY = registry(); - -const PROVIDER = { - awareness: { - getLocalState: () => null, - getStates: () => new Map(), - off() {}, - on() {}, - setLocalState() {}, - setLocalStateField() {}, - }, - connect() {}, - disconnect() {}, - off() {}, - on() {}, -} as unknown as Provider; - -/** A client: its own editor and Y.Doc, exactly as a browser would have. */ -function peer(): { editor: LexicalEditor; doc: Y.Doc; binding: Binding } { - let editor = createHeadlessEditor({ - nodes: REGISTRY.nodes, - onError(err) { - throw err; - }, - }); - let doc = new Y.Doc(); - let binding = createYjsBinding({ editor, id: "plan", doc, docMap: new Map([["plan", doc]]) }); - - editor.registerUpdateListener( - ({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => { - if (tags.has("skip-collab")) return; - syncLexicalUpdateToYjs( - binding, - PROVIDER, - prevEditorState, - editorState, - dirtyElements, - dirtyLeaves, - normalizedNodes, - tags, - ); - }, - ); - - binding.root.getSharedType().observeDeep((events, transaction) => { - if (transaction.origin !== binding) syncYjsChangesToLexical(binding, PROVIDER, events, false); - }); - - return { editor, doc, binding }; -} - function questionnaire(id: string, question: string, option: string, header: string) { return { id, @@ -315,6 +266,48 @@ describe("recovery", () => { expect(outcome.issues.length).toBeGreaterThan(0); }); + it("with questions open, refuses a human edit that grows into the expiry reserve but allows one that shrinks", async () => { + let filler = "x".repeat(limits.MAX_SOURCE_BYTES - 40); + let document = await room.create(`# T\n\n${filler}\n\nTail.\n`); + room.mark(document); + expect(room.fitsExpiry(room.project(document), 1)).toBe(false); + + let client = peer(); + Y.applyUpdate(client.doc, room.sync(document), "remote"); + await room.settle(); + + let before = Y.encodeStateVector(client.doc); + client.editor.update(() => { + let paragraph = $createParagraphNode(); + paragraph.append($createTextNode("More.")); + $getRoot().append(paragraph); + }, { discrete: true }); + let grown = await room.apply( + document, + [Y.encodeStateAsUpdate(client.doc, before)], + undefined, + 1, + ); + expect(grown.ok).toBe(false); + + let fresh = await room.rebuild(document); + let shrinker = peer(); + Y.applyUpdate(shrinker.doc, room.sync(fresh), "remote"); + await room.settle(); + let start = Y.encodeStateVector(shrinker.doc); + shrinker.editor.update(() => { + $getRoot().getLastChild()?.remove(); + }, { discrete: true }); + let shrunk = await room.apply( + fresh, + [Y.encodeStateAsUpdate(shrinker.doc, start)], + undefined, + 1, + ); + expect(shrunk.ok).toBe(true); + expect(room.project(fresh)).not.toContain("Tail."); + }); + it("rebuilds to the last known-good state under a fresh epoch", async () => { let document = await room.create("# Title\n"); room.mark(document); diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index f57b672c7..436079193 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -353,11 +353,12 @@ export async function apply( target: Document, updates: Uint8Array[], authorizeResearch?: (id: string, action: "add" | "remove") => Promise, + open = 0, ): Promise { if (updates.length === 0) return { ok: true, seq: target.seq, researchProjections: [] }; let before = parse(project(target)).children; let researchProjections: ResearchProjectionChange[] = []; - + let held = open > 0 ? Buffer.byteLength(project(target)) : 0; for (let update of updates) Y.applyUpdate(target.doc, update, REMOTE); await settle(); @@ -386,6 +387,10 @@ export async function apply( if (protectProjections(before, after, added, removed)) { return { ok: false, issues: ["protected-projection"] }; } + let source = project(target); + if (open > 0 && Buffer.byteLength(source) > held && !fitsExpiry(source, open)) { + return { ok: false, issues: ["source-too-large"] }; + } } catch (err) { if (!(err instanceof PlanValidationError)) throw err; return { ok: false, issues: err.issues.map(issue => issue.code) }; @@ -513,16 +518,52 @@ export function insertQuestionnaires( }, insertions); } +/** What expiring one open questionnaire adds to its card: the status and the date. */ +const EXPIRY_BYTES = (() => { + let size = (value: Questionnaire) => + Buffer.byteLength(serialize({ type: "root", children: [questionnaireElement(value)] })); + let card = { id: "", questions: [] }; + return size({ ...card, status: "expired", at: new Date(0).toISOString() }) - size(card); +})(); + +/** + * Whether this source still has room for `open` questionnaires to expire. + * + * Expiry adds to a card the document already holds, and a document past its + * limit stops accepting Planner edits, so every change leaves this room. + */ +export function fitsExpiry(source: string, open: number): boolean { + return Buffer.byteLength(source) + open * EXPIRY_BYTES <= limits.MAX_SOURCE_BYTES; +} + /** - * Whether appending these questionnaires keeps the source within its byte limit. + * Whether a change from `before` to `after` may proceed with `open` questionnaires + * waiting: it may always shrink the source, and may grow it only while the + * reserve for their expiry still fits. + */ +export function fitsOrShrinks(before: string, after: string, open: number): boolean { + return Buffer.byteLength(after) <= Buffer.byteLength(before) || fitsExpiry(after, open); +} + +/** + * Whether appending these questionnaires keeps the source within its byte limit + * once they, and the `open` questionnaires already waiting, have expired. * * Measured before mutating, because Yjs cannot undo an insertion that turns out * to be too large. */ -export function fitsQuestionnaires(target: Document, values: Questionnaire[]): boolean { +export function fitsQuestionnaires( + target: Document, + values: Questionnaire[], + open = 0, +): boolean { let tree = parse(project(target)); - tree.children.push(...values.map(questionnaireElement)); - return Buffer.byteLength(serialize(tree)) <= limits.MAX_SOURCE_BYTES; + let at = new Date().toISOString(); + // Reserve the metadata expiry adds to every card in this batch. + tree.children.push( + ...values.map(value => questionnaireElement({ ...value, status: "expired", at })), + ); + return fitsExpiry(serialize(tree), open); } /** Append one questionnaire to the plan. */ diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 1cb402d16..1f5674048 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1351,6 +1351,7 @@ async function commit(plan: Plan): Promise { job?.job, ); }, + plan.questions.open.size, ); if (!outcome.ok) { diff --git a/apps/server/src/questions/prose-1.test.ts b/apps/server/src/questions/prose-1.test.ts index c19961a7f..505461844 100644 --- a/apps/server/src/questions/prose-1.test.ts +++ b/apps/server/src/questions/prose-1.test.ts @@ -5,6 +5,7 @@ import * as edit from "../plan/edit"; import * as room from "../plan/room"; import * as Prose from "./prose"; +import * as Store from "./store"; import type { Plan as RoomPlan } from "../plan/service"; @@ -37,7 +38,12 @@ it("keeps the original Yjs block when a person edits it, even beside identical p 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; + let plan = { + document, + revision: 1, + outlines: new Map(), + questions: Store.create(), + } as RoomPlan; edit.apply(plan, 1, [{ op: "move", index: 2, to: 1 }]); expect(room.resolveAnchor(document, anchor)).toBeUndefined(); diff --git a/apps/server/src/questions/service-append-option.ts b/apps/server/src/questions/service-append-option.ts index da5871fde..ba44917f0 100644 --- a/apps/server/src/questions/service-append-option.ts +++ b/apps/server/src/questions/service-append-option.ts @@ -139,6 +139,17 @@ export async function appendOption( }; let mutation = room.appendQuestionOption(stagedDocument, msg.id, msg.question, result.option); if (!mutation) throw new Error("question projection is missing"); + // Every open question needs room to expire, so growth stops where that room ends. + if ( + !room.fitsOrShrinks( + room.project(plan.document), + room.project(stagedDocument), + plan.questions.open.size, + ) + ) { + outcome = refuse("invalid", "The document has no room for another option"); + return; + } await Service.publishStaged(plan, server, roomId, candidate, mutation); outcome = { kind: "question:option", diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts index f806e3180..5d7f06b70 100644 --- a/apps/server/src/questions/service-ask.ts +++ b/apps/server/src/questions/service-ask.ts @@ -148,11 +148,14 @@ export async function ask( records.set(id, record); return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; }); - // Verbatim host input has no per-field limits, so the document bounds it. Refuse before + // Verbatim host input has no per-field limits, and every card needs room to expire. Refuse before // anything is registered or published. if ( - definition.questions.some(question => question.verbatim) - && !room.fitsQuestionnaires(plan.document, asked.map(item => item.value)) + !room.fitsQuestionnaires( + plan.document, + asked.map(item => item.value), + plan.questions.open.size, + ) ) { Question.reject( `This input would take the document past its ${limits.MAX_SOURCE_BYTES / 1024} KiB limit`, diff --git a/apps/server/src/questions/service-submit.ts b/apps/server/src/questions/service-submit.ts index c728242df..ec3d82584 100644 --- a/apps/server/src/questions/service-submit.ts +++ b/apps/server/src/questions/service-submit.ts @@ -101,7 +101,13 @@ export async function submit( // A long answer can push the document past its size limit, after which the // Planner can no longer edit it; check the staged copy so a refusal changes nothing. try { - room.validate(room.project(stagedDocument)); + let source = room.project(stagedDocument); + room.validate(source); + if ( + !room.fitsOrShrinks(room.project(plan.document), source, plan.questions.open.size - 1) + ) { + throw new Error("The answer would leave no room for the open questions to expire"); + } } catch (err) { invalid = err instanceof Error ? err.message : "could not record the answer"; return; diff --git a/apps/server/src/testing/peer.ts b/apps/server/src/testing/peer.ts new file mode 100644 index 000000000..03402cbc4 --- /dev/null +++ b/apps/server/src/testing/peer.ts @@ -0,0 +1,59 @@ +import { createHeadlessEditor } from "@lexical/headless"; +import { createYjsBinding, syncLexicalUpdateToYjs, syncYjsChangesToLexical } from "@lexical/yjs"; +import * as Y from "yjs"; + +import { registry } from "@chopin/dialect"; + +import type { Binding, Provider } from "@lexical/yjs"; +import type { LexicalEditor } from "lexical"; + +export const REGISTRY = registry(); + +const PROVIDER = { + awareness: { + getLocalState: () => null, + getStates: () => new Map(), + off() {}, + on() {}, + setLocalState() {}, + setLocalStateField() {}, + }, + connect() {}, + disconnect() {}, + off() {}, + on() {}, +} as unknown as Provider; + +/** A client: its own editor and Y.Doc, exactly as a browser would have. */ +export function peer(): { editor: LexicalEditor; doc: Y.Doc; binding: Binding } { + let editor = createHeadlessEditor({ + nodes: REGISTRY.nodes, + onError(err) { + throw err; + }, + }); + let doc = new Y.Doc(); + let binding = createYjsBinding({ editor, id: "plan", doc, docMap: new Map([["plan", doc]]) }); + + editor.registerUpdateListener( + ({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => { + if (tags.has("skip-collab")) return; + syncLexicalUpdateToYjs( + binding, + PROVIDER, + prevEditorState, + editorState, + dirtyElements, + dirtyLeaves, + normalizedNodes, + tags, + ); + }, + ); + + binding.root.getSharedType().observeDeep((events, transaction) => { + if (transaction.origin !== binding) syncYjsChangesToLexical(binding, PROVIDER, events, false); + }); + + return { editor, doc, binding }; +}