diff --git a/AGENTS.md b/AGENTS.md index 7c714b847..59c8fca4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ The most useful technical references are: - [Repository channels](docs/channels.md) - [Storage and persistence](docs/storage.md) - [Hosted agent (Planner)](docs/hosted-agent.md) +- [Planner link wire format](docs/planner-link.md) - [Background jobs and workers](docs/background-jobs.md) - [Experimental implementation lifecycle](docs/implementation-lifecycle.md) - [Self-hosting](docs/self-hosting.md) @@ -62,20 +63,21 @@ Before opening a PR or repairing CI, use the repository's ## Repository map -| Area | Responsibility | Internal workspace dependencies | -| --------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `packages/dialect` | Restricted MDX, MDAST, and Lexical schema | none | -| `packages/protocol` | WebSocket declarations and addressing helper | none | -| `packages/question` | Questionnaire definitions and shared drafts | `protocol` | -| `packages/draft` | Bounded collaborative plain-text drafts | none | -| `packages/viewport` | Browser geometry and subscriptions | none | -| `packages/diagrams` | Bounded diagram rendering and scoped React viewing | `icons` (React peer) | -| `packages/experiment` | Investigation result schemas, selections and native views | `diagrams` (React peer) | -| `apps/connector` | Local ACP client and run-scoped MCP bridge | `experiment`, `protocol` | -| `packages/editor` | Collaborative editor, decisions, comments, and widgets | `diagrams`, `dialect`, `experiment`, `question`, `protocol`, `viewport` | -| `apps/server` | Auth, channels, rooms, storage, Planner, MCP, tasks | `diagrams`, `dialect`, `draft`, `experiment`, `question`, `protocol` | -| `apps/web` | Repository picker, navigation, conversation, workspace | `dialect`, `diagrams`, `draft`, `editor`, `experiment`, `protocol`, `viewport` | -| `e2e` | Browser and system integration harness | may import server internals as fixtures | +| Area | Responsibility | Internal workspace dependencies | +| ----------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `packages/dialect` | Restricted MDX, MDAST, and Lexical schema | none | +| `packages/protocol` | WebSocket declarations and addressing helper | none | +| `packages/planner-link` | Remote Planner link messages, schemas and test client | none | +| `packages/question` | Questionnaire definitions and shared drafts | `protocol` | +| `packages/draft` | Bounded collaborative plain-text drafts | none | +| `packages/viewport` | Browser geometry and subscriptions | none | +| `packages/diagrams` | Bounded diagram rendering and scoped React viewing | `icons` (React peer) | +| `packages/experiment` | Investigation result schemas, selections and native views | `diagrams` (React peer) | +| `apps/connector` | Local ACP client and run-scoped MCP bridge | `experiment`, `protocol` | +| `packages/editor` | Collaborative editor, decisions, comments, and widgets | `diagrams`, `dialect`, `experiment`, `question`, `protocol`, `viewport` | +| `apps/server` | Auth, channels, rooms, storage, Planner, MCP, tasks | `diagrams`, `dialect`, `draft`, `experiment`, `planner-link`, `question`, `protocol` | +| `apps/web` | Repository picker, navigation, conversation, workspace | `dialect`, `diagrams`, `draft`, `editor`, `experiment`, `protocol`, `viewport` | +| `e2e` | Browser and system integration harness | may import server internals as fixtures | Runtime workspace packages do not depend on an application. E2E and skill contract tests may deliberately import server internals; do not treat those test @@ -135,6 +137,15 @@ collaborative state and external implementation runs are durable. 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. +- **`HARNESS=remote` moves the model, not the authority.** Turns run in a client + attached at `/planner-link` with a GitHub bearer and push access; Chopin's + host tools, Decisions, and room writes still run in the server. The attached + client sees what a turn sees and steers its document writes. Attaching makes + the account's live browser login the document's Planner owner, one client per + document; nothing else claims ownership. `@chopin` and `invoke_planner` on a + document without one fail with `planner-not-attached`. Signing out the + owner's login, its expiry, or a Planner reset closes the link. See + [Hosted agent](docs/hosted-agent.md#remote-planner). - **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/Dockerfile b/Dockerfile index b4abd8471..4889946b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY packages/draft/package.json ./packages/draft/package.json COPY packages/editor/package.json ./packages/editor/package.json COPY packages/experiment/package.json ./packages/experiment/package.json COPY packages/icons/package.json ./packages/icons/package.json +COPY packages/planner-link/package.json ./packages/planner-link/package.json COPY packages/protocol/package.json ./packages/protocol/package.json COPY packages/question/package.json ./packages/question/package.json COPY packages/viewport/package.json ./packages/viewport/package.json @@ -49,6 +50,7 @@ COPY --from=production-dependencies --chown=bun:bun /app/apps/server/node_module COPY --from=production-dependencies --chown=bun:bun /app/packages/dialect/node_modules ./packages/dialect/node_modules COPY --from=production-dependencies --chown=bun:bun /app/packages/draft/node_modules ./packages/draft/node_modules COPY --from=production-dependencies --chown=bun:bun /app/packages/experiment/node_modules ./packages/experiment/node_modules +COPY --from=production-dependencies --chown=bun:bun /app/packages/planner-link/node_modules ./packages/planner-link/node_modules COPY --from=production-dependencies --chown=bun:bun /app/packages/question/node_modules ./packages/question/node_modules COPY --chown=bun:bun package.json bun.lock ./ COPY --chown=bun:bun apps/server ./apps/server diff --git a/apps/server/package.json b/apps/server/package.json index a67162ef1..2e24975b0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -20,6 +20,7 @@ "@chopin/diagrams": "workspace:*", "@chopin/draft": "workspace:*", "@chopin/experiment": "workspace:*", + "@chopin/planner-link": "workspace:*", "@chopin/protocol": "workspace:*", "@chopin/question": "workspace:*", "@github/copilot": "catalog:copilot", diff --git a/apps/server/src/agent/planner.test.ts b/apps/server/src/agent/planner.test.ts index 7c67c1c1d..f712723c4 100644 --- a/apps/server/src/agent/planner.test.ts +++ b/apps/server/src/agent/planner.test.ts @@ -51,6 +51,17 @@ test("ordinary and Atomic guidance prefers native diagrams with a bounded Mermai } }); +test("a remote Planner learns where it runs and that its questions become Decisions", () => { + let prompt = plannerInstructions("octo-org/score", "Earlier context.", { remote: true }) + .replace(/\s+/g, " "); + expect(prompt).toContain("You run in a member's own session on their machine"); + expect(prompt).toContain("questions appear to the document's members as Decisions"); + expect(prompt).toContain("**Stop Planner** pauses it"); + expect(prompt).not.toContain("You have no shell"); + expect(prompt).not.toContain("Your working directory"); + expect(prompt.endsWith("Earlier context.")).toBe(true); +}); + test("ordinary guidance supplies an executable numerical chart example", () => { let examples = [...DIAGRAM_AUTHORING.matchAll(/```seecode\n([^`]+)\n```/g)] .map(match => JSON.parse(match[1]!)); diff --git a/apps/server/src/agent/planner.ts b/apps/server/src/agent/planner.ts index 736a68657..4ac3cdc47 100644 --- a/apps/server/src/agent/planner.ts +++ b/apps/server/src/agent/planner.ts @@ -305,10 +305,13 @@ the plan, use the \`detach_question\` operation rather than deleting the block.` const ROUTED_PROMPT = PROMPT.replace(DIAGRAM_INTRO, ROUTED_DIAGRAM_INTRO) .replace(DIAGRAM_AUTHORING, ROUTED_DIAGRAM_AUTHORING); +/** Where a remote Planner's model and own tools run: the attached member's machine. */ +export type RemoteWorkspace = { remote: true }; + export function plannerInstructions( repository: string, bootstrap?: string, - workspace?: PlannerWorkspace, + workspace?: PlannerWorkspace | RemoteWorkspace, visualRouting = false, ): string { let visual = visualRouting @@ -337,13 +340,6 @@ add a table or diagram without its route.` and cannot change GitHub. Ground the plan in what those reading tools return.`; return [prompt, visual, 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 keeps 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 Decisions. If one expires unanswered, proceed on your best judgement and say what you assumed.`; @@ -354,6 +350,21 @@ tool result suggests it. When you start a workflow, say in plain words what it w where to follow it: Chat shows a card for each run with its stages and status, its questions appear under Decisions, **Stop Planner** pauses it, and **Resume Planner** resumes it. Offer to check on or steer a run yourself with your \`workflow\` and \`intercom\` tools when someone asks.`; + if ("remote" in workspace) { + let remote = `You run in a member's own session on their machine, attached to this document. +Your own tools act on that machine and are not visible to the document's other members. The +document changes only through this document's tools, and ${repository} is read through the +repository tools, whatever that machine holds.`; + return [prompt, visual, reading, remote, questions, surface, 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 keeps for +this document. It is not a checkout and holds no repository files, so read +${repository} through the repository tools.`; let implementing = `Your own tools read files and the web but cannot edit files or run commands, so you do not implement the plan yourself. If a member asks to implement it diff --git a/apps/server/src/chat/invoke.test.ts b/apps/server/src/chat/invoke.test.ts index 3d3df0470..d84c2d8b7 100644 --- a/apps/server/src/chat/invoke.test.ts +++ b/apps/server/src/chat/invoke.test.ts @@ -953,6 +953,20 @@ test("a run that ends blocked during the Planner's own turn starts no follow-up expect(spoken(context, "plan-review ended blocked after 10 min.")).toBe(1); }); +test("a remote Planner's run that ends blocked between turns starts no turn nobody asked for", async () => { + let { context, user } = await setup(configured(ATOMIC)); + let planner = runningSession(); + let remote = Object.assign(planner.session, { followsUpRuns: false }); + context.openPlannerSession = async () => ({ ok: true, value: remote }); + await Chat.invoke(context, user, "Run the workflow"); + await context.chat.running; + expect(context.chat.retained).toBeDefined(); + planner.set({ active: [], paused: [], cards: [card("blocked")] }); + while (!planner.calls.includes("destroy")) await tick(); + expect(followUps(planner.calls)).toHaveLength(0); + expect(spoken(context, "plan-review ended blocked after 10 min.")).toBe(1); +}); + test("the last job finishing while a reply is still being saved still lets the Planner go afterwards", async () => { let { context, user } = await setup(configured(ATOMIC)); let planner = runningSession(); diff --git a/apps/server/src/chat/remote-planner.test.ts b/apps/server/src/chat/remote-planner.test.ts new file mode 100644 index 000000000..45f6804f1 --- /dev/null +++ b/apps/server/src/chat/remote-planner.test.ts @@ -0,0 +1,343 @@ +import { afterEach, expect, test } from "bun:test"; + +import { ActiveOwnerBindings } from "../agent/active-owner"; +import { createPlannerAgent } from "../harness/agents"; +import { createRemoteAdapter } from "../harness/remote/adapter"; +import { notAttachedMessage } from "../harness/remote/attachments"; +import { openPlannerSession } from "../harness/session"; +import * as Plan from "../plan/service"; +import { configured } from "../testing/config"; +import { + attach, + finishTurn, + linkAuth, + linkServer, + RIVAL_TOKEN, + scriptedPlanner, +} from "../testing/planner-link"; +import * as Chat from "./service"; + +import type { Server } from "bun"; +import type { PlannerLinkClient } from "@chopin/planner-link/client"; +import type { HostedAuth } from "../auth/routes"; +import type { Socket, SocketData } from "../wire"; + +let cleanups: Array<() => unknown> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +const REMOTE = { HARNESS: "remote", BACKGROUND_JOBS: "off" }; +const RIVAL = { id: "U_rival", login: "rival" }; + +function card(status: "running" | "paused") { + return { + id: "run-1", + name: "plan-review", + status, + started: 1, + updated: 1, + stages: [], + waiting: 0, + }; +} + +/** + * A document's Chat under `HARNESS=remote`, served as `main.ts` serves it, + * with a real Planner link server. The rival sends from a browser with a + * login of their own; only the attached member may own the Planner. + */ +async function remoteRoom() { + let { auth, storage, channel, logins, revocations, signOut, clock } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + revocations.add(id => server.attachments.sessionRevoked(id)); + let lease = (await storage.leases.acquire("writer", "test", 60_000))!; + let events: Array<{ kind: string; [key: string]: unknown }> = []; + let publisher = { + publish(_topic: string, message: string) { + events.push(JSON.parse(message)); + }, + } as unknown as Server; + let plan = await Plan.open(channel.id, { + storage, + lease: () => lease, + fatal: error => { + throw error; + }, + }, publisher); + let owners = new ActiveOwnerBindings(auth as unknown as HostedAuth); + let url = `https://chopin.test/documents/octo-org/score/${channel.slug}`; + let agent = createPlannerAgent(createRemoteAdapter()); + let context: Chat.Room = { + chat: plan.chat, + plan, + room: channel.id, + server: publisher, + auth: auth as unknown as HostedAuth, + repository: { id: "R_score", owner: "octo-org", name: "score", defaultBranch: "main" }, + config: configured(REMOTE), + claimantSessionId: logins.get("rival"), + plannerNotAttached: async () => + await server.attachments.confirmed(channel.id) + ? undefined + : notAttachedMessage(url, "octo-org/score"), + activeOwner: () => owners.resolve(channel.id), + persist: () => Plan.persist(plan), + openPlannerSession: (binding, options) => + openPlannerSession(binding, options, { + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => + ({ + defaultWorkingDirectory: "/tmp", + run: async () => ({ exitCode: 0, stdout: "", stderr: "" }), + destroy: async () => {}, + }) as never, + registerCredential: () => () => {}, + agent, + }), + }; + cleanups.push(async () => { + owners.revokeAll(); + await Chat.close(plan.chat); + await Plan.close(plan); + }); + let replies: Array<{ kind: string; rid?: string; [key: string]: unknown }> = []; + let ws = { + data: { handle: RIVAL.login, principalId: RIVAL.id, sessionId: logins.get("rival") }, + send(text: string) { + replies.push(JSON.parse(text)); + }, + } as unknown as Socket; + let send = async (text: string, to: "planner" | "room" = "planner") => { + let rid = crypto.randomUUID(); + await Chat.send(context, ws, { + kind: "chat:send", + rid, + requestId: crypto.randomUUID(), + to, + text, + ts: 0, + } as never); + return replies.find(reply => reply.rid === rid); + }; + let owner = async () => + (await storage.channels.readAgent(channel.id, new Date()))?.agent?.ownerSessionId; + return { + context, + server, + storage, + channel, + logins, + url, + ws, + send, + owner, + events, + signOut, + clock, + }; +} + +/** + * The room with the member attached as its Planner and every browser sign-in + * expired, before the link's periodic recheck or any cleanup has noticed, + * counting what Chat saves from then on. + */ +async function expiredOwnerRoom() { + let room = await remoteRoom(); + let member = await attach(room.server.url, room.channel.id, scriptedPlanner().handlers); + cleanups.push(() => member.detach()); + expect(await room.owner()).toBe(room.logins.get("member")); + room.clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + let saves = 0; + let persist = room.context.persist; + room.context.persist = async chat => { + saves++; + await persist(chat); + }; + return { ...room, member, saves: () => saves }; +} + +test("with no attached Planner, @chopin and invoke_planner are refused with how to attach one, and nothing is posted or queued", async () => { + let room = await remoteRoom(); + let { context } = room; + let saves = 0; + let persist = context.persist; + context.persist = async chat => { + saves++; + await persist(chat); + }; + let message = + "No Planner is attached to this document. To attach one, connect a Planner client to " + + `${room.url} over the Planner link from a session in a checkout of octo-org/score; ` + + "that session becomes this document's Planner."; + + expect(await room.send("@chopin draft the plan")).toMatchObject({ + kind: "session:error", + code: "planner-not-attached", + message, + }); + context.chat.busy = true; + expect(await room.send("@chopin and then this")).toMatchObject({ code: "planner-not-attached" }); + context.chat.busy = false; + expect(await Chat.invoke(context, RIVAL, "Draft the plan")).toBe("planner-not-attached"); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + expect(saves).toBe(0); + expect(await room.owner()).toBeUndefined(); + + expect(await room.send("Just talking", "room")).toMatchObject({ kind: "chat:send" }); + expect(context.chat.entries.map(entry => entry.text)).toEqual(["Just talking"]); +}); + +test("other harnesses keep claiming ownership for the sender as before", async () => { + let room = await remoteRoom(); + let { context } = room; + context.config = configured({}); + context.plannerNotAttached = undefined; + let started: string[] = []; + context.openPlannerSession = async () => ({ + ok: true, + value: { + stream: async (prompt: string) => { + started.push(prompt); + return { fullStream: (async function*() {})() }; + }, + destroy: async () => {}, + } as never, + }); + expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "chat:send" }); + await context.chat.running; + expect(started).toHaveLength(1); + expect(await room.owner()).toBe(room.logins.get("rival")); +}); + +test("an attached Planner receives @chopin and invoke_planner turns as their owner, and Stop and Resume reach it", async () => { + let room = await remoteRoom(); + let { context } = room; + let script = scriptedPlanner(); + let controls: Array<{ type: string; run?: string }> = []; + let client: PlannerLinkClient; + client = await attach(room.server.url, room.channel.id, { + ...script.handlers, + async turn(turn) { + script.turns.push(turn.message); + if (turn.message.prompt.includes("Run the review")) { + client.reportRuns(turn.message.session, { + active: ["run-1"], + paused: [], + cards: [card("running")], + }); + } + finishTurn(turn); + }, + control(message) { + controls.push({ type: message.type, run: message.run }); + client.reportRuns( + message.session, + message.type === "stop" + ? { active: [], paused: ["run-1"], cards: [card("paused")] } + : { active: ["run-1"], paused: [], cards: [card("running")] }, + ); + }, + }); + cleanups.push(() => client.detach()); + expect(await room.owner()).toBe(room.logins.get("member")); + + expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "chat:send" }); + await context.chat.running; + expect(await Chat.invoke(context, RIVAL, "Run the review")).toBeUndefined(); + await context.chat.running; + expect(script.turns.map(turn => turn.prompt)).toEqual([ + expect.stringContaining("draft the plan"), + expect.stringContaining("Run the review"), + ]); + expect(await room.owner()).toBe(room.logins.get("member")); + expect(context.chat.retained).toBeDefined(); + + await Chat.abort(context, room.ws); + await Bun.sleep(20); + await Chat.resume(context, room.ws); + expect(controls).toEqual([{ type: "stop", run: undefined }, { type: "resume", run: undefined }]); + expect(context.chat.entries.map(entry => entry.text)).toContain( + "@rival stopped the Planner and paused its workflows.", + ); + expect(context.chat.entries.map(entry => entry.text)).toContain( + "@rival resumed the Planner's workflows.", + ); +}); + +test("once the attached Planner's owner sign-in expires, @chopin is refused with how to attach one before its periodic recheck, and nothing is posted or queued", async () => { + let room = await expiredOwnerRoom(); + let { context } = room; + expect(await room.send("@chopin draft the plan")).toMatchObject({ + kind: "session:error", + code: "planner-not-attached", + message: "No Planner is attached to this document. To attach one, connect a Planner client to " + + `${room.url} over the Planner link from a session in a checkout of octo-org/score; ` + + "that session becomes this document's Planner.", + }); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + expect(room.saves()).toBe(0); + expect(room.events).toEqual([]); + expect(await room.member.closed).toEqual({ code: 4401, reason: "sign-in-required" }); + expect(room.server.attachments.attached(room.channel.id)).toBe(false); + expect(await room.owner()).toBeUndefined(); +}); + +test("once the attached Planner's owner sign-in expires, invoke_planner is refused as planner-not-attached before its periodic recheck", async () => { + let room = await expiredOwnerRoom(); + let { context } = room; + expect(await Chat.invoke(context, RIVAL, "Draft the plan")).toBe("planner-not-attached"); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + expect(room.saves()).toBe(0); + expect(await room.member.closed).toEqual({ code: 4401, reason: "sign-in-required" }); + expect(await room.owner()).toBeUndefined(); +}); + +test("once the attached Planner's owner signs out or is reset, @chopin and invoke_planner are refused again, and another member can attach", async () => { + let room = await remoteRoom(); + let { context } = room; + let refusals = async () => { + expect(await room.send("@chopin draft the plan")).toMatchObject({ + kind: "session:error", + code: "planner-not-attached", + }); + expect(await Chat.invoke(context, RIVAL, "Draft the plan")).toBe("planner-not-attached"); + expect(context.chat.entries).toEqual([]); + expect(context.chat.waiting).toEqual([]); + }; + + let member = await attach(room.server.url, room.channel.id, scriptedPlanner().handlers); + expect(await room.owner()).toBe(room.logins.get("member")); + await room.signOut("member"); + expect(await member.closed).toEqual({ code: 4401, reason: "sign-in-required" }); + expect(await room.owner()).toBeUndefined(); + await refusals(); + + let script = scriptedPlanner(); + let rival = await attach(room.server.url, room.channel.id, script.handlers, RIVAL_TOKEN); + expect(await room.owner()).toBe(room.logins.get("rival")); + let held = (await room.storage.channels.readAgent(room.channel.id, new Date()))!.agent!; + await room.storage.channels.clearAgentOwner( + room.channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + room.server.attachments.ownerReset(room.channel.id); + expect(await rival.closed).toEqual({ code: 4403, reason: "access-revoked" }); + await refusals(); + expect(script.turns).toEqual([]); + + let again = await attach(room.server.url, room.channel.id, script.handlers, RIVAL_TOKEN); + cleanups.push(() => again.detach()); + expect(await room.send("@chopin draft the plan")).toMatchObject({ kind: "chat:send" }); + await context.chat.running; + expect(script.turns.map(turn => turn.prompt)).toEqual([ + expect.stringContaining("draft the plan"), + ]); +}); diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index 1c4f18b10..eb4e0f79d 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -533,6 +533,12 @@ export type Room = { */ claimantSessionId: string | undefined; repository: HostedRepository; + /** + * Under `HARNESS=remote`, how to attach a Planner when none is attached, or + * undefined while one is. Planner requests are refused before anything is + * posted or queued, and only an attachment takes Planner ownership. + */ + plannerNotAttached?: () => Promise; activeOwner?: () => Promise; persist: (chat?: () => Pick) => Promise; commitRoomMessage?: (entry: Wire.Entry) => Promise; @@ -596,6 +602,7 @@ export async function invoke( checkout?: string, ): Promise< | "planner-unavailable" + | "planner-not-attached" | "planner-owner-unavailable" | "planner-queue-full" | "checkout-unverified" @@ -606,13 +613,14 @@ export async function invoke( chat.pendingSends++; let post = async () => { if (!context.config.agent || chat.closed) return "planner-unavailable" as const; + if (await context.plannerNotAttached?.()) return "planner-not-attached" 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 { - await resolveOwner(context.auth, context.repository, room, context.claimantSessionId); + await resolveOwner(context.auth, context.repository, room, claimant(context)); } catch { return "planner-owner-unavailable" as const; } @@ -774,6 +782,10 @@ async function processSend(context: Room, ws: Socket, msg: Request): return; } + let unattached = await context.plannerNotAttached?.(); + if (unattached) return fail(ws, msg.rid, unattached, "planner-not-attached"); + if (chat.closed) return fail(ws, msg.rid, "chat is closed"); + if (chat.busy) { if (chat.waiting.length >= MAX_QUEUE) { say(chat, server, room, { @@ -1389,7 +1401,8 @@ function retain( let stopWatching = opened.session.watchRuns?.(next => { if (chat.retained?.session !== opened.session) return; let planning = chat.busy && !chat.job && chat.agent === opened.session; - void publishRuns(context, opened.session, next, false, !planning); + let followUp = !planning && opened.session.followsUpRuns !== false; + void publishRuns(context, opened.session, next, false, followUp); if (next.active.length || next.paused.length) return; let releaseWhenIdle = async () => { await publishing.get(chat); @@ -1620,7 +1633,7 @@ async function repositorySession( context.auth, context.repository, context.room, - claimantSessionId, + context.plannerNotAttached ? undefined : claimantSessionId, ); if (context.ownerAvailable) void context.ownerAvailable().catch(() => {}); let { chat, auth } = context; @@ -1672,7 +1685,7 @@ async function repositorySession( workspace, visualRouting(context), ), - model: context.config.model, + model: context.config.model || undefined, harness: context.config.harness, }); if (!result.ok) throw new Error(`Planner session unavailable (${result.error.kind})`); @@ -1705,6 +1718,11 @@ async function repositorySession( } } +/** The login that may claim Planner ownership for the room; none where only an attachment may. */ +function claimant(context: Room): string | undefined { + return context.plannerNotAttached ? undefined : context.claimantSessionId; +} + /** * 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. diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 60fc83d15..25762ca70 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -101,6 +101,31 @@ describe("configuration", () => { }); }); + it("accepts a remote Planner on a public bind with no MODEL, and only without background jobs", () => { + let remote = { HARNESS: "remote", SERVER_HOST: "0.0.0.0", BACKGROUND_JOBS: "off" }; + let config = configured(remote); + expect(config).toMatchObject({ harness: "remote", host: "0.0.0.0", model: "" }); + expect(description(config)).toContain("agent: chosen by the attached client"); + expect(description(config)).toContain( + "Planner: attached client over /planner-link (model and client tools run on its machine)", + ); + expect(configured({ ...remote, MODEL: "openai/gpt-6" }).model).toBe("openai/gpt-6"); + expect(() => configured({ HARNESS: "remote" })) + .toThrow("HARNESS=remote requires BACKGROUND_JOBS=off"); + }); + + it("keeps a detached remote Planner's ownership two minutes unless PLANNER_ATTACH_GRACE_MS says otherwise", () => { + let remote = { HARNESS: "remote", BACKGROUND_JOBS: "off" }; + expect(configured(remote).plannerAttachGraceMs).toBe(120_000); + expect(configured({ ...remote, PLANNER_ATTACH_GRACE_MS: "0" }).plannerAttachGraceMs).toBe(0); + expect(configured({ ...remote, PLANNER_ATTACH_GRACE_MS: "3600000" }).plannerAttachGraceMs) + .toBe(3_600_000); + for (let value of ["", "-1", "1.5", "3600001", "soon"]) { + expect(() => configured({ ...remote, PLANNER_ATTACH_GRACE_MS: value })) + .toThrow("PLANNER_ATTACH_GRACE_MS must be an integer between 0 and 3600000 milliseconds"); + } + }); + 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]) { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 510457eb7..0424b7637 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -16,7 +16,7 @@ import type { StorageConfig } from "./storage/registry"; export type Config = { host: string; port: number; - /** Planner model. */ + /** Planner model. Empty under `HARNESS=remote` unless set, where the attached client chooses. */ model: string; harness: string; harnessAuth: string | undefined; @@ -25,6 +25,11 @@ export type Config = { * passed to the CLI's `--extension`. Split on the platform path delimiter. */ harnessExtensions: string[]; + /** + * Under `HARNESS=remote`, how long a detached or dropped Planner link keeps + * the document's Planner ownership for the same account to reattach. + */ + plannerAttachGraceMs: number; /** * Whether to run the agent at all. * @@ -59,14 +64,17 @@ export type Config = { }; const DEFAULT_PORT = 8787; +const DEFAULT_ATTACH_GRACE_MS = 120_000; const DEFAULT_MODEL = "gpt-6-luna"; /** The default is a Copilot model ID. Pi and Atomic resolve IDs against their * own catalogs, so those deployments must name a model rather than inherit one - * the catalog lacks. */ + * the catalog lacks. A remote Planner's client brings its own model; a + * configured one is only passed along as the turn's request. */ function model(harness: string): string { let configured = process.env.MODEL; if (configured) return configured; + if (harness === "remote") return ""; if (harness === "pi" || harness === "atomic") { throw new Error(`MODEL is required when HARNESS=${harness}`); } @@ -137,12 +145,28 @@ export function load(): Config { ) { throw new Error("JEV_MODEL must be a valid model alias"); } + let graceRaw = process.env.PLANNER_ATTACH_GRACE_MS; + let plannerAttachGraceMs = graceRaw === undefined ? DEFAULT_ATTACH_GRACE_MS : Number(graceRaw); + if ( + (graceRaw !== undefined && !/^\d+$/.test(graceRaw)) + || !Number.isSafeInteger(plannerAttachGraceMs) || plannerAttachGraceMs > 3_600_000 + ) { + throw new Error( + "PLANNER_ATTACH_GRACE_MS must be an integer between 0 and 3600000 milliseconds", + ); + } let selection = harnessSelection(); + if (selection.harness === "remote" && backgroundJobs) { + throw new Error( + "HARNESS=remote requires BACKGROUND_JOBS=off: summary and research workers have no model on the server", + ); + } let serverPort = port(); return { ...selection, port: serverPort, model: model(selection.harness), + plannerAttachGraceMs, agent, backgroundJobs, webResearch: agent && backgroundJobs && process.env.WEB_RESEARCH !== "off", @@ -175,11 +199,16 @@ export function describe(config: Config): string { "chopin", `http://${config.host}:${config.port}`, config.devClient ? `client: vite (${config.devClient})` : "client: built", - config.agent ? `agent: ${config.model} (on demand)` : "agent: off", + config.agent + ? `agent: ${config.model || "chosen by the attached client"} (on demand)` + : "agent: off", `harness: ${config.harness}`, ...(config.harness === "atomic" ? ["Planner: full Atomic session (shell and filesystem access as this process's user)"] : []), + ...(config.harness === "remote" + ? ["Planner: attached client over /planner-link (model and client tools run on its machine)"] + : []), ...(config.harnessExtensions.length ? [`Planner extensions: ${config.harnessExtensions.join(", ")}`] : []), diff --git a/apps/server/src/conversation-plan/accepted-research.ts b/apps/server/src/conversation-plan/accepted-research.ts index 136f63f50..0bf0861ea 100644 --- a/apps/server/src/conversation-plan/accepted-research.ts +++ b/apps/server/src/conversation-plan/accepted-research.ts @@ -13,6 +13,8 @@ type AcceptedResearchDeps = { refreshAccess: (ws: Socket, force: boolean) => Promise; unavailable: (id: string) => boolean; ownerAvailable: (id: string) => Promise; + /** False under `HARNESS=remote`, where only an attached Planner takes ownership. */ + claimsOwnership?: boolean; placeReference: (channelId: string, workspaceId: string) => Promise<"placed" | "deferred">; scheduleRecovery: (deferred: number) => void; }; @@ -44,7 +46,12 @@ export async function startAcceptedResearch( || deps.unavailable(room.id) ) return false; try { - await Chat.resolveOwner(deps.auth, repository, room.id, ws.data.sessionId); + await Chat.resolveOwner( + deps.auth, + repository, + room.id, + deps.claimsOwnership === false ? undefined : ws.data.sessionId, + ); return true; } catch { return false; diff --git a/apps/server/src/harness/agents.ts b/apps/server/src/harness/agents.ts index ecea371cd..a15e1ea34 100644 --- a/apps/server/src/harness/agents.ts +++ b/apps/server/src/harness/agents.ts @@ -46,10 +46,18 @@ type PlannerCallOptions = { githubTools: ToolSet; instructions: string; model?: string; + /** + * Also stops the host tools, beside the caller's signal: a remote Planner's turn can + * end, or its link drop, while one of them still runs. + */ + toolSignal?: AbortSignal; }; -/** Tells the chat when a host tool's own execution ends; the harness reports its result only at step end. */ -function reporting(tools: ToolSet, room: DocumentRoom): ToolSet { +/** + * Tells the chat when a host tool's own execution ends; the harness reports its result + * only at step end. A `toolSignal` stops the tool as the caller's signal does. + */ +function reporting(tools: ToolSet, room: DocumentRoom, toolSignal?: AbortSignal): ToolSet { return Object.fromEntries( Object.entries(tools).map(([name, original]) => { let execute = original.execute; @@ -58,8 +66,12 @@ function reporting(tools: ToolSet, room: DocumentRoom): ToolSet { ...original, execute: async (input, options) => { let output: Awaited>; + let stops = [options.abortSignal, toolSignal].filter(signal => signal !== undefined); try { - output = await execute(input, options); + output = await execute( + input, + toolSignal ? { ...options, abortSignal: AbortSignal.any(stops) } : options, + ); } catch (error) { room.plan.chat?.toolFinished?.(options.toolCallId, error, false); throw error; @@ -98,6 +110,7 @@ export function createPlannerAgent( tools: reporting( scopedJobTools({ ...rest.tools, ...options.githubTools }, options.room), options.room, + options.toolSignal, ), toolsContext: Object.fromEntries(names.map(name => [name, { room: options.room, diff --git a/apps/server/src/harness/atomic/full.ts b/apps/server/src/harness/atomic/full.ts index 4cf23e980..7a98f11c1 100644 --- a/apps/server/src/harness/atomic/full.ts +++ b/apps/server/src/harness/atomic/full.ts @@ -60,7 +60,7 @@ export async function resumeOwnedRuns(workflows: SessionWorkflows): Promise, runId: string, signal: AbortSignal, ): Promise { diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index e5df16b11..f900e2ac0 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -11,23 +11,31 @@ import type { } from "@bastani/atomic"; import type { DocumentRoom } from "../../agent/tools"; +export type HumanInputStatus = "answered" | "expired" | "cancelled"; + /** * 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. An answer to a workflow * question is returned only once `hold` allows it, so a paused run stays stopped. + * `report` learns how each request ended, which the cancelled result alone + * cannot tell apart. */ export function createHumanInput( room: DocumentRoom, expiresInMs = limits.INPUT_EXPIRY_MS, hold?: (workflowRunId: string, signal: AbortSignal) => Promise, + report?: (status: HumanInputStatus) => void, ): HostInput { async function questionnaire( params: QuestionParams, options: HostInputOptions, ): Promise { - if (options.signal.aborted) return { answers: [], cancelled: true }; + if (options.signal.aborted) { + report?.("cancelled"); + return { answers: [], cancelled: true }; + } let definition = Questions.identify({ questions: params.questions.map(question => ({ header: question.header, @@ -54,11 +62,15 @@ export function createHumanInput( expiresInMs, ); if (options.signal.aborted || ended.some(outcome => outcome.status === "expired")) { + report?.(options.signal.aborted ? "cancelled" : "expired"); return { answers: [], cancelled: true }; } if (hold && options.workflowRunId !== undefined) { await hold(options.workflowRunId, options.signal); - if (options.signal.aborted) return { answers: [], cancelled: true }; + if (options.signal.aborted) { + report?.("cancelled"); + return { answers: [], cancelled: true }; + } } let answers: QuestionAnswer[] = []; ended.forEach((outcome, questionIndex) => { @@ -88,7 +100,9 @@ export function createHumanInput( }); } }); - return { answers, cancelled: ended.some(outcome => outcome.status === "cancelled") }; + let cancelled = ended.some(outcome => outcome.status === "cancelled"); + report?.(cancelled ? "cancelled" : "answered"); + return { answers, cancelled }; } async function single( header: string, diff --git a/apps/server/src/harness/harnesses.test.ts b/apps/server/src/harness/harnesses.test.ts index 1751c071b..073eeb990 100644 --- a/apps/server/src/harness/harnesses.test.ts +++ b/apps/server/src/harness/harnesses.test.ts @@ -6,8 +6,8 @@ import { harnesses, harnessFor, registerCredential, shutdownHarnesses } from "./ afterEach(shutdownHarnesses); -it("selects the tested Copilot SDK, Pi, and Atomic harnesses", () => { - expect(Object.keys(harnesses)).toEqual(["copilot-sdk", "pi", "atomic"]); +it("selects the tested Copilot SDK, Pi, Atomic, and remote harnesses", () => { + expect(Object.keys(harnesses)).toEqual(["copilot-sdk", "pi", "atomic", "remote"]); expect(harnessFor({ harness: "copilot-sdk" }).harnessId).toBe("copilot-sdk"); expect(() => harnessFor({ harness: "not-installed" })).toThrow("Unknown harness: not-installed"); }); @@ -68,6 +68,16 @@ it("refuses Atomic's host-login auto mode on a non-loopback bind but accepts ai- .toBe("atomic"); }); +it("accepts the remote harness on any bind without HARNESS_AUTH, and refuses one", () => { + for (let host of ["0.0.0.0", "::", "192.168.1.2", "127.0.0.1"]) { + expect(harnessFor({ harness: "remote", host }).harnessId).toBe("remote"); + expect(() => harnessFor({ harness: "remote", harnessAuth: "ai-gateway", host })) + .toThrow("HARNESS_AUTH does not apply to HARNESS=remote"); + } + expect(() => harnessFor({ harness: "remote", harnessExtensions: [tmpdir()] })) + .toThrow("HARNESS_EXTENSIONS requires HARNESS=atomic, not remote"); +}); + it("accepts only existing absolute extension paths, and only for the atomic harness", () => { let atomic = { harness: "atomic", harnessAuth: "auto", host: "127.0.0.1" }; expect(() => harnessFor({ ...atomic, harnessExtensions: ["relative/package"] })) diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index 685456f42..f10365956 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -5,6 +5,7 @@ import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; import { forgetWorkspaces } from "./atomic/workspace"; import { createCopilotSdk } from "./copilot-sdk/adapter"; import { createPiAdapter } from "./pi/adapter"; +import { createRemoteAdapter } from "./remote/adapter"; import type { HarnessV1 } from "@ai-sdk/harness"; import type { AtomicAuthMode } from "./atomic/adapter"; @@ -30,10 +31,16 @@ function createAtomicHarness( }); } +/** The model runs in a client attached over the Planner link, so the server holds no credential. */ +function createRemoteHarness(): HarnessV1 & { shutdown(): Promise } { + return createRemoteAdapter(); +} + export const harnesses = { "copilot-sdk": createCopilotSdk, pi: createPiHarness, atomic: createAtomicHarness, + remote: createRemoteHarness, } satisfies Record< string, (settings: { @@ -89,6 +96,11 @@ export function harnessFor(config: { } let extensions = checkedExtensions(config.harness, config.harnessExtensions); let auth = config.harnessAuth; + if (config.harness === "remote" && auth) { + throw new Error( + "HARNESS_AUTH does not apply to HARNESS=remote: the model runs in the attached client", + ); + } let isLoopback = loopback(config.host ?? "127.0.0.1"); let modes = AUTH_MODES.get(config.harness); if (modes) { diff --git a/apps/server/src/harness/remote.contract.test.ts b/apps/server/src/harness/remote.contract.test.ts new file mode 100644 index 000000000..7d03f5b13 --- /dev/null +++ b/apps/server/src/harness/remote.contract.test.ts @@ -0,0 +1,39 @@ +import { afterAll, expect, it } from "bun:test"; + +import { harnessContract } from "./contract"; +import { createRemoteAdapter } from "./remote/adapter"; +import { attach, linkAuth, linkServer, nodeClient, scriptedPlanner } from "../testing/planner-link"; + +let { auth, channel } = await linkAuth(); +let server = linkServer(auth); +let client = await attach(server.url, channel.id, scriptedPlanner().handlers); +afterAll(async () => { + await client.detach(); + server.stop(); +}); + +harnessContract("remote", () => + createRemoteAdapter(() => ({ + link: server.links.at(-1)!, + planner: { channelId: channel.id, isolated: false }, + }))); + +let nodeLink = await linkAuth(); +let nodeServer = linkServer(nodeLink.auth); +let node = await nodeClient(nodeServer.url, nodeLink.channel.id); +afterAll(async () => { + await node?.stop(); + nodeServer.stop(); +}); + +if (node) { + it("attaches a Node.js client that uses only the documented wire format", () => { + expect(node.attached).toBe(nodeLink.channel.id); + expect(nodeServer.links).toHaveLength(1); + }); + harnessContract(`remote over Node.js ${node.node}`, () => + createRemoteAdapter(() => ({ + link: nodeServer.links.at(-1)!, + planner: { channelId: nodeLink.channel.id, isolated: false }, + }))); +} diff --git a/apps/server/src/harness/remote/adapter.ts b/apps/server/src/harness/remote/adapter.ts new file mode 100644 index 000000000..c18c5f9ea --- /dev/null +++ b/apps/server/src/harness/remote/adapter.ts @@ -0,0 +1,61 @@ +import { attachedLink, remotePlanner } from "./registry"; + +import type { HarnessV1, HarnessV1Session, HarnessV1StartOptions } from "@ai-sdk/harness"; +import type { PlannerLink } from "./link"; +import type { RemotePlanner } from "./registry"; + +export type RemoteTarget = { link: PlannerLink; planner: RemotePlanner }; + +/** + * Finds the attached client and Chopin's record for a session. Only sessions + * Chopin registered for a document's Planner have one. + */ +export function attachedTarget(sessionId: string): RemoteTarget { + let planner = remotePlanner(sessionId); + if (!planner) { + throw new Error( + "HARNESS=remote runs only Planner sessions; this session has no document to attach to.", + ); + } + let link = attachedLink(planner.channelId); + if (!link) throw new Error("No Planner is attached to this document."); + return { link, planner }; +} + +/** + * `HarnessV1` whose sessions run in a client attached over the Planner link. + * The server keeps no model and no client tools: it forwards each session and + * turn, and runs Chopin's host tools when the client calls them. + */ +export function createRemoteAdapter( + resolve: (sessionId: string) => RemoteTarget = attachedTarget, +): HarnessV1 & { shutdown(): Promise } { + let open = new Set(); + return { + specificationVersion: "harness-v1", + harnessId: "remote", + builtinTools: {}, + supportsBuiltinToolFiltering: true, + + async doStart(options: HarnessV1StartOptions): Promise { + if (options.resumeFrom || options.continueFrom) { + throw new Error("The remote Planner cannot resume a session."); + } + let { link, planner } = resolve(options.sessionId); + let session = await link.startSession(options.sessionId, planner); + let handle: HarnessV1Session = { + ...session, + async doDestroy() { + open.delete(handle); + await session.doDestroy(); + }, + }; + open.add(handle); + return handle; + }, + + async shutdown() { + await Promise.allSettled([...open].map(session => session.doDestroy())); + }, + }; +} diff --git a/apps/server/src/harness/remote/attachments.ts b/apps/server/src/harness/remote/attachments.ts new file mode 100644 index 000000000..ac7ec3e2a --- /dev/null +++ b/apps/server/src/harness/remote/attachments.ts @@ -0,0 +1,397 @@ +/** + * Which client is each document's remote Planner. + * + * Attaching makes the client's account the document's Planner owner through + * the same generation-guarded storage claim a browser owner takes elsewhere. + * The owner is the account's live browser login, because Chopin's repository + * tools act with that login's GitHub App token, never with the link's bearer. + * A dropped or detached link keeps its ownership for a grace period, so the + * same account can reattach without losing it; then the ownership is cleared. + * Ending that login, resetting the document's Planner, or any other release of + * the stored ownership closes the link and frees the document at once. + */ + +import { GitHubError } from "../../github/client"; +import { attachLink } from "./registry"; + +import type { Admission } from "../../auth/admission"; +import type { AuthConfig } from "../../auth/config"; +import type { AuthenticatedSession, Sessions } from "../../auth/session"; +import type { GitHub } from "../../github/client"; +import type { StorageAdapter } from "../../storage/port"; +import type { PlannerLink } from "./link"; + +export type AttachmentAuth = { + config: Pick; + storage: Pick; + github: Pick; + admission: Pick; + sessions: Pick; + clock: () => Date; +}; + +export type AttachRequest = { + channelId: string; + repository: { id: string; owner: string; name: string }; + user: { id: string; login: string }; +}; + +export type AttachRefusal = { + ok: false; + code: "planner-attached" | "sign-in-required" | "repository-forbidden" | "access-revoked"; + message: string; +}; + +export type AttachGrant = { ok: true; detach: () => void }; + +type Hold = { + channelId: string; + userId: string; + login: string; + ownerSessionId: string; + generation: number; + link?: PlannerLink; + forget?: () => void; + timer?: ReturnType; + releaseAt?: Date; +}; + +type PendingAttach = { + login: string; + held?: Hold; + sessionIds: Set; + invalidated?: AttachRefusal; +}; + +/** What `@chopin` and `invoke_planner` say when no Planner is attached to the document. */ +export function notAttachedMessage(url: string, repository: string): string { + return `No Planner is attached to this document. To attach one, connect a Planner client to ` + + `${url} over the Planner link from a session in a checkout of ${repository}; ` + + `that session becomes this document's Planner.`; +} + +export function forbiddenMessage(repository: string): string { + return `Attaching as this document's Planner needs push or administration access to ${repository}.`; +} + +export class PlannerAttachments { + #auth: AttachmentAuth; + #graceMs: number; + #holds = new Map(); + #pending = new Map(); + #queues = new Map>(); + + constructor(auth: AttachmentAuth, graceMs: number) { + this.#auth = auth; + this.#graceMs = graceMs; + } + + /** Whether a client is attached to the document now, not merely holding it through a grace period. */ + attached(channelId: string): boolean { + return this.#holds.get(channelId)?.link !== undefined; + } + + /** + * Whether a client is attached and its ownership still stands, checked as + * the link's periodic recheck does. A stale attachment is closed and + * released here, so a Planner request never proceeds on it. + */ + async confirmed(channelId: string): Promise { + let link = this.#holds.get(channelId)?.link; + return link !== undefined && await this.verify(channelId, link); + } + + /** The GitHub login holding the document's Planner, attached or within its grace period. */ + holder(channelId: string): string | undefined { + return this.#holds.get(channelId)?.login; + } + + /** Makes `link` the document's Planner and its account the owner, or says why not. */ + attach(request: AttachRequest, link: PlannerLink): Promise { + return this.#serialized(request.channelId, () => this.#attach(request, link)); + } + + /** The browser login ended: every document it owns through an attachment is released. */ + async sessionRevoked(sessionId: string): Promise { + let origin = this.#auth.config.origin; + for (let pending of this.#pending.values()) { + if (!pending.sessionIds.has(sessionId)) continue; + pending.invalidated ??= pending.held?.ownerSessionId === sessionId + ? signInEndedRefusal(origin, pending.login) + : signInRefusal(origin, pending.login); + } + let held = [...this.#holds.values()].filter(hold => hold.ownerSessionId === sessionId); + for (let hold of held) this.#invalidate(hold, signInEndedRefusal(origin, hold.login)); + await Promise.all(held.map(hold => this.#release(hold))); + } + + /** A repository writer reset the document's Planner, which already cleared its stored owner. */ + ownerReset(channelId: string): void { + let pending = this.#pending.get(channelId); + if (pending) pending.invalidated ??= resetRefusal(pending.login); + let hold = this.#holds.get(channelId); + if (hold) this.#invalidate(hold, resetRefusal(hold.login)); + } + + /** + * Whether `link` still holds the document: its login is still live and the + * stored owner is still its attachment's login and generation. + * Otherwise the link closes and the document is free to attach. + */ + async verify(channelId: string, link: PlannerLink): Promise { + let hold = this.#holds.get(channelId); + if (hold?.link !== link) return false; + let stored = await this.#auth.storage.channels.readAgent(channelId, this.#auth.clock()); + let live = await this.#auth.sessions.resolve(hold.ownerSessionId); + if (this.#holds.get(channelId) !== hold || hold.link !== link) return false; + if (!live) { + this.#invalidate(hold, signInEndedRefusal(this.#auth.config.origin, hold.login)); + await this.#release(hold); + return false; + } + if ( + stored?.agent?.ownerSessionId === hold.ownerSessionId + && stored.agent.generation === hold.generation + ) return true; + this.#invalidate(hold, { + ok: false, + code: "access-revoked", + message: `This document's Planner ownership was released, so ${hold.login} is no longer ` + + `attached. Attach again to resume.`, + }); + return false; + } + + /** Stops every grace timer. Ownership stays in storage, which startup clears. */ + close(): void { + for (let hold of this.#holds.values()) clearTimeout(hold.timer); + this.#holds.clear(); + } + + async #attach(request: AttachRequest, link: PlannerLink): Promise { + let { channelId, user } = request; + let held = this.#holds.get(channelId); + if (held && (held.link || held.userId !== user.id)) return attachedRefusal(held); + let pending: PendingAttach = { + login: user.login, + held, + sessionIds: new Set(held ? [held.ownerSessionId] : []), + }; + this.#pending.set(channelId, pending); + try { + return await this.#attachPending(request, link, pending); + } finally { + if (this.#pending.get(channelId) === pending) this.#pending.delete(channelId); + } + } + + /** + * Takes or keeps the document for `request` while `pending` collects every + * reset or ended login that lands during its awaits, so a hold binds only + * if nothing released its ownership in the meantime. + */ + async #attachPending( + request: AttachRequest, + link: PlannerLink, + pending: PendingAttach, + ): Promise { + let { channelId, user } = request; + let login = await this.#eligibleLogin(request, pending); + if ("ok" in login) return login; + if (pending.invalidated) return pending.invalidated; + let now = this.#auth.clock(); + let held = pending.held; + if (held && this.#holds.get(channelId) === held) { + let stored = await this.#auth.storage.channels.readAgent(channelId, now); + let live = await this.#auth.sessions.resolve(held.ownerSessionId); + if (pending.invalidated) return pending.invalidated; + if ( + live && this.#holds.get(channelId) === held + && stored?.agent?.ownerSessionId === held.ownerSessionId + && stored.agent.generation === held.generation + ) return this.#bind(held, link); + this.#drop(held); + if (!live) await this.#release(held); + } + if (pending.invalidated) return pending.invalidated; + let claimed; + try { + claimed = await this.#claim(channelId, login.session.id, now); + } catch (error) { + // Storage refuses a claim for a sign-in that ended after its last check. + if (pending.invalidated) return pending.invalidated; + if (!await this.#auth.sessions.resolve(login.session.id)) { + return signInRefusal(this.#auth.config.origin, user.login); + } + throw error; + } + if (!claimed) throw new Error("The document's Planner owner changed while attaching."); + let hold: Hold = { + channelId, + userId: user.id, + login: user.login, + ownerSessionId: login.session.id, + generation: claimed.generation, + }; + let live = await this.#auth.sessions.resolve(hold.ownerSessionId); + let refusal = pending.invalidated + ?? (live ? undefined : signInRefusal(this.#auth.config.origin, user.login)); + if (refusal) { + await this.#release(hold); + return refusal; + } + this.#holds.set(channelId, hold); + return this.#bind(hold, link); + } + + /** + * The caller's browser login, registered with `pending` before its access + * checks so that its sign-out during them refuses the attach. + */ + async #eligibleLogin( + request: AttachRequest, + pending: PendingAttach, + ): Promise { + let { repository, user } = request; + let fullName = `${repository.owner}/${repository.name}`; + let login = await this.#auth.sessions.forUser(user.id); + if (!login) return signInRefusal(this.#auth.config.origin, user.login); + pending.sessionIds.add(login.session.id); + let checked; + try { + checked = await this.#auth.sessions.use( + login, + token => this.#auth.github.repositoryAccess(token, repository.owner, repository.name), + ); + } catch (error) { + if (error instanceof GitHubError && error.status === 401) { + return signInRefusal(this.#auth.config.origin, user.login); + } + throw error; + } + let access = checked.value; + if ( + !access || access.id !== repository.id + || (!access.permissions.push && !access.permissions.admin) + || !await this.#auth.admission.allowed(checked.authenticated.access.token, user.id) + ) return { ok: false, code: "repository-forbidden", message: forbiddenMessage(fullName) }; + return checked.authenticated; + } + + /** + * Claims the document for `sessionId`. Under the remote Planner only an + * attachment takes ownership, so an owner no attachment holds is stale and + * is cleared, guarded by its generation, before claiming again. + */ + async #claim(channelId: string, sessionId: string, now: Date) { + let channels = this.#auth.storage.channels; + let state = await channels.claimAgentOwner(channelId, sessionId, now); + if (state.ownerSessionId === sessionId) return state; + if (state.ownerSessionId) { + await channels.clearAgentOwner(channelId, state.ownerSessionId, state.generation, now); + } + state = await channels.claimAgentOwner(channelId, sessionId, now); + return state.ownerSessionId === sessionId ? state : undefined; + } + + #bind(hold: Hold, link: PlannerLink): AttachGrant { + clearTimeout(hold.timer); + hold.timer = undefined; + hold.releaseAt = undefined; + hold.link = link; + hold.forget = attachLink(hold.channelId, link); + return { ok: true, detach: () => this.#detached(hold, link) }; + } + + #detached(hold: Hold, link: PlannerLink): void { + if (hold.link !== link) return; + hold.link = undefined; + hold.forget?.(); + hold.forget = undefined; + if (this.#holds.get(hold.channelId) !== hold) return; + hold.releaseAt = new Date(this.#auth.clock().getTime() + this.#graceMs); + hold.timer = setTimeout(() => void this.#expire(hold), this.#graceMs); + hold.timer.unref?.(); + } + + #expire(hold: Hold): Promise { + return this.#serialized(hold.channelId, async () => { + if (this.#holds.get(hold.channelId) !== hold || hold.link) return; + this.#drop(hold); + await this.#release(hold); + }); + } + + /** Forgets the hold before its link closes, so closing starts no grace period. */ + #invalidate(hold: Hold, refusal: AttachRefusal): void { + if (this.#holds.get(hold.channelId) !== hold) return; + this.#drop(hold); + hold.link?.revoke(refusal.code, refusal.message); + } + + async #release(hold: Hold): Promise { + try { + await this.#auth.storage.channels.clearAgentOwner( + hold.channelId, + hold.ownerSessionId, + hold.generation, + this.#auth.clock(), + ); + } catch (error) { + console.error("[planner-link] releasing the Planner owner failed:", error); + } + } + + #drop(hold: Hold): void { + clearTimeout(hold.timer); + hold.timer = undefined; + if (this.#holds.get(hold.channelId) === hold) this.#holds.delete(hold.channelId); + } + + #serialized(channelId: string, action: () => Promise): Promise { + let previous = this.#queues.get(channelId) ?? Promise.resolve(); + let next = previous.then(action); + let settled = next.then(() => {}, () => {}); + this.#queues.set(channelId, settled); + void settled.then(() => { + if (this.#queues.get(channelId) === settled) this.#queues.delete(channelId); + }); + return next; + } +} + +function resetRefusal(login: string): AttachRefusal { + return { + ok: false, + code: "access-revoked", + message: `A repository writer reset this document's Planner, so ${login} is no longer ` + + `attached. Attach again to resume.`, + }; +} + +function attachedRefusal(hold: Hold): AttachRefusal { + let message = hold.link || !hold.releaseAt + ? `${hold.login} is attached as this document's Planner. A document has one Planner at a time.` + : `${hold.login} detached from this document's Planner and holds it until ` + + `${hold.releaseAt.toISOString()} unless they reattach. A document has one Planner at a time.`; + return { ok: false, code: "planner-attached", message }; +} + +function signInRefusal(origin: string, login: string): AttachRefusal { + return { + ok: false, + code: "sign-in-required", + message: `Sign in to Chopin at ${origin} in a browser as ${login}, then attach again. ` + + `The Planner's repository tools act with that sign-in.`, + }; +} + +function signInEndedRefusal(origin: string, login: string): AttachRefusal { + return { + ok: false, + code: "sign-in-required", + message: `The Chopin browser sign-in that made ${login} this document's Planner ended, so the ` + + `Planner was released. Sign in to Chopin at ${origin} in a browser as ${login}, then ` + + `attach again.`, + }; +} diff --git a/apps/server/src/harness/remote/endpoint.ts b/apps/server/src/harness/remote/endpoint.ts new file mode 100644 index 000000000..59531b47e --- /dev/null +++ b/apps/server/src/harness/remote/endpoint.ts @@ -0,0 +1,182 @@ +/** + * `/planner-link`: where a client attaches as a document's Planner. + * + * The bearer is a GitHub token checked as hosted MCP checks it, before the + * socket upgrades. The `attach` that follows names a document whose repository + * its account can push to or administer, and makes that account the document's + * Planner owner. Nothing is posted to the document by attaching or refusing. + */ + +import { AdmissionDenied } from "../../auth/admission"; +import { documentUrl } from "../../channels/document-url"; +import { GitHubError } from "../../github/client"; +import { locateDocument } from "../../mcp/hosted"; +import { forbiddenMessage } from "./attachments"; +import { PlannerLink } from "./link"; + +import type { ServerWebSocket } from "bun"; +import type { HostedAuth } from "../../auth/routes"; +import type { HostedCaller } from "../../mcp/hosted"; +import type { PlannerAttachments } from "./attachments"; +import type { AttachOutcome, LinkOptions, LinkSocket } from "./link"; + +export type PlannerLinkAuth = Pick; + +function writable(repository: { permissions: { push: boolean; admin: boolean } }): boolean { + return repository.permissions.push || repository.permissions.admin; +} + +export type PlannerLinkSocketData = { + plannerLink: true; + sidebar?: undefined; + caller: HostedCaller; + link?: PlannerLink; +}; + +export type PlannerLinkHooks = { + /** One Planner per document, and its ownership. */ + attachments: PlannerAttachments; + /** A document being deleted cannot be attached. */ + unavailable?: (channelId: string) => boolean; + /** Observes attached links. */ + register?: LinkOptions["register"]; + timeoutMs?: number; + revalidateEveryMs?: number; +}; + +const BEARER = /^Bearer ([A-Za-z0-9._~+/-]+=*)$/i; + +function refusal(status: number, text: string): Response { + let headers = new Headers({ + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + if (status === 401) headers.set("www-authenticate", "Bearer"); + return new Response(text, { status, headers }); +} + +function revoked(err: unknown): boolean { + return err instanceof AdmissionDenied || err instanceof GitHubError && err.status === 401; +} + +/** + * The caller for an upgrade request, or the HTTP refusal; nothing is upgraded on refusal. + * A server that does not run the remote Planner answers 404 rather than its web client. + */ +export async function admitPlannerLink( + request: Request, + auth: PlannerLinkAuth, + enabled = true, +): Promise { + if (!enabled) return refusal(404, "This Chopin does not accept Planner links."); + let origin = request.headers.get("origin"); + if (origin !== null && origin !== auth.config.origin) { + return refusal(403, "origin is not allowed"); + } + let token = request.headers.get("authorization")?.match(BEARER)?.[1]; + if (!token) return refusal(401, "unauthorized"); + try { + return { + plannerLink: true, + caller: { oauthToken: token, user: await auth.admission.user(token) }, + }; + } catch (err) { + if (err instanceof AdmissionDenied) return refusal(403, "forbidden"); + if (err instanceof GitHubError) { + if (err.status === 401) return refusal(401, "unauthorized"); + if (err.status === 429 || err.status >= 500) { + return refusal(503, "GitHub access is temporarily unavailable"); + } + } + console.error("[planner-link] admission failed:", err); + return refusal(500, "Planner link request failed"); + } +} + +/** A link for one admitted caller, attaching only documents whose repository it can write. */ +export function plannerLink( + caller: HostedCaller, + auth: PlannerLinkAuth, + socket: LinkSocket, + hooks: PlannerLinkHooks, +): PlannerLink { + let channelId: string | undefined; + let link: PlannerLink; + async function attach(message: { document: string }): Promise { + let located = await locateDocument(auth, caller, message.document); + if (located === "forbidden") { + return { + ok: false, + code: "repository-forbidden", + message: "Your GitHub account cannot read this document's repository.", + }; + } + if (!located || hooks.unavailable?.(located.channel.id)) { + return { + ok: false, + code: "document-unavailable", + message: "No document matches that id or URL.", + }; + } + let { channel, repository } = located; + if (channel.archivedAt) { + return { ok: false, code: "document-archived", message: "This document is archived." }; + } + let fullName = `${repository.owner}/${repository.name}`; + if (!writable(repository)) { + return { ok: false, code: "repository-forbidden", message: forbiddenMessage(fullName) }; + } + let url = new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href; + let granted = await hooks.attachments.attach({ + channelId: channel.id, + repository: { id: repository.id, owner: repository.owner, name: repository.name }, + user: { id: caller.user.id, login: caller.user.login }, + }, link); + if (!granted.ok) return granted; + channelId = channel.id; + return { + ok: true, + document: { id: channel.id, title: channel.title, repository: fullName, url }, + detach: granted.detach, + }; + } + async function revalidate(): Promise { + if (!channelId) return false; + try { + await auth.admission.user(caller.oauthToken); + let located = await locateDocument(auth, caller, channelId); + return typeof located === "object" && located.channel.id === channelId + && writable(located.repository) + && !located.channel.archivedAt && !hooks.unavailable?.(channelId) + && await hooks.attachments.verify(channelId, link); + } catch (err) { + if (revoked(err)) return false; + throw err; + } + } + link = new PlannerLink(socket, { + attach, + revalidate, + register: hooks.register, + timeoutMs: hooks.timeoutMs, + revalidateEveryMs: hooks.revalidateEveryMs, + }); + return link; +} + +export function isPlannerLinkSocket( + ws: ServerWebSocket, +): ws is ServerWebSocket { + return (ws.data as { plannerLink?: unknown } | undefined)?.plannerLink === true; +} + +export function openPlannerLinkSocket( + ws: ServerWebSocket, + auth: PlannerLinkAuth, + hooks: PlannerLinkHooks, +): void { + ws.data.link = plannerLink(ws.data.caller, auth, { + send: text => void ws.send(text), + close: (code, reason) => ws.close(code, reason), + }, hooks); +} diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts new file mode 100644 index 000000000..70cb7a0c6 --- /dev/null +++ b/apps/server/src/harness/remote/link.test.ts @@ -0,0 +1,1271 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { tool } from "ai"; +import { z } from "zod"; +import { LINK_CLOSE, MAX_LINK_MESSAGE_BYTES, PLANNER_LINK_VERSION } from "@chopin/planner-link"; +import { PlannerLinkRefused } from "@chopin/planner-link/client"; + +import { GitHubError } from "../../github/client"; +import * as Store from "../../questions/store"; +import { createPlannerAgent } from "../agents"; +import { createHumanInput } from "../atomic/human-input"; +import { openPlannerSession } from "../session"; +import { untilUnpaused } from "../atomic/full"; +import { hostInputRoom } from "../../testing/decisions"; +import { + attach, + DENIED_TOKEN, + finishTurn, + GUEST_TOKEN, + linkAuth, + linkServer, + MEMBER_TOKEN, + OUTSIDER_TOKEN, + rawLink, + RIVAL_TOKEN, + scriptedPlanner, + VIEWER_TOKEN, +} from "../../testing/planner-link"; +import { createRemoteAdapter } from "./adapter"; +import { admitPlannerLink } from "./endpoint"; +import { remotePlanner } from "./registry"; + +import type { PlannerLinkClient, PlannerLinkHandlers } from "@chopin/planner-link/client"; +import type { ActiveOwnerBinding } from "../../agent/active-owner"; +import type { RemotePlanner } from "./registry"; + +let cleanups: Array<() => unknown> = []; +afterEach(async () => { + for (let cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +async function setup(handlers?: Partial) { + let { auth, storage, channel } = await linkAuth(); + let server = linkServer(auth, 300); + cleanups.push(server.stop); + let script = scriptedPlanner(); + let client: PlannerLinkClient | undefined; + if (handlers !== undefined) { + client = await attach(server.url, channel.id, { ...script.handlers, ...handlers }); + cleanups.push(() => client!.detach()); + } + return { auth, storage, channel, server, script, client: client! }; +} + +function adapterFor(server: ReturnType, planner: RemotePlanner) { + return createRemoteAdapter(() => ({ link: server.links.at(-1)!, planner })); +} + +const sandboxSession = { + defaultWorkingDirectory: "/tmp", + async run() { + return { exitCode: 0, stdout: "", stderr: "" }; + }, + async destroy() {}, +} as never; + +let readPlanRuns = 0; + +function agentFor(harness: ReturnType) { + return new HarnessAgent({ + harness, + tools: { + read_plan: tool({ + inputSchema: z.object({}), + execute: async () => { + readPlanRuns++; + return "the plan"; + }, + }), + }, + activeTools: ["read_plan"], + permissionMode: "allow-reads", + }); +} + +type Agent = ReturnType; + +/** Streams one turn of an open session to its end, returning its parts and any error it ended with. */ +async function streamOn( + agent: Agent, + session: Awaited>, + prompt: string, +) { + let parts: { type: string; [key: string]: unknown }[] = []; + let failure: unknown; + try { + let result = await agent.stream({ session, prompt }); + for await (let part of result.fullStream) parts.push(part as never); + } catch (error) { + failure = error; + } + let errors = parts.filter(part => part.type === "error").map(part => String(part.error)); + if (failure) errors.push(String(failure)); + return { parts, errors, session }; +} + +/** Streams one turn of a new session to its end. */ +async function streamTurn(harness: ReturnType, prompt: string) { + let agent = agentFor(harness); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + return streamOn(agent, session, prompt); +} + +/** Attaches a raw link and answers the next `start`, returning the session it names. */ +async function rawAttached(server: ReturnType, document: string) { + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: 1, document }); + await raw.next("attached"); + let started = raw.next("start").then(start => { + raw.send({ type: "started", session: start.session }); + return start.session as string; + }); + return { raw, started }; +} + +function upgradeRequest(url: string, headers: Record = {}) { + return fetch(`${url}/planner-link`, { + headers: { + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-version": "13", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + ...headers, + }, + }); +} + +describe("Planner link admission", () => { + it("checks the bearer as hosted MCP does and upgrades nothing it refuses", async () => { + let { server, channel } = await setup(); + let missing = await upgradeRequest(server.url); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toBe("Bearer"); + expect((await upgradeRequest(server.url, { authorization: "Bearer bad-token" })).status) + .toBe(401); + expect((await upgradeRequest(server.url, { authorization: `Bearer ${DENIED_TOKEN}` })).status) + .toBe(403); + expect( + (await upgradeRequest(server.url, { + authorization: `Bearer ${MEMBER_TOKEN}`, + origin: "https://elsewhere.test", + })).status, + ).toBe(403); + await expect(attach(server.url, channel.id, scriptedPlanner().handlers, "bad-token")) + .rejects.toThrow("closed before attaching"); + expect(server.links).toEqual([]); + }); + + it("answers 404 where the server does not run the remote Planner", async () => { + let { auth } = await linkAuth(); + let request = new Request("https://chopin.test/planner-link", { + headers: { authorization: `Bearer ${MEMBER_TOKEN}`, upgrade: "websocket" }, + }); + let refused = await admitPlannerLink(request, auth, false); + expect(refused).toBeInstanceOf(Response); + expect((refused as Response).status).toBe(404); + expect(await admitPlannerLink(request, auth, true)).toMatchObject({ plannerLink: true }); + }); + + it("refuses a repository the caller cannot read, a missing or archived document, and another version", async () => { + let { server, channel, storage } = await setup(); + let before = await storage.channels.get(channel.id); + let refusal = (token: string, document: string) => + attach(server.url, document, scriptedPlanner().handlers, token).then( + () => undefined, + (error: PlannerLinkRefused) => error.code, + ); + expect(await refusal(OUTSIDER_TOKEN, channel.id)).toBe("repository-forbidden"); + expect(await refusal(MEMBER_TOKEN, crypto.randomUUID())).toBe("document-unavailable"); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION + 1, document: channel.id }); + expect(await raw.next("refused")).toMatchObject({ code: "unsupported-version" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unsupportedVersion); + expect(await storage.channels.get(channel.id)).toEqual(before); + await storage.channels.archive({ id: channel.id, now: new Date() }); + expect(await refusal(MEMBER_TOKEN, channel.id)).toBe("document-archived"); + expect(server.links).toEqual([]); + }); + + it("attaches a member to the document by id or URL", async () => { + let { server, channel } = await setup(); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + expect(client.document).toEqual({ + id: channel.id, + title: "Linked document", + repository: "octo-org/score", + url: `https://chopin.test/documents/octo-org/score/${channel.slug}`, + }); + await client.detach(); + let byUrl = await attach(server.url, client.document.url!, scriptedPlanner().handlers); + expect(byUrl.document.id).toBe(channel.id); + expect(server.links.map(link => link.channelId)).toEqual([channel.id]); + await byUrl.detach(); + expect(server.links).toEqual([]); + }); +}); + +describe("Planner link ownership", () => { + /** The code, message, and close code an attach is refused with. */ + async function refused(url: string, document: string, token: string) { + let raw = await rawLink(url, token); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document }); + let refusal = await raw.next("refused"); + return { code: refusal.code, message: refusal.message, close: (await raw.closed).code }; + } + + async function owner( + storage: Awaited>["storage"], + channelId: string, + ) { + return (await storage.channels.readAgent(channelId, new Date()))?.agent; + } + + it("makes the attaching member the owner through their browser login, replacing an unattached owner", async () => { + let { auth, storage, channel, logins } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + let stale = await storage.channels.claimAgentOwner( + channel.id, + logins.get("rival")!, + new Date(), + ); + expect(stale).toMatchObject({ ownerSessionId: logins.get("rival"), generation: 1 }); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + cleanups.push(() => client.detach()); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("member"), generation: 2, status: "ready" }); + expect(server.attachments.attached(channel.id)).toBe(true); + expect(server.attachments.holder(channel.id)).toBe("member"); + }); + + it("refuses a second attach while a Planner is attached, naming the holder", async () => { + let { auth, storage, channel, logins } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + cleanups.push(() => client.detach()); + let held = await owner(storage, channel.id); + expect(held).toMatchObject({ ownerSessionId: logins.get("member"), generation: 1 }); + for (let token of [RIVAL_TOKEN, MEMBER_TOKEN]) { + expect(await refused(server.url, channel.id, token)).toEqual({ + code: "planner-attached", + message: "member is attached as this document's Planner. " + + "A document has one Planner at a time.", + close: LINK_CLOSE.attached, + }); + } + expect(await owner(storage, channel.id)).toEqual(held); + expect(server.links).toHaveLength(1); + }); + + it("refuses an account that cannot push or has no browser login, and takes no ownership", async () => { + let { auth, storage, channel } = await linkAuth(); + let server = linkServer(auth); + cleanups.push(server.stop); + expect(await refused(server.url, channel.id, VIEWER_TOKEN)).toEqual({ + code: "repository-forbidden", + message: "Attaching as this document's Planner needs push or administration access to " + + "octo-org/score.", + close: LINK_CLOSE.forbidden, + }); + expect(await refused(server.url, channel.id, GUEST_TOKEN)).toEqual({ + code: "sign-in-required", + message: "Sign in to Chopin at https://chopin.test in a browser as guest, then attach " + + "again. The Planner's repository tools act with that sign-in.", + close: LINK_CLOSE.unauthorized, + }); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + expect(server.links).toEqual([]); + }); + + it("keeps ownership through the grace period for the same member, then releases it", async () => { + let { auth, storage, channel, logins } = await linkAuth(); + let server = linkServer(auth, 2_000, undefined, 400); + cleanups.push(server.stop); + let first = await attach(server.url, channel.id, scriptedPlanner().handlers); + await first.detach(); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("member"), generation: 1 }); + let refusal = await refused(server.url, channel.id, RIVAL_TOKEN); + expect(refusal).toMatchObject({ code: "planner-attached", close: LINK_CLOSE.attached }); + expect(refusal.message).toStartWith( + "member detached from this document's Planner and holds it until ", + ); + + let dropped = await rawLink(server.url); + dropped.send({ type: "attach", version: PLANNER_LINK_VERSION, document: channel.id }); + await dropped.next("attached"); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("member"), generation: 1 }); + dropped.socket.close(); + await dropped.closed; + await Bun.sleep(100); + expect((await owner(storage, channel.id))?.ownerSessionId).toBe(logins.get("member")); + for (let deadline = Date.now() + 3_000; Date.now() < deadline;) { + if (!(await owner(storage, channel.id))?.ownerSessionId) break; + await Bun.sleep(25); + } + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: undefined, generation: 1, status: "unavailable" }); + + let rival = await attach(server.url, channel.id, scriptedPlanner().handlers, RIVAL_TOKEN); + cleanups.push(() => rival.detach()); + expect(await owner(storage, channel.id)) + .toMatchObject({ ownerSessionId: logins.get("rival"), generation: 2 }); + }); + + it("detaches a link whose account loses push access while it stays attached", async () => { + let { auth, channel } = await linkAuth(); + let server = linkServer(auth, 300, 20); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + let repository = auth.github.repository; + auth.github.repository = async (token, owner, name) => ({ + ...await repository(token, owner, name), + permissions: { pull: true, push: false, admin: false }, + }); + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); + expect(server.links).toEqual([]); + }); + + /** A member's raw link attached to the document, with ended logins reaching `attachments`. */ + async function attachedMember(revalidateEveryMs?: number) { + let linked = await linkAuth(); + let server = linkServer(linked.auth, 2_000, revalidateEveryMs, 2_000); + cleanups.push(server.stop); + linked.revocations.add(id => server.attachments.sessionRevoked(id)); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document: linked.channel.id }); + await raw.next("attached"); + expect(server.attachments.attached(linked.channel.id)).toBe(true); + return { ...linked, server, raw }; + } + + async function rivalAttaches( + url: string, + channelId: string, + storage: Awaited>["storage"], + rival: string, + ) { + let client = await attach(url, channelId, scriptedPlanner().handlers, RIVAL_TOKEN); + cleanups.push(() => client.detach()); + expect(await owner(storage, channelId)).toMatchObject({ ownerSessionId: rival }); + } + + it("releases the Planner at once when its owner signs out of the browser", async () => { + let { server, raw, channel, storage, logins, signOut } = await attachedMember(); + expect(await signOut("member")).toBe(logins.get("member")); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "sign-in-required", + message: "The Chopin browser sign-in that made member this document's Planner ended, so " + + "the Planner was released. Sign in to Chopin at https://chopin.test in a browser as " + + "member, then attach again.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.attachments.holder(channel.id)).toBeUndefined(); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("releases the Planner when its owner's browser sign-in expires", async () => { + let { server, raw, channel, storage, sessions, clock } = await attachedMember(); + clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + await sessions.cleanupExpired(); + expect(await raw.next("refused")).toMatchObject({ code: "sign-in-required" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + }); + + it("notices an expired owner sign-in on its periodic recheck when nothing revoked it", async () => { + let { server, raw, channel, storage, clock } = await attachedMember(20); + clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + expect(await raw.next("refused")).toMatchObject({ code: "sign-in-required" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + }); + + it("releases the Planner as sign-in-required when storage already sees its owner's sign-in expired", async () => { + let { auth, server, raw, channel, storage, clock } = await attachedMember(20); + let held = (await owner(storage, channel.id))!; + auth.clock = () => new Date(Date.now() + clock.skewMs); + clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + expect(await raw.next("refused")).toMatchObject({ + code: "sign-in-required", + message: expect.stringContaining("The Chopin browser sign-in that made member"), + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + clock.skewMs = 0; + expect(await owner(storage, channel.id)).toMatchObject({ + ownerSessionId: undefined, + generation: held.generation, + status: "unavailable", + }); + }); + + /** Holds the next attach's ownership read until `resume`, once `reading` says it started. */ + function pauseOwnerRead(storage: Awaited>["storage"]) { + let readAgent = storage.channels.readAgent; + let reading = Promise.withResolvers(); + let resume = Promise.withResolvers(); + storage.channels.readAgent = async (channelId, now) => { + storage.channels.readAgent = readAgent; + let snapshot = await readAgent(channelId, now); + reading.resolve(); + await resume.promise; + return snapshot; + }; + return { reading: reading.promise, resume: () => resume.resolve() }; + } + + /** A member detached within the grace period, reattaching on a raw link paused at its ownership read. */ + async function reattachingMember() { + let linked = await linkAuth(); + let server = linkServer(linked.auth, 2_000, undefined, 2_000); + cleanups.push(server.stop); + linked.revocations.add(id => server.attachments.sessionRevoked(id)); + let first = await attach(server.url, linked.channel.id, scriptedPlanner().handlers); + await first.detach(); + let held = (await owner(linked.storage, linked.channel.id))!; + let paused = pauseOwnerRead(linked.storage); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document: linked.channel.id }); + await paused.reading; + return { ...linked, server, raw, held, resume: paused.resume }; + } + + it("refuses a grace reattach whose ownership a reset released while it was attaching", async () => { + let { server, raw, channel, storage, logins, held, resume } = await reattachingMember(); + await storage.channels.clearAgentOwner( + channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + server.attachments.ownerReset(channel.id); + resume(); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "access-revoked", + message: "A repository writer reset this document's Planner, so member is no longer " + + "attached. Attach again to resume.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.forbidden); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.attachments.holder(channel.id)).toBeUndefined(); + expect(server.links).toEqual([]); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("refuses a grace reattach whose owner signed out while it was attaching", async () => { + let { server, raw, channel, storage, logins, signOut, resume } = await reattachingMember(); + expect(await signOut("member")).toBe(logins.get("member")); + resume(); + expect(await raw.next("refused")).toMatchObject({ + code: "sign-in-required", + message: expect.stringContaining("The Chopin browser sign-in that made member"), + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.unauthorized); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.links).toEqual([]); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + /** A member's fresh attach on a raw link, paused while GitHub checks its repository access. */ + async function checkingMember() { + let linked = await linkAuth(); + let server = linkServer(linked.auth); + cleanups.push(server.stop); + linked.revocations.add(id => server.attachments.sessionRevoked(id)); + let { github } = linked.auth; + let repositoryAccess = github.repositoryAccess; + let checking = Promise.withResolvers(); + let resume = Promise.withResolvers(); + github.repositoryAccess = async (token, repositoryOwner, name) => { + github.repositoryAccess = repositoryAccess; + checking.resolve(); + await resume.promise; + return repositoryAccess(token, repositoryOwner, name); + }; + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: PLANNER_LINK_VERSION, document: linked.channel.id }); + await checking.promise; + return { ...linked, server, raw, resume: () => resume.resolve() }; + } + + async function refusedSignIn( + attempt: Awaited>, + ) { + let { server, raw, channel, storage } = attempt; + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "sign-in-required", + message: "Sign in to Chopin at https://chopin.test in a browser as member, then attach " + + "again. The Planner's repository tools act with that sign-in.", + }); + expect(await raw.closed).toEqual({ code: LINK_CLOSE.unauthorized, reason: "sign-in-required" }); + expect(server.attachments.attached(channel.id)).toBe(false); + expect(server.attachments.holder(channel.id)).toBeUndefined(); + expect(server.links).toEqual([]); + expect((await owner(storage, channel.id))?.ownerSessionId).toBeUndefined(); + } + + it("refuses a fresh attach whose owner signed out while its repository access was checked", async () => { + let attempt = await checkingMember(); + expect(await attempt.signOut("member")).toBe(attempt.logins.get("member")); + attempt.resume(); + await refusedSignIn(attempt); + let { server, channel, storage, logins } = attempt; + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("refuses a fresh attach whose sign-in storage saw expire before it claimed ownership", async () => { + let attempt = await checkingMember(); + attempt.auth.clock = () => new Date(Date.now() + attempt.clock.skewMs); + attempt.clock.skewMs = 31 * 24 * 60 * 60 * 1_000; + attempt.resume(); + await refusedSignIn(attempt); + }); + + it("releases the attachment when a repository writer resets the document's Planner", async () => { + let { server, raw, channel, storage, logins } = await attachedMember(); + let held = (await owner(storage, channel.id))!; + await storage.channels.clearAgentOwner( + channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + server.attachments.ownerReset(channel.id); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "access-revoked", + message: "A repository writer reset this document's Planner, so member is no longer " + + "attached. Attach again to resume.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.forbidden); + expect(server.attachments.attached(channel.id)).toBe(false); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); + + it("closes a link whose stored ownership was released elsewhere on its next recheck", async () => { + let { server, raw, channel, storage, logins } = await attachedMember(20); + let held = (await owner(storage, channel.id))!; + await storage.channels.clearAgentOwner( + channel.id, + held.ownerSessionId!, + held.generation, + new Date(), + ); + expect(await raw.next("refused")).toEqual({ + type: "refused", + code: "access-revoked", + message: "This document's Planner ownership was released, so member is no longer " + + "attached. Attach again to resume.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.forbidden); + expect(server.attachments.attached(channel.id)).toBe(false); + await rivalAttaches(server.url, channel.id, storage, logins.get("rival")!); + }); +}); + +describe("Planner link protocol", () => { + it("closes the link on malformed or out-of-order messages", async () => { + let { server, channel } = await setup(); + for ( + let [frames, code] of [ + [["not json"], "malformed-message"], + [[{ type: "attach", version: 1, document: channel.id, extra: true }], "malformed-message"], + [[{ type: "started", session: "s" }], "protocol"], + [[ + { type: "attach", version: 1, document: channel.id }, + { type: "attach", version: 1, document: channel.id }, + ], "protocol"], + ] as const + ) { + let raw = await rawLink(server.url); + for (let frame of frames) raw.send(frame); + expect(await raw.next("error")).toMatchObject({ code }); + expect((await raw.closed).code).toBe(LINK_CLOSE.malformed); + } + expect(server.links).toEqual([]); + }); + + it("closes the link on an oversized message", async () => { + let { server } = await setup(); + let raw = await rawLink(server.url); + raw.send(JSON.stringify({ type: "detach", padding: "x".repeat(MAX_LINK_MESSAGE_BYTES) })); + expect(await raw.next("error")).toMatchObject({ code: "message-too-large" }); + expect((await raw.closed).code).toBe(LINK_CLOSE.tooLarge); + }); + + it("ends the turn with an error when the client disconnects mid-turn", async () => { + let { server, channel } = await setup(); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: 1, document: channel.id }); + await raw.next("attached"); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let harness = adapterFor(server, planner); + let started = (async () => { + let start = await raw.next("start"); + raw.send({ type: "started", session: start.session }); + await raw.next("turn"); + raw.socket.close(); + })(); + let outcome = await streamTurn(harness, "hang"); + await started; + expect(outcome.errors.join("\n")).toContain("disconnected during the turn"); + expect(outcome.session.hasUnfinishedTurn()).toBe(false); + expect(server.links).toEqual([]); + }); + + it("stops the runs a dropped link reported, so nothing waits on them", async () => { + let { server, channel, client } = await setup({}); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let reports: unknown[] = []; + planner.onRuns = runs => reports.push(runs); + let session = await adapterFor(server, planner).doStart({ + sessionId: "runs", + sandboxSession, + sessionWorkDir: "/tmp", + }); + cleanups.push(() => session.doDestroy()); + let card = { + id: "run", + name: "plan", + status: "running" as const, + started: 1, + updated: 1, + stages: [], + waiting: 0, + }; + let done = { ...card, id: "done", status: "finished" as const, ended: 2 }; + client.reportRuns("runs", { active: ["run"], paused: [], cards: [card, done] }); + await Bun.sleep(20); + expect(planner.runs?.active).toEqual(["run"]); + await client.detach(); + await Bun.sleep(20); + expect(planner.runs).toEqual({ + active: [], + paused: [], + cards: [{ ...card, status: "stopped" }, done], + }); + expect(reports).toHaveLength(2); + }); + + it("runs Chopin's tools in the server and the client's own tools only once declared", async () => { + let reports: unknown[] = []; + let { server, channel } = await setup({ + async turn(context) { + let host = await context.callHostTool("read_plan", {}); + let undeclared = context.message.prompt === "undeclared"; + if (!undeclared) context.offerTools(["bash"]); + context.emit({ + type: "tool-call", + toolCallId: "own-1", + toolName: "bash", + input: "{}", + providerExecuted: true, + }); + context.emit({ + type: "tool-result", + toolCallId: "own-1", + toolName: "bash", + result: host.output as string, + }); + finishTurn(context); + }, + }); + let planner: RemotePlanner = { + channelId: channel.id, + isolated: false, + toolReport: (...report) => reports.push(report), + }; + let declared = await streamTurn(adapterFor(server, planner), "declared"); + expect(declared.errors).toEqual([]); + expect(declared.parts.filter(part => part.type === "tool-result").map(part => part.output)) + .toEqual(["the plan", "the plan"]); + expect(planner.activeTools).toEqual(["read_plan", "bash"]); + expect(reports).toEqual([["own-1", "the plan", "done"]]); + let undeclared = await streamTurn(adapterFor(server, planner), "undeclared"); + expect(undeclared.errors.join("\n")).toContain("Planner tool boundary failure: bash"); + let isolated = await streamTurn( + adapterFor(server, { channelId: channel.id, isolated: true }), + "declared", + ); + expect(isolated.errors.join("\n")).toContain("An isolated Planner turn cannot offer bash"); + }); + + it("gives the server and the client the same error when a host result cannot cross the link", async () => { + let seen: unknown[] = []; + let { server, channel } = await setup({ + async turn(context) { + seen.push(await context.callHostTool("read_plan", {})); + finishTurn(context); + }, + }); + let agent = new HarnessAgent({ + harness: adapterFor(server, { channelId: channel.id, isolated: false }), + tools: { + read_plan: tool({ + inputSchema: z.object({}), + execute: async () => "x".repeat(MAX_LINK_MESSAGE_BYTES), + }), + }, + activeTools: ["read_plan"], + permissionMode: "allow-reads", + }); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + let outcome = await streamOn(agent, session, "large"); + let failure = { error: "read_plan returned more than the Planner link can carry." }; + expect(seen).toEqual([{ output: failure, isError: true }]); + expect(outcome.parts.filter(part => part.type === "tool-result" || part.type === "tool-error")) + .toMatchObject([{ toolName: "read_plan" }]); + expect(JSON.stringify(outcome.parts)).not.toContain("x".repeat(1_000)); + expect(JSON.stringify(outcome.parts)).toContain(failure.error); + }); + + it("fails a session the client refuses or never starts, and tells it to let go", async () => { + let destroyed: string[] = []; + let { server, channel } = await setup({ + async start(message) { + if (message.session === "refused") throw new Error("busy elsewhere"); + await Bun.sleep(1_000); + }, + destroy(message) { + destroyed.push(message.session); + }, + }); + let harness = adapterFor(server, { channelId: channel.id, isolated: false }); + let start = (sessionId: string) => + harness.doStart({ sessionId, sandboxSession, sessionWorkDir: "/tmp" }); + await expect(start("refused")).rejects.toThrow("refused the session: busy elsewhere"); + await expect(start("silent")).rejects.toThrow("did not start the session"); + await Bun.sleep(20); + expect(destroyed).toEqual(["refused", "silent"]); + }); + + it("carries Stop and Resume to the client and reports a refusal", async () => { + let controls: unknown[] = []; + let { server, channel } = await setup({ + control(message) { + controls.push({ type: message.type, run: message.run }); + if (message.type === "resume") throw new Error("nothing to resume"); + }, + }); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let session = await adapterFor(server, planner).doStart({ + sessionId: crypto.randomUUID(), + sandboxSession, + sessionWorkDir: "/tmp", + }); + cleanups.push(() => session.doDestroy()); + await planner.control!("stop", "run-1"); + await expect(planner.control!("resume")).rejects.toThrow("nothing to resume"); + expect(controls).toEqual([{ type: "stop", run: "run-1" }, { type: "resume", run: undefined }]); + await session.doDestroy(); + expect(planner.control).toBeUndefined(); + }); + + it("rechecks the caller's access before every session and detaches one that lost it", async () => { + let { server, channel, auth } = await setup({}); + let planner: RemotePlanner = { channelId: channel.id, isolated: false }; + let harness = adapterFor(server, planner); + let first = await harness.doStart({ + sessionId: "first", + sandboxSession, + sessionWorkDir: "/tmp", + }); + await first.doDestroy(); + auth.github.repository = async () => { + throw new GitHubError("Not Found", 404); + }; + await expect(harness.doStart({ sessionId: "second", sandboxSession, sessionWorkDir: "/tmp" })) + .rejects.toThrow("can no longer act for this document"); + expect(server.links).toEqual([]); + }); + + it("rechecks the caller's access before every turn of a kept session", async () => { + let { server, channel, auth } = await setup({}); + let agent = agentFor(adapterFor(server, { channelId: channel.id, isolated: false })); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + expect((await streamOn(agent, session, "first")).errors).toEqual([]); + auth.github.repository = async () => { + throw new GitHubError("Not Found", 404); + }; + let revoked = await streamOn(agent, session, "second"); + expect(revoked.errors.join("\n")).toContain("can no longer act for this document"); + expect(server.links).toEqual([]); + }); + + it("detaches a link whose access is lost while it stays attached", async () => { + let { auth, channel } = await linkAuth(); + let server = linkServer(auth, 300, 20); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + await Bun.sleep(60); + expect(server.links).toHaveLength(1); + auth.github.repository = async () => { + throw new GitHubError("Not Found", 404); + }; + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); + expect(server.links).toEqual([]); + }); + + it("detaches a link whose token GitHub rejects while admission is still cached", async () => { + let { server, channel, auth, client } = await setup({}); + let agent = agentFor(adapterFor(server, { channelId: channel.id, isolated: false })); + let session = await agent.createSession({ sandboxSession }); + cleanups.push(() => session.destroy()); + auth.github.repository = async () => { + throw new GitHubError("Bad credentials", 401); + }; + let revoked = await streamOn(agent, session, "second"); + expect(revoked.errors.join("\n")).toContain("can no longer act for this document"); + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); + expect(server.links).toEqual([]); + }); + + it("detaches on a rejected token at the periodic check but stays attached through an outage", async () => { + let { auth, channel } = await linkAuth(); + let server = linkServer(auth, 300, 20); + cleanups.push(server.stop); + let client = await attach(server.url, channel.id, scriptedPlanner().handlers); + auth.github.repository = async () => { + throw new GitHubError("Service Unavailable", 503); + }; + await Bun.sleep(80); + expect(server.links).toHaveLength(1); + auth.github.repository = async () => { + throw new GitHubError("Bad credentials", 401); + }; + expect(await client.closed).toEqual({ code: LINK_CLOSE.forbidden, reason: "access-revoked" }); + expect(server.links).toEqual([]); + }); + + it("refuses a host call id the turn already used, even after that call finished", async () => { + let { server, channel } = await setup(); + let { raw, started } = await rawAttached(server, channel.id); + let driving = (async () => { + let session = await started; + let turn = (await raw.next("turn")).turn; + let call = { + type: "host-tool-call", + session, + turn, + toolCallId: "same", + toolName: "read_plan", + input: "{}", + }; + raw.send(call); + expect(await raw.next("host-tool-result")).toMatchObject({ output: "the plan" }); + raw.send(call); + await raw.next("abort"); + })(); + readPlanRuns = 0; + let outcome = await streamTurn( + adapterFor(server, { channelId: channel.id, isolated: false }), + "repeat", + ); + await driving; + expect(outcome.errors.join("\n")).toContain("Tool call same was repeated"); + expect(readPlanRuns).toBe(1); + }); + + it("fails a turn that reuses a refused host call id or its own finished tool call id", async () => { + let { server, channel } = await setup(); + let harness = adapterFor(server, { channelId: channel.id, isolated: false }); + async function reuse( + script: ( + send: (message: object) => void, + raw: Awaited>, + ) => Promise, + ) { + let { raw, started } = await rawAttached(server, channel.id); + let driving = (async () => { + let session = await started; + let turn = (await raw.next("turn")).turn; + await script(message => raw.send({ session, turn, ...message }), raw); + raw.send({ type: "turn-end", session, turn, status: "finished" }); + await raw.next("abort"); + })(); + let outcome = await streamTurn(harness, "reuse"); + await driving; + raw.socket.close(); + await raw.closed; + return outcome.errors.join("\n"); + } + readPlanRuns = 0; + let refused = await reuse(async (send, raw) => { + let call = { type: "host-tool-call", toolCallId: "refused", input: "{}" }; + send({ ...call, toolName: "write_plan" }); + expect(await raw.next("host-tool-result")).toMatchObject({ + toolCallId: "refused", + output: { error: "write_plan is not available in this turn." }, + isError: true, + }); + send({ ...call, toolName: "read_plan" }); + }); + expect(refused).toContain("Tool call refused was repeated"); + expect(readPlanRuns).toBe(0); + let own = await reuse(async send => { + let call = { toolCallId: "own", toolName: "bash" }; + send({ type: "tools", names: ["bash"] }); + send({ + type: "part", + part: { type: "tool-call", ...call, input: "{}", providerExecuted: true }, + }); + send({ type: "part", part: { type: "tool-result", ...call, result: "done" } }); + send({ + type: "part", + part: { type: "tool-call", ...call, input: "{}", providerExecuted: true }, + }); + }); + expect(own).toContain("Tool call own was repeated"); + }); + + it("answers a host call for a session that has ended with an error result", async () => { + let { server, channel } = await setup(); + let { raw, started } = await rawAttached(server, channel.id); + let opened = await adapterFor(server, { channelId: channel.id, isolated: false }).doStart({ + sessionId: crypto.randomUUID(), + sandboxSession, + sessionWorkDir: "/tmp", + }); + let session = await started; + await opened.doDestroy(); + await raw.next("destroy"); + raw.send({ + type: "host-tool-call", + session, + turn: "gone", + toolCallId: "late", + toolName: "read_plan", + input: "{}", + }); + expect(await raw.next("host-tool-result")).toEqual({ + type: "host-tool-result", + session, + turn: "gone", + toolCallId: "late", + output: { error: "The session has ended." }, + isError: true, + }); + }); +}); + +describe("Planner link questions", () => { + async function questions(options: { expiresInMs?: number; isolated?: boolean } = {}) { + let f = await hostInputRoom(options.expiresInMs); + cleanups.push(f.close); + let { server, channel, client } = await setup({}); + let planner: RemotePlanner = { channelId: channel.id, isolated: options.isolated ?? false }; + if (!options.isolated) { + planner.humanInput = report => + createHumanInput( + f.room, + options.expiresInMs, + (runId, signal) => untilUnpaused(planner, runId, signal), + report, + ); + } + let sessionId = crypto.randomUUID(); + let session = await adapterFor(server, planner).doStart({ + sessionId, + sandboxSession, + sessionWorkDir: "/tmp", + }); + cleanups.push(() => session.doDestroy()); + return { f, client, sessionId, planner, session }; + } + + let params = { + questions: [{ + header: "Choice", + question: " Which store? ", + options: [ + { label: " Postgres ", description: " durable ", preview: "create table" }, + { label: "Memory", description: "fast" }, + ], + }], + }; + + it("asks the client's question as a Decision, verbatim, and returns the member's answer", async () => { + let { f, client, sessionId } = await questions(); + let asked = client.ask(sessionId, { method: "questionnaire", params }); + let [card] = await f.cards(1); + expect(card!.definition.questions[0]).toMatchObject({ + header: "Choice", + question: " Which store? ", + options: [ + { label: " Postgres ", description: " durable \n\ncreate table" }, + { label: "Memory", description: "fast" }, + ], + }); + await f.answer(card!.id, [0]); + expect(await asked).toMatchObject({ + status: "answered", + value: { + cancelled: false, + answers: [{ + questionIndex: 0, + question: " Which store? ", + kind: "option", + answer: " Postgres ", + preview: "create table", + }], + }, + }); + let confirm = client.ask(sessionId, { method: "confirm", title: "Ship?", message: "Now." }); + let [prompt] = await f.cards(1); + await f.answer(prompt!.id, [0]); + expect(await confirm).toMatchObject({ status: "answered", value: true }); + }); + + it("expires an unanswered question and leaves its card in Decisions marked expired", async () => { + let { f, client, sessionId } = await questions({ expiresInMs: 30 }); + let asked = await client.ask(sessionId, { method: "questionnaire", params }); + expect(asked).toMatchObject({ status: "expired", value: { answers: [], cancelled: true } }); + let records = [...f.plan.records.values()]; + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ status: "expired", resolver: "chopin" }); + }); + + it("withdraws the cards when the client cancels or the session ends", async () => { + let { f, client, sessionId, session } = await questions(); + let controller = new AbortController(); + let asked = client.ask(sessionId, { method: "questionnaire", params }, { + signal: controller.signal, + }); + let [card] = await f.cards(1); + controller.abort(); + expect(await asked).toMatchObject({ status: "cancelled" }); + expect(f.plan.records.get(card!.id)).toMatchObject({ status: "cancelled" }); + let pending = client.ask(sessionId, { method: "select", title: "Pick", choices: ["a", "b"] }); + let [second] = await f.cards(1); + await session.doDestroy(); + expect(await pending).toMatchObject({ status: "cancelled" }); + expect(f.plan.records.get(second!.id)).toMatchObject({ status: "cancelled" }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + }); + + it("holds a workflow answer until the client reports its root run unpaused", async () => { + let { f, client, sessionId, planner } = await questions(); + client.reportRuns(sessionId, { active: [], paused: ["root"], cards: [] }); + await Bun.sleep(20); + expect(planner.runs?.paused).toEqual(["root"]); + let settled = false; + let asked = client.ask(sessionId, { method: "questionnaire", params }, { + workflowRunId: "stage-run", + rootRunId: "root", + }).then(result => { + settled = true; + return result; + }); + let [card] = await f.cards(1); + await f.answer(card!.id, [1]); + await Bun.sleep(30); + expect(settled).toBe(false); + client.reportRuns(sessionId, { active: ["root"], paused: [], cards: [] }); + expect(await asked).toMatchObject({ + status: "answered", + value: { answers: [{ kind: "option", answer: "Memory" }] }, + }); + }); + + it("gives an isolated session no Decisions", async () => { + let { f, client, sessionId } = await questions({ isolated: true }); + expect(await client.ask(sessionId, { method: "questionnaire", params })).toMatchObject({ + status: "failed", + message: "Decisions are not available to this session.", + }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + }); + + it("closes the link when an answered input-request id is reused", async () => { + let f = await hostInputRoom(); + cleanups.push(f.close); + let { server, channel } = await setup(); + let { raw, started } = await rawAttached(server, channel.id); + let opened = await adapterFor(server, { + channelId: channel.id, + isolated: false, + humanInput: report => createHumanInput(f.room, undefined, undefined, report), + }).doStart({ sessionId: crypto.randomUUID(), sandboxSession, sessionWorkDir: "/tmp" }); + cleanups.push(() => opened.doDestroy()); + let request = { + type: "input-request", + request: "asked", + session: await started, + input: { method: "confirm", title: "Ship?", message: "" }, + }; + raw.send(request); + let [card] = await f.cards(1); + await f.answer(card!.id, [0]); + expect(await raw.next("input-result")).toMatchObject({ request: "asked", status: "answered" }); + raw.send(request); + expect(await raw.next("error")).toMatchObject({ + code: "protocol", + message: "Input request asked was repeated.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.malformed); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.size).toBe(1); + }); + + it("refuses a failed input-request id again until 1024 later ids have been used", async () => { + let { server, channel } = await setup(); + let raw = await rawLink(server.url); + raw.send({ type: "attach", version: 1, document: channel.id }); + await raw.next("attached"); + let ask = (request: string) => + raw.send({ + type: "input-request", + request, + session: "none", + input: { method: "confirm", title: "Ship?", message: "" }, + }); + let ids = ["first", ...Array.from({ length: 1_024 }, (_, index) => `later-${index}`)]; + for (let id of ids) ask(id); + for (let id of ids) { + expect(await raw.next("input-result")).toMatchObject({ request: id, status: "failed" }); + } + ask("first"); + expect(await raw.next("input-result")).toMatchObject({ request: "first", status: "failed" }); + ask("later-1"); + expect(await raw.next("error")).toMatchObject({ + code: "protocol", + message: "Input request later-1 was repeated.", + }); + expect((await raw.closed).code).toBe(LINK_CLOSE.malformed); + }); + + it("withdraws a question asked while a session starts when the start fails or times out", async () => { + let f = await hostInputRoom(); + cleanups.push(f.close); + let asks: Promise<{ status: string }>[] = []; + let client: PlannerLinkClient | undefined; + let linked = await setup({ + async start(message) { + asks.push(client!.ask(message.session, { method: "confirm", title: "Ship?", message: "" })); + await f.cards(1); + if (message.session === "refused") throw new Error("busy elsewhere"); + await Bun.sleep(1_000); + }, + }); + client = linked.client; + let planner: RemotePlanner = { + channelId: f.room.id, + isolated: false, + humanInput: report => createHumanInput(f.room, undefined, undefined, report), + }; + let harness = adapterFor(linked.server, planner); + let start = (sessionId: string) => + harness.doStart({ sessionId, sandboxSession, sessionWorkDir: "/tmp" }); + await expect(start("refused")).rejects.toThrow("refused the session: busy elsewhere"); + expect(await asks[0]).toMatchObject({ status: "cancelled" }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + await expect(start("silent")).rejects.toThrow("did not start the session"); + expect(await asks[1]).toMatchObject({ status: "cancelled" }); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect([...f.plan.records.values()].map(record => record.status)) + .toEqual(["cancelled", "cancelled"]); + }); + + /** Opens a real remote Planner session over the link whose turn waits in a host `ask`. */ + async function askingTurn(ending: "detach" | "violation" | "destroy" | "turn-end") { + let f = await hostInputRoom(); + cleanups.push(f.close); + let { server, client } = await setup({ + async turn(context) { + let asked = context.callHostTool("ask", { + revision: f.plan.revision, + questions: [{ + header: "Store", + question: "Which store?", + options: [{ label: "Postgres", description: "durable" }], + multiple: false, + blocks: [], + }], + }); + if (ending !== "turn-end") return void await asked; + await f.cards(1); + context.end("failed", "The client gave up."); + }, + }); + let agent = createPlannerAgent( + createRemoteAdapter(sessionId => ({ + link: server.links.at(-1)!, + planner: remotePlanner(sessionId)!, + })), + ); + let toolSignal: AbortSignal | undefined; + let owner = { + channelId: f.room.id, + token: "test-token", + repository: { id: "R_test", owner: "org", name: "repo", defaultBranch: "main" }, + signal: new AbortController().signal, + currentToken: () => "test-token", + revalidate: async () => true, + release() {}, + } as unknown as ActiveOwnerBinding; + let opened = await openPlannerSession(owner, { + room: f.room, + repository: owner.repository, + instructions: "Plan.", + harness: "remote", + }, { + githubTools: async () => ({ ok: true, value: {} }), + createSandbox: async () => sandboxSession, + registerCredential: () => () => {}, + agent: { + createSession: options => agent.createSession(options), + stream: call => { + toolSignal = (call.options as { toolSignal?: AbortSignal }).toolSignal; + return agent.stream(call); + }, + } as typeof agent, + }); + if (!opened.ok) throw new Error("Planner unavailable"); + let session = opened.value; + cleanups.push(() => session.destroy()); + let caller = new AbortController(); + let parts: { type: string; error?: unknown }[] = []; + let streaming = (async () => { + let result = await session.stream("ask", caller.signal); + for await (let part of result.fullStream) parts.push(part); + })(); + let [card] = await f.cards(1); + if (ending === "detach") await client.detach(); + if (ending === "violation") client.send({ type: "attach", version: 1, document: "again" }); + if (ending === "destroy") void session.destroy(); + await streaming; + expect(parts.some(part => part.type === "abort")).toBe(false); + expect(caller.signal.aborted).toBe(false); + expect(toolSignal?.aborted).toBe(true); + expect(Store.outstanding(f.plan.questions)).toHaveLength(0); + expect(f.plan.records.get(card!.id)).toMatchObject({ status: "cancelled" }); + return parts.filter(part => part.type === "error").map(part => String(part.error)); + } + + it("stops a host ask, withdraws its Decision and fails the turn when the link drops mid-tool", async () => { + let disconnected = ["Error: The attached Planner disconnected during the turn."]; + expect(await askingTurn("detach")).toEqual(disconnected); + expect(await askingTurn("violation")).toEqual(disconnected); + }); + + it("stops a host ask and withdraws its Decision when its turn ends before the tool does", async () => { + expect(await askingTurn("turn-end")).toEqual(["Error: The client gave up."]); + await askingTurn("destroy"); + }); +}); diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts new file mode 100644 index 000000000..f84f370f3 --- /dev/null +++ b/apps/server/src/harness/remote/link.ts @@ -0,0 +1,863 @@ +/** + * The server end of one Planner link: a client attached to a document whose + * model runs Planner turns. Each Planner session Chopin opens becomes a + * `start`, each turn a `turn`; the client streams parts back and calls + * Chopin's host tools, which still run here against the room. + */ + +import { HarnessCapabilityUnsupportedError } from "@ai-sdk/harness"; +import { + encodeMessage, + LINK_CLOSE, + parseClientMessage, + PLANNER_LINK_VERSION, +} from "@chopin/planner-link"; + +import type { + HarnessV1PromptControl, + HarnessV1PromptTurnOptions, + HarnessV1ResumeSessionState, + HarnessV1Session, + HarnessV1StreamPart, +} from "@ai-sdk/harness"; +import type { + ClientMessage, + ClientMessageOf, + ProtocolErrorCode, + RefusalCode, + ServerMessage, + ServerMessageOf, +} from "@chopin/planner-link"; +import type { HumanInputStatus } from "../atomic/human-input"; +import type { RemotePlanner } from "./registry"; + +const OPERATION_TIMEOUT_MS = 10_000; +const REVALIDATE_EVERY_MS = 5 * 60_000; +const MAX_OPEN_INPUTS = 16; +/** How many settled `input-request` ids a link remembers to refuse their reuse. */ +const REMEMBERED_INPUTS = 1_024; +const LIVE_RUN = new Set(["running", "waiting", "paused"]); + +export type LinkSocket = { + send(text: string): void; + close(code: number, reason: string): void; +}; + +export type AttachOutcome = + | { + ok: true; + document: ServerMessageOf<"attached">["document"]; + /** Lets go of what attaching took, such as the document's Planner ownership. */ + detach?: () => void; + } + | { ok: false; code: RefusalCode; message: string }; + +export type LinkOptions = { + /** Resolves the document the client asked for and takes its Planner, or refuses. */ + attach(message: ClientMessageOf<"attach">): Promise; + /** Rechecks the caller's access; false closes the link. */ + revalidate?(): Promise; + /** Records the attached link and returns how to forget it. */ + register?(channelId: string, link: PlannerLink): () => void; + timeoutMs?: number; + /** How often an attached link's access is rechecked while it stays open. */ + revalidateEveryMs?: number; +}; + +const REFUSAL_CLOSE: Record = { + "unsupported-version": LINK_CLOSE.unsupportedVersion, + "repository-forbidden": LINK_CLOSE.forbidden, + "access-revoked": LINK_CLOSE.forbidden, + "document-unavailable": LINK_CLOSE.unavailable, + "document-archived": LINK_CLOSE.unavailable, + "planner-attached": LINK_CLOSE.attached, + "sign-in-required": LINK_CLOSE.unauthorized, + unavailable: LINK_CLOSE.failed, +}; + +export class ToolBoundaryError extends Error { + constructor(message: string) { + super(message); + this.name = "ToolBoundaryError"; + } +} + +function unsupported(capability: string): HarnessCapabilityUnsupportedError { + return new HarnessCapabilityUnsupportedError({ + harnessId: "remote", + message: `The remote Planner does not support ${capability}.`, + }); +} + +function within(operation: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +function promptText(prompt: HarnessV1PromptTurnOptions["prompt"]): string { + if (typeof prompt === "string") return prompt; + if (typeof prompt.content === "string") return prompt.content; + return prompt.content.map(part => { + if (part.type !== "text") throw unsupported(`${part.type} prompt parts`); + return part.text; + }).join("\n\n"); +} + +function hostError(message: string): { error: string } { + return { error: message }; +} + +type LinkPart = ClientMessageOf<"part">["part"]; +type LinkUsage = Extract["usage"]; + +function finishReason(reason: Extract["finishReason"]) { + return { unified: reason.unified, raw: reason.raw }; +} + +function usage(value: LinkUsage) { + let { inputTokens, outputTokens } = value; + return { + inputTokens: { + total: inputTokens.total, + noCache: inputTokens.noCache, + cacheRead: inputTokens.cacheRead, + cacheWrite: inputTokens.cacheWrite, + }, + outputTokens: { + total: outputTokens.total, + text: outputTokens.text, + reasoning: outputTokens.reasoning, + }, + }; +} + +type Port = { + send(message: ServerMessage): boolean; + control(session: string, action: "stop" | "resume", runId?: string): Promise; + forget(session: string): void; + /** Throws, closing the link, when the caller can no longer read the document. */ + revalidate(): Promise; + timeoutMs: number; +}; + +type Turn = { + id: string; + emit: (part: HarnessV1StreamPart) => void; + hostNames: Set; + own: Set; + ownCalls: Set; + /** Every host call id the turn has used, refused ones too, so none runs twice. */ + hostIds: Set; + hostCalls: Map; + stopped: boolean; + ended: boolean; + settle: PromiseWithResolvers; + /** Aborted when the turn ends, so the server's host tools still running for it stop too. */ + hostTools?: AbortController; + grace?: ReturnType; + listeners: (() => void)[]; +}; + +type TurnMessage = ClientMessageOf<"tools" | "part" | "host-tool-call" | "turn-end">; + +/** One Planner session whose turns run in the attached client. */ +class RemoteSession { + readonly id: string; + readonly planner: RemotePlanner; + #port: Port; + #started = Promise.withResolvers(); + #current: Turn | undefined; + #closed = false; + #roots = new Map(); + + constructor(id: string, planner: RemotePlanner, port: Port) { + this.id = id; + this.planner = planner; + this.#port = port; + void this.#started.promise.catch(() => {}); + } + + async start(): Promise { + let mode = this.planner.isolated ? "isolated" as const : "planner" as const; + if (!this.#port.send({ type: "start", session: this.id, mode })) { + throw new Error("The Planner link is closed."); + } + await within( + this.#started.promise, + this.#port.timeoutMs, + "The attached Planner did not start the session.", + ); + this.planner.control = (action, runId) => this.#port.control(this.id, action, runId); + this.planner.rootOf = runId => this.#roots.get(runId) ?? runId; + return this.#session(); + } + + acknowledge(message: ClientMessageOf<"started" | "start-failed">): void { + if (message.type === "started") this.#started.resolve(); + else {this.#started.reject( + new Error(`The attached Planner refused the session: ${message.message}`), + );} + } + + /** The root run a workflow question belongs to, so a paused root holds its answers. */ + rememberRoot(runId: string, rootRunId: string): void { + this.#roots.set(runId, rootRunId); + } + + reportRuns(message: Pick, "active" | "paused" | "cards">): void { + if (this.#closed || this.planner.isolated) return; + this.planner.runs = { active: message.active, paused: message.paused, cards: message.cards }; + this.planner.onRuns?.(this.planner.runs); + for (let wake of this.planner.waiters ?? []) wake(); + } + + /** The link is gone: nothing more will arrive for this session. */ + linkClosed(): void { + this.#started.reject(new Error("The Planner link closed.")); + let reported = this.planner.runs; + if (reported) { + this.reportRuns({ + active: [], + paused: [], + cards: reported.cards.map(card => + LIVE_RUN.has(card.status) ? { ...card, status: "stopped" as const } : card + ), + }); + } + let turn = this.#current; + if (turn) { + this.#finish( + turn, + turn.stopped ? undefined : new Error("The attached Planner disconnected during the turn."), + ); + } + this.#closed = true; + this.planner.control = undefined; + } + + receive(message: TurnMessage): void { + let turn = this.#current; + if (!turn || turn.id !== message.turn || turn.ended) { + if (message.type === "host-tool-call") { + this.#port.send({ + type: "host-tool-result", + session: this.id, + turn: message.turn, + toolCallId: message.toolCallId, + output: hostError("The turn has ended."), + isError: true, + }); + } + return; + } + if (message.type === "turn-end") { + let failure = message.status === "finished" || turn.stopped + ? undefined + : new Error( + message.message + ?? (message.status === "aborted" + ? "The attached Planner stopped the turn." + : "The attached Planner's turn failed."), + ); + return this.#finish(turn, failure); + } + if (turn.stopped) { + if (message.type === "host-tool-call") this.#refuseHostCall(turn, message, "aborted"); + return; + } + if (message.type === "tools") return this.#offer(turn, message.names); + if (message.type === "host-tool-call") return this.#hostCall(turn, message); + this.#part(turn, message.part); + } + + #offer(turn: Turn, names: string[]): void { + if (this.planner.isolated && names.length) { + return this.#fail( + turn, + new ToolBoundaryError(`An isolated Planner turn cannot offer ${names.join(", ")}.`), + ); + } + let clash = names.filter(name => turn.hostNames.has(name)); + if (clash.length) { + return this.#fail( + turn, + new ToolBoundaryError(`The attached Planner redefined Chopin's ${clash.join(", ")}.`), + ); + } + turn.own = new Set(names); + this.planner.activeTools = [...turn.hostNames, ...names]; + } + + #hostCall(turn: Turn, message: ClientMessageOf<"host-tool-call">): void { + if (turn.hostIds.has(message.toolCallId) || turn.ownCalls.has(message.toolCallId)) { + return this.#repeated(turn, message.toolCallId); + } + turn.hostIds.add(message.toolCallId); + if (!turn.hostNames.has(message.toolName)) { + return this.#refuseHostCall(turn, message, "unavailable"); + } + turn.hostCalls.set(message.toolCallId, message.toolName); + turn.emit({ + type: "tool-call", + toolCallId: message.toolCallId, + toolName: message.toolName, + input: message.input, + }); + } + + #refuseHostCall( + turn: Turn, + message: ClientMessageOf<"host-tool-call">, + reason: "aborted" | "unavailable", + ): void { + this.#port.send({ + type: "host-tool-result", + session: this.id, + turn: turn.id, + toolCallId: message.toolCallId, + output: hostError( + reason === "aborted" + ? "The turn was aborted." + : `${message.toolName} is not available in this turn.`, + ), + isError: true, + }); + } + + #repeated(turn: Turn, toolCallId: string): void { + this.#fail(turn, new Error(`Tool call ${toolCallId} was repeated.`)); + } + + #part(turn: Turn, part: LinkPart): void { + let boundary = (name: string) => + this.#fail(turn, new ToolBoundaryError(`Planner tool boundary failure: ${name}`)); + switch (part.type) { + case "tool-input-start": + if (turn.hostNames.has(part.toolName)) { + let { providerExecuted: _executed, dynamic: _dynamic, ...host } = part; + return turn.emit(host); + } + if (!turn.own.has(part.toolName)) return boundary(part.toolName); + return turn.emit({ ...part, providerExecuted: true, dynamic: true }); + case "tool-call": + if (!turn.own.has(part.toolName)) return boundary(part.toolName); + if (turn.hostIds.has(part.toolCallId) || turn.ownCalls.has(part.toolCallId)) { + return this.#repeated(turn, part.toolCallId); + } + turn.ownCalls.add(part.toolCallId); + return turn.emit({ ...part, dynamic: true }); + case "tool-result": { + if (!turn.ownCalls.has(part.toolCallId)) return boundary(part.toolName); + let result = (part.result ?? null) as NonNullable; + this.planner.toolReport?.( + part.toolCallId, + result, + part.preliminary ? "running" : part.isError ? "failed" : "done", + ); + return turn.emit({ ...part, result, dynamic: true }); + } + case "error": + return turn.emit({ type: "error", error: new Error(part.error) }); + case "finish-step": + return turn.emit({ + type: "finish-step", + finishReason: finishReason(part.finishReason), + usage: usage(part.usage), + }); + case "finish": + return turn.emit({ + type: "finish", + finishReason: finishReason(part.finishReason), + totalUsage: usage(part.totalUsage), + }); + default: + return turn.emit(part); + } + } + + #stop(turn: Turn): void { + if (turn.stopped || turn.ended) return; + turn.stopped = true; + this.#port.send({ type: "abort", session: this.id, turn: turn.id }); + turn.grace = setTimeout(() => this.#finish(turn), this.#port.timeoutMs); + } + + #fail(turn: Turn, error: Error): void { + if (!turn.stopped) { + turn.stopped = true; + this.#port.send({ type: "abort", session: this.id, turn: turn.id }); + } + this.#finish(turn, error); + } + + #finish(turn: Turn, error?: Error): void { + if (turn.ended) return; + turn.ended = true; + clearTimeout(turn.grace); + for (let release of turn.listeners.splice(0)) release(); + if (this.#current === turn) this.#current = undefined; + turn.hostTools?.abort(error ?? new Error("The turn has ended.")); + if (error) turn.settle.reject(error); + else turn.settle.resolve(); + } + + #listen(turn: Turn, signal: AbortSignal | undefined): void { + if (!signal) return; + if (signal.aborted) return this.#stop(turn); + let abort = () => this.#stop(turn); + signal.addEventListener("abort", abort, { once: true }); + turn.listeners.push(() => signal.removeEventListener("abort", abort)); + } + + #control(turn: Turn): HarnessV1PromptControl { + return { + submitToolResult: async ({ toolCallId, output, isError }) => { + let toolName = turn.hostCalls.get(toolCallId); + if (!toolName || turn.ended) return; + turn.hostCalls.delete(toolCallId); + let carried: ServerMessageOf<"host-tool-result"> = { + type: "host-tool-result", + session: this.id, + turn: turn.id, + toolCallId, + output: (output ?? null) as never, + ...(isError ? { isError } : {}), + }; + if (!encodeMessage(carried)) { + carried = { + ...carried, + output: hostError(`${toolName} returned more than the Planner link can carry.`), + isError: true, + }; + } + let result = carried.output as NonNullable; + turn.emit({ + type: "tool-result", + toolCallId, + toolName, + result, + isError: carried.isError ?? isError, + }); + if (!turn.stopped) this.#port.send(carried); + }, + async submitToolApproval() { + // The remote Planner asks no approvals; host tools are already allowed. + }, + done: turn.settle.promise, + }; + } + + async #prompt(options: HarnessV1PromptTurnOptions): Promise { + this.#ready(); + if (options.skills.length > 0) throw unsupported("skills"); + let prompt = promptText(options.prompt); + await this.#port.revalidate(); + this.#ready(); + let hostNames = options.tools.map(tool => tool.name); + let hostTools = this.planner.hostTools; + this.planner.hostTools = undefined; + let turn: Turn = { + id: crypto.randomUUID(), + emit: options.emit, + hostNames: new Set(hostNames), + own: new Set(), + ownCalls: new Set(), + hostIds: new Set(), + hostCalls: new Map(), + stopped: false, + ended: false, + settle: Promise.withResolvers(), + ...(hostTools ? { hostTools } : {}), + listeners: [], + }; + let sent = this.#port.send({ + type: "turn", + session: this.id, + turn: turn.id, + prompt, + ...(options.instructions === undefined ? {} : { instructions: options.instructions }), + ...(options.model ? { model: options.model } : {}), + tools: options.tools.map(tool => ({ + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + ...(tool.inputSchema === undefined + ? {} + : { inputSchema: tool.inputSchema as Record }), + })), + ...(options.responseFormat + ? { responseFormat: options.responseFormat as ServerMessageOf<"turn">["responseFormat"] } + : {}), + }); + if (!sent) throw new Error("The Planner link could not carry this turn."); + this.planner.activeTools = hostNames; + this.#current = turn; + this.#listen(turn, options.abortSignal); + return this.#control(turn); + } + + #ready(): void { + if (this.#closed) throw new Error("The remote Planner session is closed."); + if (this.#current) throw new Error("The remote Planner session already has an active turn."); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + let turn = this.#current; + if (turn) { + this.#stop(turn); + this.#finish(turn); + } + this.planner.control = undefined; + this.#port.send({ type: "destroy", session: this.id }); + this.#port.forget(this.id); + } + + #session(): HarnessV1Session { + let stopped = async (): Promise => { + await this.close(); + return { + type: "resume-session", + harnessId: "remote", + specificationVersion: "harness-v1", + data: {}, + }; + }; + return { + sessionId: this.id, + isResume: false, + doPromptTurn: options => this.#prompt(options), + doContinueTurn: async options => { + let turn = this.#current; + if (!turn) throw unsupported("continuing a turn that is no longer running"); + turn.emit = options.emit; + this.#listen(turn, options.abortSignal); + return this.#control(turn); + }, + doCompact: async () => { + throw unsupported("compaction"); + }, + doSuspendTurn: async () => { + throw unsupported("suspending a turn"); + }, + doDetach: stopped, + doStop: stopped, + doDestroy: () => this.close(), + }; + } +} + +/** One attached client, from its `attach` until its socket closes. */ +export class PlannerLink { + #socket: LinkSocket; + #options: LinkOptions; + #state: "waiting" | "attaching" | "attached" | "closed" = "waiting"; + #channelId: string | undefined; + #forget: (() => void) | undefined; + #sessions = new Map(); + #inputs = new Map(); + /** Recent `input-request` ids, oldest first, so a settled one cannot be reused. */ + #inputIds = new Set(); + #controls = new Map>(); + #watch: ReturnType | undefined; + #port: Port; + + constructor(socket: LinkSocket, options: LinkOptions) { + this.#socket = socket; + this.#options = options; + this.#port = { + send: message => this.#send(message), + control: (session, action, runId) => this.#control(session, action, runId), + forget: session => { + this.#sessions.delete(session); + this.#cancelInputs(session); + }, + revalidate: () => this.#recheck(), + timeoutMs: options.timeoutMs ?? OPERATION_TIMEOUT_MS, + }; + } + + get channelId(): string | undefined { + return this.#channelId; + } + + get attached(): boolean { + return this.#state === "attached"; + } + + /** Opens a Planner session on the client; throws when it cannot. */ + async startSession(sessionId: string, planner: RemotePlanner): Promise { + if (this.#state !== "attached") throw new Error("The Planner link is not attached."); + if (this.#sessions.has(sessionId)) { + throw new Error("The remote Planner session is already open."); + } + await this.#recheck(); + let session = new RemoteSession(sessionId, planner, this.#port); + this.#sessions.set(sessionId, session); + try { + return await session.start(); + } catch (error) { + // Withdraws any question the client asked while the session was starting. + await session.close(); + throw error; + } + } + + /** Closes the link with `access-revoked` and throws when the caller may no longer be the Planner. */ + async #recheck(): Promise { + if (this.#options.revalidate && !await this.#options.revalidate()) { + this.#refuse( + "access-revoked", + "The attached account can no longer act as this document's Planner.", + ); + throw new Error("The attached Planner can no longer act for this document."); + } + if (this.#state !== "attached") throw new Error("The Planner link closed."); + } + + async #recheckAttached(): Promise { + try { + await this.#recheck(); + } catch (error) { + if (this.#state === "attached") console.error("[planner-link] access recheck failed:", error); + } + } + + receive(raw: unknown): void { + if (this.#state === "closed") return; + let parsed = parseClientMessage(raw); + if (!parsed.ok) return this.#violate(parsed.code, parsed.message); + let message = parsed.message; + if (this.#state !== "attached") { + if (this.#state === "waiting" && message.type === "attach") { + void this.#attach(message); + return; + } + return this.#violate("protocol", "Send attach first, and nothing else until it is answered."); + } + this.#dispatch(message); + } + + /** Tells the client why it is no longer the Planner, then closes the link. */ + revoke(code: RefusalCode, message: string): void { + if (this.#state === "closed") return; + this.#refuse(code, message); + } + + /** Ends the link from the server side. */ + close(code: number, reason: string): void { + if (this.#state === "closed") return; + try { + this.#socket.close(code, reason); + } finally { + this.disconnected(); + } + } + + /** The socket closed: every open turn ends, and nothing waits on this client again. */ + disconnected(): void { + if (this.#state === "closed") return; + this.#state = "closed"; + clearInterval(this.#watch); + this.#forget?.(); + this.#forget = undefined; + for (let session of this.#sessions.values()) session.linkClosed(); + this.#sessions.clear(); + for (let input of this.#inputs.values()) input.controller.abort(); + this.#inputs.clear(); + for (let control of this.#controls.values()) { + control.reject(new Error("The Planner link closed.")); + } + this.#controls.clear(); + } + + #send(message: ServerMessage): boolean { + if (this.#state === "closed") return false; + let encoded = encodeMessage(message); + if (!encoded) return false; + this.#socket.send(encoded); + return true; + } + + #violate(code: ProtocolErrorCode, message: string): void { + this.#send({ type: "error", code, message }); + this.close(code === "message-too-large" ? LINK_CLOSE.tooLarge : LINK_CLOSE.malformed, code); + } + + #refuse(code: RefusalCode, message: string): void { + this.#send({ type: "refused", code, message }); + this.close(REFUSAL_CLOSE[code], code); + } + + async #attach(message: ClientMessageOf<"attach">): Promise { + this.#state = "attaching"; + if (message.version !== PLANNER_LINK_VERSION) { + return this.#refuse( + "unsupported-version", + `This Chopin speaks Planner link version ${PLANNER_LINK_VERSION}, not ${message.version}.`, + ); + } + let outcome: AttachOutcome; + try { + outcome = await this.#options.attach(message); + } catch (error) { + console.error("[planner-link] attach failed:", error); + outcome = { ok: false, code: "unavailable", message: "The document could not be checked." }; + } + if (this.#state !== "attaching") { + if (outcome.ok) outcome.detach?.(); + return; + } + if (!outcome.ok) return this.#refuse(outcome.code, outcome.message); + this.#channelId = outcome.document.id; + this.#state = "attached"; + this.#send({ type: "attached", version: PLANNER_LINK_VERSION, document: outcome.document }); + let unregister = this.#options.register?.(outcome.document.id, this); + let detach = outcome.detach; + this.#forget = () => { + unregister?.(); + detach?.(); + }; + this.#watch = setInterval( + () => void this.#recheckAttached(), + this.#options.revalidateEveryMs ?? REVALIDATE_EVERY_MS, + ); + } + + #dispatch(message: ClientMessage): void { + switch (message.type) { + case "attach": + return this.#violate("protocol", "This link is already attached."); + case "detach": + return this.close(LINK_CLOSE.detached, "detached"); + case "started": + case "start-failed": + return this.#sessions.get(message.session)?.acknowledge(message); + case "tools": + case "part": + case "host-tool-call": + case "turn-end": { + let session = this.#sessions.get(message.session); + if (session) return session.receive(message); + if (message.type !== "host-tool-call") return; + this.#send({ + type: "host-tool-result", + session: message.session, + turn: message.turn, + toolCallId: message.toolCallId, + output: hostError("The session has ended."), + isError: true, + }); + return; + } + case "runs": + return this.#sessions.get(message.session)?.reportRuns(message); + case "control-result": { + let control = this.#controls.get(message.request); + this.#controls.delete(message.request); + if (message.ok) control?.resolve(); + else control?.reject(new Error(message.message ?? "The attached Planner refused.")); + return; + } + case "input-request": + return void this.#input(message); + case "input-cancel": + return this.#inputs.get(message.request)?.controller.abort(); + } + } + + async #control(session: string, action: "stop" | "resume", runId?: string): Promise { + let request = crypto.randomUUID(); + let pending = Promise.withResolvers(); + this.#controls.set(request, pending); + if (!this.#send({ type: action, request, session, ...(runId ? { run: runId } : {}) })) { + this.#controls.delete(request); + throw new Error("The Planner link is closed."); + } + try { + await within( + pending.promise, + this.#port.timeoutMs, + `The attached Planner did not answer ${action}.`, + ); + } finally { + this.#controls.delete(request); + } + } + + async #input(message: ClientMessageOf<"input-request">): Promise { + let reply = ( + status: ServerMessageOf<"input-result">["status"], + value?: unknown, + text?: string, + ) => + this.#send({ + type: "input-result", + request: message.request, + status, + ...(value === undefined ? {} : { value: value as never }), + ...(text === undefined ? {} : { message: text }), + }); + if (this.#inputs.has(message.request) || this.#inputIds.has(message.request)) { + return this.#violate("protocol", `Input request ${message.request} was repeated.`); + } + this.#inputIds.add(message.request); + if (this.#inputIds.size > REMEMBERED_INPUTS) { + this.#inputIds.delete(this.#inputIds.values().next().value!); + } + let session = this.#sessions.get(message.session); + let humanInput = session?.planner.humanInput; + if (!session || !humanInput) { + return void reply("failed", undefined, "Decisions are not available to this session."); + } + if (this.#inputs.size >= MAX_OPEN_INPUTS) { + return void reply("failed", undefined, "Too many questions are already open."); + } + let controller = new AbortController(); + this.#inputs.set(message.request, { session: message.session, controller }); + if (message.workflowRunId && message.rootRunId) { + session.rememberRoot(message.workflowRunId, message.rootRunId); + } + let status: HumanInputStatus = "cancelled"; + let host = humanInput(outcome => { + status = outcome; + }); + let options = { + signal: controller.signal, + requestId: message.request, + sessionId: message.session, + ...(message.workflowRunId ? { workflowRunId: message.workflowRunId } : {}), + ...(message.workflowStageId ? { workflowStageId: message.workflowStageId } : {}), + }; + let input = message.input; + try { + let value = input.method === "questionnaire" + ? await host.questionnaire(input.params, options) + : input.method === "confirm" + ? await host.confirm(input.title, input.message, options) + : input.method === "select" + ? await host.select(input.title, input.choices, options) + : input.method === "input" + ? await host.input(input.title, input.placeholder, options) + : await host.editor(input.title, input.initial, options); + reply(controller.signal.aborted ? "cancelled" : status, value ?? null); + } catch (error) { + reply("failed", undefined, error instanceof Error ? error.message : String(error)); + } finally { + this.#inputs.delete(message.request); + } + } + + #cancelInputs(session: string): void { + for (let [request, input] of this.#inputs) { + if (input.session !== session) continue; + input.controller.abort(); + this.#inputs.delete(request); + } + } +} diff --git a/apps/server/src/harness/remote/registry.ts b/apps/server/src/harness/remote/registry.ts new file mode 100644 index 000000000..1b4075413 --- /dev/null +++ b/apps/server/src/harness/remote/registry.ts @@ -0,0 +1,53 @@ +import type { HostInput } from "@bastani/atomic"; +import type { FullPlanner } from "../atomic/full"; +import type { HumanInputStatus } from "../atomic/human-input"; +import type { PlannerLink } from "./link"; + +/** + * What Chopin keeps for one Planner session whose model runs in an attached + * client. Run state mirrors a full Atomic Planner's, reported by the client. + */ +export type RemotePlanner = + & Pick + & { + channelId: string; + /** A background job's session: the client may offer only Chopin's tools and ask nothing. */ + isolated: boolean; + /** Decisions for one client input request; absent for an isolated session. */ + humanInput?: (report: (status: HumanInputStatus) => void) => HostInput; + /** Stops or resumes the client's workflow runs; set while the session is open. */ + control?: (action: "stop" | "resume", runId?: string) => Promise; + /** + * Stops the server's host tools for the next turn. That turn aborts it when it + * ends without its caller stopping it, so a host tool still running, such as an + * open Decision, ends with the turn instead of holding it open. + */ + hostTools?: AbortController; + }; + +let planners = new Map(); +let links = new Map(); + +export function registerRemotePlanner(sessionId: string, planner: RemotePlanner): () => void { + if (planners.has(sessionId)) throw new Error("Remote Planner session is already registered"); + planners.set(sessionId, planner); + return () => { + if (planners.get(sessionId) === planner) planners.delete(sessionId); + }; +} + +export function remotePlanner(sessionId: string): RemotePlanner | undefined { + return planners.get(sessionId); +} + +/** Records the link attached as a document's Planner; `PlannerAttachments` admits one per document. */ +export function attachLink(channelId: string, link: PlannerLink): () => void { + links.set(channelId, link); + return () => { + if (links.get(channelId) === link) links.delete(channelId); + }; +} + +export function attachedLink(channelId: string): PlannerLink | undefined { + return links.get(channelId); +} diff --git a/apps/server/src/harness/session.test.ts b/apps/server/src/harness/session.test.ts index 3dccbe3ca..14913ab45 100644 --- a/apps/server/src/harness/session.test.ts +++ b/apps/server/src/harness/session.test.ts @@ -6,7 +6,10 @@ import { openPlannerSession } from "./session"; import { fullPlanner } from "./atomic/full"; import { forgetWorkspaces, rememberCheckout, stateDirectory } from "./atomic/workspace"; import { plannerInstructions } from "../agent/planner"; -import { VISUAL_PLANNER_TOOL_NAMES } from "./tool-names"; +import { BACKGROUND_TOOL_NAMES, VISUAL_PLANNER_TOOL_NAMES } from "./tool-names"; +import { remotePlanner } from "./remote/registry"; +import { hostInputRoom } from "../testing/decisions"; +import { limits } from "@chopin/question"; import type { ActiveOwnerBinding } from "../agent/active-owner"; import type { PlannerSessionDependencies } from "./session"; @@ -86,6 +89,105 @@ describe("openPlannerSession", () => { await opened.value.destroy(); }); + it("registers a remote Planner for its document, with Decisions and run control on the client", async () => { + let { owner, channel, deps } = fixture(); + let room = await hostInputRoom(); + 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, + room: room.room, + harness: "remote", + instructions: workspace => plannerInstructions("owner/repo", "BOOTSTRAP", workspace), + }, deps); + if (!opened.ok) throw new Error("Planner unavailable"); + let session = opened.value; + let registered = remotePlanner(sessionId)!; + try { + expect(registered).toMatchObject({ channelId: room.room.id, isolated: false }); + await session.stream("prompt", new AbortController().signal); + expect(instructions).toContain("You run in a member's own session on their machine"); + expect(instructions).toContain("BOOTSTRAP"); + + let delays: unknown[] = []; + let original = globalThis.setTimeout; + globalThis.setTimeout = ((handler: () => void, ms?: number) => { + delays.push(ms); + return original(handler, ms); + }) as typeof setTimeout; + let asked: Promise; + try { + asked = registered.humanInput!(() => {}).confirm("Ship?", "Now.", room.options); + await room.cards(1); + } finally { + globalThis.setTimeout = original; + } + expect(delays).toContain(limits.INPUT_EXPIRY_MS); + room.controller.abort(); + expect(await asked).toBe(false); + + let seen: unknown[] = []; + session.watchRuns!(runs => seen.push(runs)); + let runs = { active: ["run"], paused: [], cards: [] }; + registered.runs = runs; + registered.onRuns!(runs); + expect(seen).toEqual([runs]); + expect(session.runs!()).toBe(runs); + await expect(session.pauseRuns!()).rejects.toThrow("no attached session to control"); + let controls: unknown[] = []; + registered.control = async (action, runId) => void controls.push([action, runId]); + await session.pauseRuns!(); + await session.resumeRun!("run"); + expect(controls).toEqual([["stop", undefined], ["resume", "run"]]); + registered.activeTools = ["read_plan", "bash"]; + expect(session.activeTools).toEqual(["read_plan", "bash"]); + } finally { + await session.destroy(); + await room.close(); + } + expect(remotePlanner(sessionId)).toBeUndefined(); + }); + + it("keeps a remote background job isolated, without Decisions or run control", async () => { + let { owner, channel, deps } = fixture(); + let sessionId = ""; + let instructions = ""; + deps.headingAgent = { + createSession: async (options: { sessionId: string }) => { + sessionId = options.sessionId; + return { destroy: async () => {} }; + }, + stream: async (call: { options: { instructions: string } }) => { + instructions = call.options.instructions; + }, + } as never; + deps.agent = undefined; + let opened = await openPlannerSession(owner, { + ...channel, + room: { id: "channel", plan: { chat: { job: { kind: "heading" } } }, server: {} } as never, + harness: "remote", + instructions: workspace => plannerInstructions("owner/repo", undefined, workspace), + }, deps); + if (!opened.ok) throw new Error("Planner unavailable"); + expect(remotePlanner(sessionId)).toMatchObject({ channelId: "channel", isolated: true }); + expect(remotePlanner(sessionId)!.humanInput).toBeUndefined(); + expect(opened.value.pauseRuns).toBeUndefined(); + expect(opened.value.activeTools).toEqual(BACKGROUND_TOOL_NAMES.heading); + await opened.value.stream("prompt", new AbortController().signal); + expect(instructions).toContain("You have no shell"); + await opened.value.destroy(); + expect(remotePlanner(sessionId)).toBeUndefined(); + }); + it("keeps the credential host-side and destroys the session and sandbox once", async () => { let { owner, channel, deps, destroyed } = fixture(); let opened = await openPlannerSession(owner, channel, deps); diff --git a/apps/server/src/harness/session.ts b/apps/server/src/harness/session.ts index 0e05b0c44..b247c5483 100644 --- a/apps/server/src/harness/session.ts +++ b/apps/server/src/harness/session.ts @@ -19,11 +19,13 @@ import { } from "./atomic/full"; import { createHumanInput } from "./atomic/human-input"; import { type PlannerWorkspace, plannerWorkspace } from "./atomic/workspace"; +import { registerRemotePlanner, type RemotePlanner } from "./remote/registry"; 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 { RemoteWorkspace } from "../agent/planner"; export type OpenError = | GitHubToolsError @@ -38,9 +40,12 @@ type PlannerAgent = typeof plannerAgent; export type PlannerChannel = { room: DocumentRoom; repository: HostedRepository; - instructions: string | ((workspace?: PlannerWorkspace) => string); + instructions: string | ((workspace?: PlannerWorkspace | RemoteWorkspace) => string); model?: string; - /** `atomic` runs the session as a full Atomic session in the channel's workspace. */ + /** + * `atomic` runs the session as a full Atomic session in the channel's workspace; + * `remote` runs it in the client attached to the channel. + */ harness?: string; }; @@ -54,7 +59,7 @@ export type PlannerSession = { activeTools?: readonly string[]; stream: (prompt: string, abortSignal: AbortSignal) => ReturnType; destroy: () => Promise; - /** Workflow runs this session owns; only atomic Planner sessions report them. */ + /** Workflow runs this session owns; only atomic and remote Planner sessions report them. */ runs?: () => PlannerRuns | undefined; /** Subscribes to run changes; returns the unsubscribe function. */ watchRuns?: (listener: (runs: PlannerRuns) => void) => () => void; @@ -66,8 +71,37 @@ export type PlannerSession = { pauseRun?: (runId: string) => Promise; /** Resumes one paused run this session owns. */ resumeRun?: (runId: string) => Promise; + /** + * Whether a run that ends blocked or failed between turns starts a Planner turn + * about it. A remote Planner's runs are only what its client reports, so they + * never start a turn no writer asked for. + */ + followsUpRuns?: boolean; }; +/** Run control for a remote Planner: the attached client stops and resumes its own runs. */ +function remoteRunControl( + remote: RemotePlanner, + watchRuns: NonNullable, +): Pick< + PlannerSession, + "runs" | "watchRuns" | "pauseRuns" | "resumeRuns" | "pauseRun" | "resumeRun" | "followsUpRuns" +> { + let control = () => { + if (!remote.control) throw new Error("The Planner has no attached session to control."); + return remote.control; + }; + return { + runs: () => remote.runs, + watchRuns, + pauseRuns: async () => control()("stop"), + resumeRuns: async () => control()("resume"), + pauseRun: async runId => control()("stop", runId), + resumeRun: async runId => control()("resume", runId), + followsUpRuns: false, + }; +} + export type PlannerSessionDependencies = { agent?: PlannerAgent; headingAgent?: PlannerAgent; @@ -120,6 +154,7 @@ export async function openPlannerSession( } let unregister: (() => void) | undefined; let unregisterFull: (() => void) | undefined; + let unregisterRemote: (() => void) | undefined; let session: Awaited> | undefined; let timeout: ReturnType | undefined; try { @@ -151,8 +186,31 @@ export async function openPlannerSession( }; unregisterFull = registerFullPlanner(sessionId, planner); } + let remote: RemotePlanner | undefined; + if (channel.harness === "remote") { + let held: RemotePlanner = { channelId: channel.room.id, isolated: !!job }; + if (!job) { + held.humanInput = report => + createHumanInput( + channel.room, + undefined, + (runId, signal) => untilUnpaused(held, runId, signal), + report, + ); + held.onRuns = runs => { + for (let listener of listeners) listener(runs); + }; + held.toolReport = (id, output, state) => { + let chat = channel.room.plan.chat; + if (state === "running") chat?.toolProgress?.(id, output); + else chat?.toolFinished?.(id, output, state === "done"); + }; + } + remote = held; + unregisterRemote = registerRemotePlanner(sessionId, held); + } let instructions = typeof channel.instructions === "function" - ? channel.instructions(workspace) + ? channel.instructions(workspace ?? (remote && !job ? { remote: true } : undefined)) : channel.instructions; let agent = deps.agent ?? (job?.kind === "heading" ? deps.headingAgent ?? headingPlannerAgent @@ -186,18 +244,21 @@ export async function openPlannerSession( } return planner.workflows; }; + let watchRuns = (listener: (runs: PlannerRuns) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; let runControl = planner ? { runs: () => planner.runs, - watchRuns: (listener: (runs: PlannerRuns) => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, + watchRuns, pauseRuns: () => pauseOwnedRuns(workflows()), resumeRuns: () => resumeOwnedRuns(workflows()), pauseRun: async (runId: string) => applied(await workflows().pause(runId)), resumeRun: async (runId: string) => applied(await workflows().resume(runId)), } + : remote && !job + ? remoteRunControl(remote, watchRuns) : {}; let fixedTools = Object.freeze([ ...(job @@ -210,11 +271,14 @@ export async function openPlannerSession( ok: true, value: { get activeTools() { - return planner?.activeTools ?? fixedTools; + return planner?.activeTools ?? remote?.activeTools ?? fixedTools; }, ...runControl, - stream: (prompt, abortSignal) => - agent.stream({ + stream: (prompt, abortSignal) => { + // The remote turn aborts it when the turn ends or the link drops mid-tool. + let hostTools = remote ? new AbortController() : undefined; + if (remote) remote.hostTools = hostTools; + return agent.stream({ session: active, prompt, abortSignal, @@ -223,8 +287,10 @@ export async function openPlannerSession( instructions, owner, githubTools: tools.value, + ...(hostTools ? { toolSignal: hostTools.signal } : {}), }, - }), + }); + }, destroy: () => stopped ??= (async () => { try { @@ -235,6 +301,7 @@ export async function openPlannerSession( } finally { release(); unregisterFull?.(); + unregisterRemote?.(); } } })(), @@ -253,6 +320,7 @@ export async function openPlannerSession( } unregister?.(); unregisterFull?.(); + unregisterRemote?.(); 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/main.ts b/apps/server/src/main.ts index 1f43970c2..e9de009c9 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -13,6 +13,13 @@ import { join } from "node:path"; import { ulid } from "@chopin/dialect"; import { harnessFor, shutdownHarnesses } from "./harness/harnesses"; +import { + admitPlannerLink, + isPlannerLinkSocket, + openPlannerLinkSocket, +} from "./harness/remote/endpoint"; +import { notAttachedMessage, PlannerAttachments } from "./harness/remote/attachments"; +import { documentUrl } from "./channels/document-url"; import { ActiveOwnerBindings } from "./agent/active-owner"; import { registerAuthRoutes } from "./auth/routes"; import { registerExperimentRoutes } from "./experiments/routes"; @@ -74,7 +81,10 @@ import { StorageError } from "./storage/errors"; import { createStorage } from "./storage/registry"; import { broadcast, fail, relay, reply, repositoryTopic, tell, topic } from "./wire"; +import { LINK_CLOSE, PLANNER_LINK_PATH } from "@chopin/planner-link"; + import type { Server, ServerWebSocket } from "bun"; +import type { PlannerLinkSocketData } from "./harness/remote/endpoint"; import type { RepositoryAuthorizer, WatchTopics } from "./socket/decision-watch"; import type { DocumentSummaryInput } from "./jobs/document-summary"; import type { JobDefinition } from "./jobs/registry"; @@ -120,6 +130,7 @@ let renewingLease: Promise | undefined; let sessionCleanup: ReturnType | undefined; let cleaningSessions: Promise | undefined; let ownerBindings: ActiveOwnerBindings | undefined; +let plannerAttachments: PlannerAttachments | undefined; let jobRunner: JobRunner | undefined; let researchService: ResearchWorkspaceService | undefined; let researchRecoveryTimer: ReturnType | undefined; @@ -299,6 +310,19 @@ function conversation( auth: hostedAuth, claimantSessionId, repository, + plannerNotAttached: plannerAttachments + ? async () => { + if (await plannerAttachments!.confirmed(room.id)) return undefined; + let channel = await storage.channels.get(room.id); + let path = channel + ? await documentUrl(channel, storage.channels) + : `/channels/${room.id}`; + return notAttachedMessage( + new URL(path, hostedAuth.config.origin).href, + `${repository.owner}/${repository.name}`, + ); + } + : undefined, persist: chat => Service.persist(opened, chat), activeOwner: () => ownerBindings!.resolve(room.id), ownerAvailable: () => jobRunner?.ownerAvailable(room.id) ?? Promise.resolve(), @@ -666,6 +690,7 @@ async function receive(ws: Socket, raw: string): Promise { refreshAccess, unavailable: id => archivingChannels.has(id) || deletingChannels.has(id), ownerAvailable: id => jobRunner?.ownerAvailable(id) ?? Promise.resolve(), + claimsOwnership: !plannerAttachments, placeReference: placeResearchReference, scheduleRecovery: scheduleResearchRecovery, }), @@ -800,7 +825,7 @@ const VIEWER_ALLOWED = new Set([ "conversation-plan:research-link", ]); -type AnySocket = ServerWebSocket; +type AnySocket = ServerWebSocket; function isSidebarSocket(ws: AnySocket): ws is SidebarSocket { return ws.data.sidebar === true; @@ -1110,8 +1135,8 @@ function closeSidebarSocket(ws: SidebarSocket): void { function upgraded( req: Request, - self: Server, - data: SocketData | SidebarSocketData, + self: Server, + data: SocketData | SidebarSocketData | PlannerLinkSocketData, ): Response | undefined { if ( req.headers.get("x-chopin-socket-probe") === "1" @@ -1122,7 +1147,7 @@ function upgraded( } function listen(): Server { - return Bun.serve({ + return Bun.serve({ hostname: config.host, port: config.port, @@ -1139,6 +1164,11 @@ function listen(): Server { } return upgraded(req, self, outcome.data); } + if (url.pathname === PLANNER_LINK_PATH) { + let outcome = await admitPlannerLink(req, hostedAuth, !!plannerAttachments); + if (outcome instanceof Response) return outcome; + return upgraded(req, self, outcome); + } if (url.pathname === "/ws/sidebar") { let outcome = await admitSidebar(req, hostedAuth); if ("status" in outcome) { @@ -1154,11 +1184,18 @@ function listen(): Server { websocket: { open(ws: AnySocket) { - if (isSidebarSocket(ws)) scheduleSidebarRecheck(ws); + if (isPlannerLinkSocket(ws)) { + if (!plannerAttachments) return ws.close(LINK_CLOSE.failed, "unavailable"); + openPlannerLinkSocket(ws, hostedAuth, { + attachments: plannerAttachments, + unavailable: channelId => deletingChannels.has(channelId), + }); + } else if (isSidebarSocket(ws)) scheduleSidebarRecheck(ws); else openRoomSocket(ws as Socket); }, message(ws: AnySocket, raw) { + if (isPlannerLinkSocket(ws)) return ws.data.link?.receive(raw); if (typeof raw !== "string") return; let received = isSidebarSocket(ws) ? receiveSidebar(ws, raw) : receive(ws as Socket, raw); void received.catch(err => { @@ -1167,7 +1204,8 @@ function listen(): Server { }, close(ws: AnySocket) { - if (isSidebarSocket(ws)) closeSidebarSocket(ws); + if (isPlannerLinkSocket(ws)) ws.data.link?.disconnected(); + else if (isSidebarSocket(ws)) closeSidebarSocket(ws); else closeRoomSocket(ws as Socket); }, }, @@ -1214,6 +1252,7 @@ function drain(): Promise { summaryCoordinator?.close(); let stoppingJobs = jobRunner?.shutdown(); ownerBindings?.revokeAll(); + plannerAttachments?.close(); for (let result of await Promise.allSettled([stoppingJobs])) { if (result.status === "rejected") record(result.reason); } @@ -1581,7 +1620,11 @@ async function sessionRevoked(sessionId: string): Promise { experiments?.connections.revokeSession(sessionId); let jobs = jobRunner?.ownerRevoked(sessionId); ownerBindings?.revokeSession(sessionId); - await Promise.all([resetOpenAgents(() => true, sessionId), jobs]); + await Promise.all([ + resetOpenAgents(() => true, sessionId), + jobs, + plannerAttachments?.sessionRevoked(sessionId), + ]); } async function credentialsWillRotate(sessionId: string, revision: number): Promise { @@ -1601,6 +1644,7 @@ async function credentialsWillRotate(sessionId: string, revision: number): Promi async function channelOwnerReset(channelId: string): Promise { let jobs = jobRunner?.channelOwnerReset(channelId); ownerBindings?.revokeChannel(channelId); + plannerAttachments?.ownerReset(channelId); await Promise.all([resetOpenAgents(room => room.id === channelId), jobs]); } @@ -1652,6 +1696,9 @@ experiments = registerExperimentRoutes(router, hostedAuth, { }, }); ownerBindings = new ActiveOwnerBindings(hostedAuth); +if (config.agent && config.harness === "remote") { + plannerAttachments = new PlannerAttachments(hostedAuth, config.plannerAttachGraceMs); +} let definitions: JobDefinition[] = []; if (config.backgroundJobs) { definitions.push(researchBriefDefinition({ config })); @@ -1868,7 +1915,7 @@ registerResearchWorkspaceRoutes(router, hostedAuth, { defaultBranch: repository.defaultBranch, }, channel.id, - session.session.id, + plannerAttachments ? undefined : session.session.id, ); await jobRunner?.ownerAvailable(channel.id); }, diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index 00ed96b66..93c01a309 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -155,6 +155,7 @@ export type InvokePlannerError = | "document-unavailable" | "repository-forbidden" | "document-archived" + | "planner-not-attached" | "planner-owner-unavailable" | "checkout-unverified" | "planner-unavailable" @@ -163,7 +164,7 @@ export type InvokePlannerError = export type InvokePlanner = { invoke(caller: Caller, input: InvokePlannerInput): Promise< | { kind: "invoked"; document: DocumentSummary & { url: string } } - | { kind: "refused"; code: InvokePlannerError } + | { kind: "refused"; code: InvokePlannerError; message?: string } >; }; @@ -518,7 +519,10 @@ export const TOOLS: Tool[] = [ 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.", + + "owner, and otherwise the call is refused. Where the Planner runs in an attached " + + "client, a document with no attached Planner refuses the call with " + + "planner-not-attached and a message saying how to attach one. Questions appear in " + + "Decisions.", inputSchema: { type: "object", properties: { @@ -554,15 +558,27 @@ export const TOOLS: Tool[] = [ required: ["id", "title", "url"], additionalProperties: false, }, - outcome([ - "document-unavailable", - "repository-forbidden", - "document-archived", - "planner-owner-unavailable", - "checkout-unverified", - "planner-unavailable", - "planner-queue-full", - ]), + { + type: "object", + properties: { + code: { + type: "string", + enum: [ + "document-unavailable", + "repository-forbidden", + "document-archived", + "planner-not-attached", + "planner-owner-unavailable", + "checkout-unverified", + "planner-unavailable", + "planner-queue-full", + ], + }, + message: { type: "string" }, + }, + required: ["code"], + additionalProperties: false, + }, ], }, }, @@ -1098,7 +1114,10 @@ export function handler( let result = await options.invoke.invoke(caller, args as InvokePlannerInput); return result.kind === "invoked" ? respond(text(result.document)) - : respond(text({ code: result.code }, true)); + : respond(text({ + code: result.code, + ...(result.message ? { message: result.message } : {}), + }, true)); } if (tool.name === "upload_image" && options.upload) { let prepared = prepareImage(tool.arguments); diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index fe33a4164..3096b8109 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -276,6 +276,31 @@ describe("the hosted MCP adapter", () => { } }); + it("tells an invocation with no attached Planner how to attach one, naming the document URL", async () => { + let context = setup(); + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + let adapter = hosted(context.auth, undefined, { + invokePlanner: async () => "planner-not-attached", + }); + let caller = (await adapter.caller(request("Bearer allowed")))!; + try { + expect( + await adapter.invoke!.invoke(caller, { id: opened.channel.id, instruction: "Plan it." }), + ).toEqual({ + kind: "refused", + code: "planner-not-attached", + message: + "No Planner is attached to this document. To attach one, connect a Planner client to " + + "https://chopin.test/documents/octo-org/score/release-readiness over the Planner " + + "link from a session in a checkout of octo-org/score; that session becomes this " + + "document's Planner.", + }); + } finally { + await Service.close(opened.plan); + } + }); + it("passes a URL invocation's checkout through unverified for the harness to judge", async () => { let context = setup(); context.github.repositoryValue.permissions.admin = true; diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 04f40d241..6c574489c 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -7,6 +7,7 @@ import { } from "@chopin/protocol/document-url"; import { documentUrl } from "../channels/document-url"; +import { notAttachedMessage } from "../harness/remote/attachments"; import { deterministicChannelId, isChannelId } from "../channels/id"; import { documentSlug } from "../channels/slug"; import { GitHubError } from "../github/client"; @@ -155,74 +156,91 @@ function claimResult(value: ClaimResult) { : value; } -/** Bind the backend-neutral MCP surface to hosted GitHub authentication. */ -export function hosted( - auth: HostedAuth, - persistence?: ImplementationPersistence, - callbacks: HostedCallbacks = {}, -): McpOptions { - async function directRepository(caller: HostedCaller, owner: string, name: string) { - try { - return await auth.github.repository(caller.oauthToken, owner, name); - } catch (err) { - if (err instanceof GitHubError && (err.status === 403 || err.status === 404)) { - return undefined; - } - throw err; - } - } +type LocatorAuth = Pick; - async function locatedChannel(caller: HostedCaller, locator: string) { - let path: URL | undefined; - try { - path = new URL(locator, auth.config.origin); - } catch { - path = undefined; - } - let local = path?.origin === auth.config.origin ? path.pathname : undefined; - let child = local === undefined ? undefined : parseChildDocumentPath(local); - if (child) { - let repository = await directRepository(caller, child.owner, child.repository); - if (!repository?.permissions.pull) return "forbidden" as const; - let channel = await auth.storage.channels.resolve( - repository.id, - documentSlug(child.childSlug), - ); - let parent = channel?.parentChannelId - ? await auth.storage.channels.resolve(repository.id, documentSlug(child.parentSlug)) - : undefined; - return channel && parent?.id === channel.parentChannelId - ? { channel, repository } - : undefined; - } - let parsed = local === undefined ? undefined : parseDocumentPath(local); - if (parsed?.slug) { - let repository = await directRepository(caller, parsed.owner, parsed.repository); - if (!repository?.permissions.pull) return "forbidden" as const; - let channel = await auth.storage.channels.resolve( - repository.id, - documentSlug(parsed.slug), - ); - return channel ? { channel, repository } : undefined; +async function directRepositoryFor( + auth: LocatorAuth, + caller: HostedCaller, + owner: string, + name: string, +) { + try { + return await auth.github.repository(caller.oauthToken, owner, name); + } catch (err) { + if (err instanceof GitHubError && (err.status === 403 || err.status === 404)) { + return undefined; } + throw err; + } +} - let legacy = path?.origin === auth.config.origin - ? /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(path.pathname) +/** + * The document a locator names, by id, legacy channel URL, or document URL, + * with the caller's own repository access; "forbidden" when it cannot read it. + */ +export async function locateDocument(auth: LocatorAuth, caller: HostedCaller, locator: string) { + let path: URL | undefined; + try { + path = new URL(locator, auth.config.origin); + } catch { + path = undefined; + } + let local = path?.origin === auth.config.origin ? path.pathname : undefined; + let child = local === undefined ? undefined : parseChildDocumentPath(local); + if (child) { + let repository = await directRepositoryFor(auth, caller, child.owner, child.repository); + if (!repository?.permissions.pull) return "forbidden" as const; + let channel = await auth.storage.channels.resolve( + repository.id, + documentSlug(child.childSlug), + ); + let parent = channel?.parentChannelId + ? await auth.storage.channels.resolve(repository.id, documentSlug(child.parentSlug)) : undefined; - let id = legacy?.[1]?.toLowerCase(); - let channel = await auth.storage.channels.get(id && isChannelId(id) ? id : locator); - if (!channel) return undefined; - let repository = await directRepository( - caller, - channel.repositoryOwner, - channel.repositoryName, + return channel && parent?.id === channel.parentChannelId + ? { channel, repository } + : undefined; + } + let parsed = local === undefined ? undefined : parseDocumentPath(local); + if (parsed?.slug) { + let repository = await directRepositoryFor(auth, caller, parsed.owner, parsed.repository); + if (!repository?.permissions.pull) return "forbidden" as const; + let channel = await auth.storage.channels.resolve( + repository.id, + documentSlug(parsed.slug), ); - if (!repository?.permissions.pull || repository.id !== channel.repositoryId) { - return "forbidden" as const; - } - return { channel, repository }; + return channel ? { channel, repository } : undefined; } + let legacy = path?.origin === auth.config.origin + ? /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(path.pathname) + : undefined; + let id = legacy?.[1]?.toLowerCase(); + let channel = await auth.storage.channels.get(id && isChannelId(id) ? id : locator); + if (!channel) return undefined; + let repository = await directRepositoryFor( + auth, + caller, + channel.repositoryOwner, + channel.repositoryName, + ); + if (!repository?.permissions.pull || repository.id !== channel.repositoryId) { + return "forbidden" as const; + } + return { channel, repository }; +} + +/** Bind the backend-neutral MCP surface to hosted GitHub authentication. */ +export function hosted( + auth: HostedAuth, + persistence?: ImplementationPersistence, + callbacks: HostedCallbacks = {}, +): McpOptions { + let directRepository = (caller: HostedCaller, owner: string, name: string) => + directRepositoryFor(auth, caller, owner, name); + let locatedChannel = (caller: HostedCaller, locator: string) => + locateDocument(auth, caller, locator); + async function writableChannel(caller: HostedCaller, locator: string) { let located = await locatedChannel(caller, locator); if (!located || located === "forbidden") return { kind: "unavailable" as const }; @@ -319,15 +337,17 @@ export function hosted( instruction: input.instruction, ...(input.checkout === undefined ? {} : { checkout: input.checkout }), }); + let url = + new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href; + if (code === "planner-not-attached") { + return { + kind: "refused", + code, + message: notAttachedMessage(url, `${repository.owner}/${repository.name}`), + }; + } if (code) return { kind: "refused", code }; - return { - kind: "invoked", - document: { - ...summary(channel), - url: - new URL(await documentUrl(channel, auth.storage.channels), auth.config.origin).href, - }, - }; + return { kind: "invoked", document: { ...summary(channel), url } }; }, } : undefined, diff --git a/apps/server/src/mcp/invoke.test.ts b/apps/server/src/mcp/invoke.test.ts index e774e92eb..831faf4dd 100644 --- a/apps/server/src/mcp/invoke.test.ts +++ b/apps/server/src/mcp/invoke.test.ts @@ -93,6 +93,7 @@ test("invoke_planner exposes each refusal as an object code", async () => { "document-unavailable", "repository-forbidden", "document-archived", + "planner-not-attached", "planner-owner-unavailable", "checkout-unverified", "planner-unavailable", @@ -107,3 +108,22 @@ test("invoke_planner exposes each refusal as an object code", async () => { expect(result.result).toMatchObject({ isError: true, structuredContent: { code } }); } }); + +test("invoke_planner carries a refusal's message beside its code", async () => { + let message = "No Planner is attached to this document. Connect a Planner client to attach one."; + let mcp = endpoint({ + invoke: async () => ({ kind: "refused", code: "planner-not-attached", message }), + }); + let result = await call(mcp, "tools/call", { + name: "invoke_planner", + arguments: { id: "document-id", instruction: "go" }, + }); + expect(result.result).toMatchObject({ + isError: true, + structuredContent: { code: "planner-not-attached", message }, + }); + expect(JSON.parse(result.result.content[0].text)).toEqual({ + code: "planner-not-attached", + message, + }); +}); diff --git a/apps/server/src/storage/contract.ts b/apps/server/src/storage/contract.ts index 36c7ce3f6..f9931735a 100644 --- a/apps/server/src/storage/contract.ts +++ b/apps/server/src/storage/contract.ts @@ -1,6 +1,7 @@ import { researchWorkspace } from "./research-inline-contract-fixtures"; import { experimentContract } from "./experiment-contract"; import { imageContract } from "./image-contract"; +import { plannerAttachmentContract } from "./planner-attachment-contract"; import { researchInlineContract } from "./research-inline-contract"; import { describe, expect, it } from "bun:test"; @@ -35,6 +36,7 @@ function attempt(action: () => Promise): Promise { export function storageContract(name: string, factory: Factory): void { experimentContract(name, factory); imageContract(name, factory); + plannerAttachmentContract(name, factory); describe(`${name} storage`, () => { it("supports expiring metadata-only sessions for local authentication", async () => { let storage = await opened(factory); diff --git a/apps/server/src/storage/planner-attachment-contract.ts b/apps/server/src/storage/planner-attachment-contract.ts new file mode 100644 index 000000000..6b1745d48 --- /dev/null +++ b/apps/server/src/storage/planner-attachment-contract.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; + +import { Sessions } from "../auth/session"; +import { PlannerAttachments } from "../harness/remote/attachments"; +import { contractId as id, openedStorage } from "./contract-support"; + +import type { AttachmentAuth } from "../harness/remote/attachments"; +import type { PlannerLink } from "../harness/remote/link"; +import type { StorageFactory } from "./contract-support"; +import type { StorageAdapter } from "./port"; + +const GRANT = { accessExpiresIn: 28_800, refreshToken: "refresh", refreshExpiresIn: 86_400 }; + +async function attachable(storage: StorageAdapter) { + let now = new Date(); + let member = { id: id("member"), login: "member" }; + let rival = { id: id("rival"), login: "rival" }; + for (let user of [member, rival]) await storage.users.put({ ...user, avatarUrl: "", now }); + let channelId = id("channel"); + let repository = { id: id("repository"), owner: "octo-org", name: "score" }; + await storage.channels.create({ + id: channelId, + repositoryId: repository.id, + repositoryOwner: repository.owner, + repositoryName: repository.name, + title: "Attached document", + createdBy: member.id, + now, + }); + let skew = { ms: 0 }; + let clock = () => new Date(Date.now() + skew.ms); + let sessions = new Sessions(storage, false, clock); + let signIn = async (user: { id: string }) => + (await sessions.issue(user.id, { ...GRANT, accessToken: `${user.id}-login` })).id; + let logins = { member: await signIn(member), rival: await signIn(rival) }; + let auth: AttachmentAuth = { + config: { origin: "https://chopin.test" }, + storage, + sessions, + clock, + admission: { allowed: async () => true }, + github: { + repositoryAccess: async () => ({ + ...repository, + fullName: "octo-org/score", + private: true, + url: "https://github.test/octo-org/score", + defaultBranch: "main", + permissions: { pull: true, push: true, admin: false }, + }), + }, + }; + let owner = async () => (await storage.channels.readAgent(channelId, new Date()))?.agent; + let request = (user: { id: string; login: string }) => ({ channelId, repository, user }); + return { auth, sessions, member, rival, logins, channelId, owner, request, skew }; +} + +function link(): PlannerLink { + return {} as PlannerLink; +} + +function revocable() { + let refusals: { code: string; message: string }[] = []; + let revocable = { revoke: (code: string, message: string) => refusals.push({ code, message }) }; + return { link: revocable as unknown as PlannerLink, refusals }; +} + +async function released(owner: () => Promise<{ ownerSessionId?: string } | undefined>) { + for (let deadline = Date.now() + 3_000; Date.now() < deadline;) { + if (!(await owner())?.ownerSessionId) return; + await Bun.sleep(10); + } +} + +/** Remote Planner attachments over each storage adapter's generation-guarded ownership. */ +export function plannerAttachmentContract(name: string, factory: StorageFactory): void { + describe(`${name} Planner attachments`, () => { + test("an attachment owns the document, refuses another login, and survives its own reattach", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 60_000); + try { + let granted = await attachments.attach(f.request(f.member), link()); + expect(granted.ok).toBe(true); + let held = await f.owner(); + expect(held).toMatchObject({ ownerSessionId: f.logins.member, status: "ready" }); + expect(await attachments.attach(f.request(f.rival), link())).toMatchObject({ + ok: false, + code: "planner-attached", + message: expect.stringContaining("member is attached"), + }); + if (granted.ok) granted.detach(); + expect(attachments.attached(f.channelId)).toBe(false); + expect(await f.owner()).toEqual(held); + expect(await attachments.attach(f.request(f.rival), link())).toMatchObject({ + ok: false, + code: "planner-attached", + message: expect.stringContaining("member detached"), + }); + expect((await attachments.attach(f.request(f.member), link())).ok).toBe(true); + expect(attachments.attached(f.channelId)).toBe(true); + expect(await f.owner()).toEqual(held); + } finally { + attachments.close(); + await storage.close(); + } + }); + + test("the grace period's end clears only the ownership the attachment still holds", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 30); + try { + let first = await attachments.attach(f.request(f.member), link()); + let generation = (await f.owner())!.generation; + if (first.ok) first.detach(); + await released(f.owner); + expect(await f.owner()).toMatchObject({ + ownerSessionId: undefined, + generation, + status: "unavailable", + }); + expect(attachments.holder(f.channelId)).toBeUndefined(); + + let rival = await attachments.attach(f.request(f.rival), link()); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: f.logins.rival, generation: generation + 1 }); + if (rival.ok) rival.detach(); + let channels = storage.channels; + expect( + await channels.clearAgentOwner(f.channelId, f.logins.rival, generation + 1, new Date()), + ) + .toBe(true); + await channels.claimAgentOwner(f.channelId, f.logins.member, new Date()); + await Bun.sleep(80); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: f.logins.member, generation: generation + 2 }); + } finally { + attachments.close(); + await storage.close(); + } + }); + + test("startup clears an attachment's ownership and a new process starts unattached", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 60_000); + try { + await attachments.attach(f.request(f.member), link()); + let generation = (await f.owner())!.generation; + let lease = await storage.leases.acquire(id("writer"), id("instance"), 60_000); + await storage.sessions.reset(new Date(), lease!, 60_000); + expect(await f.owner()).toMatchObject({ ownerSessionId: undefined, generation }); + + let sessions = new Sessions(storage, false); + let restarted = new PlannerAttachments({ ...f.auth, sessions }, 60_000); + try { + expect(restarted.attached(f.channelId)).toBe(false); + expect(await restarted.attach(f.request(f.member), link())).toMatchObject({ + ok: false, + code: "sign-in-required", + }); + let login = await sessions.issue(f.member.id, { ...GRANT, accessToken: "again" }); + expect((await restarted.attach(f.request(f.member), link())).ok).toBe(true); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: login.id, generation: generation + 1 }); + } finally { + restarted.close(); + } + } finally { + attachments.close(); + await storage.close(); + } + }); + + test("an owner sign-in storage already sees expired releases the attachment as sign-in-required", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let attachments = new PlannerAttachments(f.auth, 60_000); + try { + let attached = revocable(); + expect((await attachments.attach(f.request(f.member), attached.link)).ok).toBe(true); + let generation = (await f.owner())!.generation; + f.skew.ms = 31 * 24 * 60 * 60 * 1_000; + expect((await storage.channels.readAgent(f.channelId, f.auth.clock()))?.agent) + .toMatchObject({ ownerSessionId: undefined, generation }); + expect(await attachments.verify(f.channelId, attached.link)).toBe(false); + expect(attached.refusals).toEqual([{ + code: "sign-in-required", + message: expect.stringContaining("The Chopin browser sign-in that made member"), + }]); + f.skew.ms = 0; + expect(await f.owner()) + .toMatchObject({ ownerSessionId: undefined, generation, status: "unavailable" }); + expect(attachments.holder(f.channelId)).toBeUndefined(); + } finally { + attachments.close(); + await storage.close(); + } + }); + + test("a reset during a grace reattach refuses it and leaves the document free", async () => { + let storage = await openedStorage(factory); + let f = await attachable(storage); + let reading = Promise.withResolvers(); + let resume = Promise.withResolvers(); + let paused = false; + let channels = { + ...storage.channels, + readAgent: async (channelId: string, now: Date) => { + let snapshot = await storage.channels.readAgent(channelId, now); + if (!paused) return snapshot; + paused = false; + reading.resolve(); + await resume.promise; + return snapshot; + }, + }; + let attachments = new PlannerAttachments({ ...f.auth, storage: { channels } }, 60_000); + try { + let first = await attachments.attach(f.request(f.member), link()); + if (first.ok) first.detach(); + let held = (await f.owner())!; + paused = true; + let reattached = revocable(); + let reattaching = attachments.attach(f.request(f.member), reattached.link); + await reading.promise; + expect( + await storage.channels.clearAgentOwner( + f.channelId, + held.ownerSessionId!, + held.generation, + new Date(), + ), + ).toBe(true); + attachments.ownerReset(f.channelId); + resume.resolve(); + expect(await reattaching).toMatchObject({ ok: false, code: "access-revoked" }); + expect(attachments.attached(f.channelId)).toBe(false); + expect(attachments.holder(f.channelId)).toBeUndefined(); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: undefined, generation: held.generation }); + expect((await attachments.attach(f.request(f.rival), link())).ok).toBe(true); + expect(await f.owner()) + .toMatchObject({ ownerSessionId: f.logins.rival, generation: held.generation + 1 }); + } finally { + attachments.close(); + await storage.close(); + } + }); + }); +} diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts index cc6e64fd6..55cd854f6 100644 --- a/apps/server/src/testing/config.ts +++ b/apps/server/src/testing/config.ts @@ -29,6 +29,7 @@ export function configured(overrides: Record = {}) { JEV_API_KEY: undefined, JEV_MODEL: undefined, JEV_TIMEOUT_MS: undefined, + PLANNER_ATTACH_GRACE_MS: undefined, TYPESAFE_API_KEY: undefined, HARNESS: undefined, HARNESS_AUTH: undefined, diff --git a/apps/server/src/testing/planner-link-node-client.mjs b/apps/server/src/testing/planner-link-node-client.mjs new file mode 100644 index 000000000..c1ca43db0 --- /dev/null +++ b/apps/server/src/testing/planner-link-node-client.mjs @@ -0,0 +1,39 @@ +import { connectPlannerLink } from "../../../../packages/planner-link/src/client.ts"; + +let [url, token, document] = process.argv.slice(2); +let usage = { inputTokens: {}, outputTokens: {} }; + +let client = await connectPlannerLink({ + url, + token, + document, + handlers: { + async turn(context) { + let { prompt, tools } = context.message; + context.emit({ type: "stream-start", modelId: "node-scripted" }); + if (prompt === "abort" || prompt === "hang") { + if (!context.signal.aborted) { + await new Promise(resolve => { + context.signal.addEventListener("abort", resolve, { once: true }); + }); + } + return; + } + if (prompt === "tools") { + for (let tool of tools) await context.callHostTool(tool.name, {}); + } + if (prompt === "output") { + context.emit({ type: "text-start", id: "answer" }); + context.emit({ type: "text-delta", id: "answer", delta: '{"answer":"yes"}' }); + context.emit({ type: "text-end", id: "answer" }); + } + context.emit({ type: "finish-step", finishReason: { unified: "stop" }, usage }); + context.emit({ type: "finish", finishReason: { unified: "stop" }, totalUsage: usage }); + }, + }, +}); +console.log(JSON.stringify({ attached: client.document.id, node: process.version })); +process.stdin.resume(); +process.stdin.on("end", () => void client.detach()); +await client.closed; +process.exit(0); diff --git a/apps/server/src/testing/planner-link.ts b/apps/server/src/testing/planner-link.ts new file mode 100644 index 000000000..55a4b38a9 --- /dev/null +++ b/apps/server/src/testing/planner-link.ts @@ -0,0 +1,326 @@ +import { connectPlannerLink, linkSocket, plannerLinkUrl } from "@chopin/planner-link/client"; + +import { AdmissionDenied } from "../auth/admission"; +import { GitHubError } from "../github/client"; +import { Sessions } from "../auth/session"; +import { PlannerAttachments } from "../harness/remote/attachments"; +import { admitPlannerLink, openPlannerLinkSocket } from "../harness/remote/endpoint"; +import { MemoryStorage } from "../storage/memory/adapter"; + +import type { + PlannerLinkClient, + PlannerLinkHandlers, + TurnContext, +} from "@chopin/planner-link/client"; +import type { Repository } from "../github/client"; +import type { AttachmentAuth } from "../harness/remote/attachments"; +import type { PlannerLinkAuth, PlannerLinkSocketData } from "../harness/remote/endpoint"; +import type { PlannerLink } from "../harness/remote/link"; + +export const MEMBER_TOKEN = "member-token"; +export const RIVAL_TOKEN = "rival-token"; +export const VIEWER_TOKEN = "viewer-token"; +export const GUEST_TOKEN = "guest-token"; +export const OUTSIDER_TOKEN = "outsider-token"; +export const DENIED_TOKEN = "denied-token"; + +const ACCOUNTS: Record = { + [MEMBER_TOKEN]: { id: "U_member", login: "member", push: true, signedIn: true }, + [RIVAL_TOKEN]: { id: "U_rival", login: "rival", push: true, signedIn: true }, + [VIEWER_TOKEN]: { id: "U_viewer", login: "viewer", push: false, signedIn: true }, + [GUEST_TOKEN]: { id: "U_guest", login: "guest", push: true, signedIn: false }, +}; + +function loginToken(login: string): string { + return `${login}-browser-login`; +} + +function scoreRepository(owner: string, name: string, push: boolean): Repository { + return { + id: "R_score", + owner, + name, + fullName: `${owner}/${name}`, + private: true, + url: `https://github.test/${owner}/${name}`, + defaultBranch: "main", + permissions: { pull: true, push, admin: false }, + }; +} + +/** + * GitHub as hosted MCP sees it: the member and the rival can push to + * `octo-org/score` and are signed in to Chopin in a browser, the viewer can + * only pull, the guest can push but never signed in, the outsider is a valid + * account without access, the denied account fails admission, and every other + * token is rejected by GitHub. + */ +export async function linkAuth(channelId?: string) { + let storage = new MemoryStorage(); + let now = new Date(); + for (let account of Object.values(ACCOUNTS)) { + await storage.users.put({ id: account.id, login: account.login, avatarUrl: "", now }); + } + let clock = { skewMs: 0 }; + let revocations = new Set<(sessionId: string) => Promise>(); + let sessions = new Sessions(storage, false, () => new Date(Date.now() + clock.skewMs), { + onRevoked: async id => { + for (let revoked of revocations) await revoked(id); + }, + }); + let logins = new Map(); + let cookies = new Map(); + for (let account of Object.values(ACCOUNTS)) { + if (!account.signedIn) continue; + let issued = await sessions.issue(account.id, { + accessToken: loginToken(account.login), + accessExpiresIn: 28_800, + refreshToken: "refresh-token", + refreshExpiresIn: 86_400, + }); + logins.set(account.login, issued.id); + cookies.set(account.login, issued.cookie.split(";")[0]!); + } + let channel = await storage.channels.create({ + id: channelId ?? crypto.randomUUID(), + repositoryId: "R_score", + repositoryOwner: "octo-org", + repositoryName: "score", + title: "Linked document", + createdBy: "U_member", + now, + }); + let auth = { + config: { origin: "https://chopin.test" }, + storage, + sessions, + clock: () => new Date(), + admission: { + async user(token: string) { + if (token === DENIED_TOKEN) throw new AdmissionDenied(); + let account = ACCOUNTS[token]; + if (account) return { id: account.id, login: account.login, avatarUrl: "" }; + if (token === OUTSIDER_TOKEN) return { id: "U_outsider", login: "outsider", avatarUrl: "" }; + throw new GitHubError("Bad credentials", 401); + }, + async allowed(token: string) { + return token !== DENIED_TOKEN; + }, + }, + github: { + async repository(token: string, owner: string, name: string): Promise { + let account = ACCOUNTS[token]; + if (!account) throw new GitHubError("Not Found", 404); + return scoreRepository(owner, name, account.push); + }, + async repositoryAccess(token: string, owner: string, name: string) { + let account = Object.values(ACCOUNTS).find(value => loginToken(value.login) === token); + return account ? scoreRepository(owner, name, account.push) : undefined; + }, + }, + }; + return { + auth: auth as unknown as PlannerLinkAuth & AttachmentAuth & { github: typeof auth.github }, + storage, + sessions, + logins, + channel, + /** Moves the browser logins' clock, so they expire without the server's clock moving. */ + clock, + /** Called with every browser login that ends, as `main.ts` wires `onSessionRevoked`. */ + revocations, + /** Signs `login` out of the browser, as the logout route does. */ + async signOut(login: string) { + let cookie = cookies.get(login)!; + return sessions.revoke(new Request("https://chopin.test/", { headers: { cookie } })); + }, + }; +} + +/** A Bun server that serves only the Planner link, as the Chopin server does under HARNESS=remote. */ +export function linkServer( + auth: PlannerLinkAuth & AttachmentAuth, + timeoutMs = 2_000, + revalidateEveryMs?: number, + graceMs = 2_000, +) { + let links: PlannerLink[] = []; + let attachments = new PlannerAttachments(auth, graceMs); + let server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request, self) { + let outcome = await admitPlannerLink(request, auth); + if (outcome instanceof Response) return outcome; + return self.upgrade(request, { data: outcome }) + ? undefined + : new Response("upgrade failed", { status: 400 }); + }, + websocket: { + open(ws) { + openPlannerLinkSocket(ws, auth, { + attachments, + timeoutMs, + revalidateEveryMs, + register(_channelId, link) { + links.push(link); + return () => { + links = links.filter(candidate => candidate !== link); + }; + }, + }); + }, + message(ws, raw) { + ws.data.link?.receive(raw); + }, + close(ws) { + ws.data.link?.disconnected(); + }, + }, + }); + return { + url: `http://127.0.0.1:${server.port}`, + attachments, + get links() { + return links; + }, + stop: () => { + attachments.close(); + return server.stop(true); + }, + }; +} + +/** Raw frames over a socket that authenticated as the member, for protocol violations. */ +export async function rawLink(url: string, token = MEMBER_TOKEN) { + let socket = linkSocket(plannerLinkUrl(url), token); + let messages: any[] = []; + let arrivals = new Set<() => void>(); + let closed = Promise.withResolvers<{ code: number; reason: string }>(); + socket.addEventListener("message", event => { + messages.push(JSON.parse(String(event.data))); + for (let wake of arrivals) wake(); + }); + socket.addEventListener("close", event => { + closed.resolve({ code: event.code, reason: event.reason }); + for (let wake of arrivals) wake(); + }); + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener("error", () => reject(new Error("raw link failed")), { once: true }); + }); + async function next(type: string): Promise { + for (let deadline = Date.now() + 4_000; Date.now() < deadline;) { + let found = messages.find(message => message.type === type); + if (found) { + messages.splice(messages.indexOf(found), 1); + return found; + } + if (socket.readyState === WebSocket.CLOSED) break; + await new Promise(resolve => { + let wake = () => { + arrivals.delete(wake); + resolve(); + }; + arrivals.add(wake); + setTimeout(wake, 50); + }); + } + throw new Error(`no ${type} message arrived`); + } + return { + socket, + messages, + next, + send: (message: unknown) => + socket.send(typeof message === "string" ? message : JSON.stringify(message)), + closed: closed.promise, + }; +} + +/** Ends a scripted turn the way a model's last step does. */ +export function finishTurn(context: TurnContext): void { + let usage = { inputTokens: {}, outputTokens: {} }; + context.emit({ type: "finish-step", finishReason: { unified: "stop" }, usage }); + context.emit({ type: "finish", finishReason: { unified: "stop" }, totalUsage: usage }); +} + +/** + * The scripted test client: a fake model that answers the prompts the harness + * contract and the link tests send. + */ +export function scriptedPlanner(overrides: Partial = {}) { + let turns: TurnContext["message"][] = []; + let destroyed: string[] = []; + let handlers: PlannerLinkHandlers = { + async turn(context) { + turns.push(context.message); + context.emit({ type: "stream-start", modelId: "scripted" }); + let prompt = context.message.prompt; + if (prompt === "abort" || prompt === "hang") { + if (!context.signal.aborted) { + await new Promise(resolve => { + context.signal.addEventListener("abort", resolve, { once: true }); + }); + } + return; + } + if (prompt === "tools") { + for (let tool of context.message.tools) await context.callHostTool(tool.name, {}); + } + if (prompt === "output") { + context.emit({ type: "text-start", id: "answer" }); + context.emit({ type: "text-delta", id: "answer", delta: '{"answer":"yes"}' }); + context.emit({ type: "text-end", id: "answer" }); + } + finishTurn(context); + }, + destroy(message) { + destroyed.push(message.session); + }, + ...overrides, + }; + return { handlers, turns, destroyed }; +} + +export function attach( + url: string, + document: string, + handlers: PlannerLinkHandlers, + token = MEMBER_TOKEN, +): Promise { + return connectPlannerLink({ url, token, document, handlers }); +} + +/** + * The same scripted client run by Node.js from the wire-only client module, or + * undefined when no Node.js with built-in TypeScript stripping is installed. + */ +export async function nodeClient(url: string, document: string) { + let node = Bun.which("node"); + if (!node) return undefined; + let probe = Bun.spawnSync([node, "-p", "Boolean(process.features.typescript)"]); + if (probe.stdout.toString().trim() !== "true") return undefined; + let child = Bun.spawn([ + node, + new URL("./planner-link-node-client.mjs", import.meta.url).pathname, + url, + MEMBER_TOKEN, + document, + ], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + let reader = child.stdout.getReader(); + let { value } = await reader.read(); + reader.releaseLock(); + let line = JSON.parse(new TextDecoder().decode(value).trim()) as { + attached: string; + node: string; + }; + return { + ...line, + async stop() { + child.stdin.end(); + await Promise.race([child.exited, Bun.sleep(2_000)]); + child.kill(); + }, + }; +} diff --git a/apps/server/src/wire.ts b/apps/server/src/wire.ts index 314aced8a..d0edba037 100644 --- a/apps/server/src/wire.ts +++ b/apps/server/src/wire.ts @@ -11,7 +11,7 @@ */ import type { Server, ServerWebSocket } from "bun"; -import type { Frame, Outgoing } from "@chopin/protocol"; +import type { Frame, Outgoing, Session } from "@chopin/protocol"; import type { DecisionWatch } from "./socket/decision-watch"; export type Identity = { @@ -82,8 +82,13 @@ export function reply(ws: Sender, rid: string, frame: T): vo } /** Refuse one request, on the socket that made it. */ -export function fail(ws: Sender, rid: string, message: string): void { - ws.send(stamp({ kind: "session:error", ts: 0, message }, { rid })); +export function fail( + ws: Sender, + rid: string, + message: string, + code?: Session.Failure["code"], +): void { + ws.send(stamp({ kind: "session:error", ts: 0, message, ...(code ? { code } : {}) }, { rid })); } /** Tell one socket something it did not ask for. */ diff --git a/apps/web/src/chat/references.test.ts b/apps/web/src/chat/references.test.ts index ff4d39172..7096273c0 100644 --- a/apps/web/src/chat/references.test.ts +++ b/apps/web/src/chat/references.test.ts @@ -18,6 +18,7 @@ import { selectedReplacement, withNoticesAfter, } from "./references"; +import { WireRefusal } from "../wire"; import type { ReferenceDraft, ReferenceTarget } from "./references"; @@ -366,4 +367,12 @@ describe("acknowledged composer drafts", () => { expect(bounded).toEndWith("..."); expect(boundedChatError(new Error("connection lost"))).toContain("Check the connection"); }); + + test("shows a missing remote Planner's instructions whole, however long", () => { + let message = `No Planner is attached to this document. ${ + "Connect a Planner client. ".repeat(20) + }`.trim(); + expect(boundedChatError(new WireRefusal(message, "planner-not-attached"))).toBe(message); + expect(boundedChatError(new WireRefusal(message))).toEndWith("..."); + }); }); diff --git a/apps/web/src/chat/references.ts b/apps/web/src/chat/references.ts index 21b641a32..ffb666b07 100644 --- a/apps/web/src/chat/references.ts +++ b/apps/web/src/chat/references.ts @@ -364,6 +364,7 @@ export function acknowledgeDraft(current: ComposerDraft, submitted: ComposerDraf export function boundedChatError(error: unknown): string { let detail = error instanceof Error ? error.message.trim() : ""; + if (detail && (error as { code?: unknown }).code === "planner-not-attached") return detail; if (!detail || /^(connection lost|disposed|not connected)$/i.test(detail)) { return "Message not sent. Check the connection and try again."; } diff --git a/apps/web/src/wire.ts b/apps/web/src/wire.ts index f7accad90..7248aa37b 100644 --- a/apps/web/src/wire.ts +++ b/apps/web/src/wire.ts @@ -10,6 +10,8 @@ * same build work on localhost, over a LAN address, and through a tunnel. */ +import type { Session } from "@chopin/protocol"; + export type Status = | "connecting" | "connected" @@ -20,6 +22,17 @@ export type Status = export type Unsubscribe = () => void; +/** A request the server refused; `code` marks a refusal to present in full. */ +export class WireRefusal extends Error { + readonly code: Session.Failure["code"]; + + constructor(message: string, code?: Session.Failure["code"]) { + super(message); + this.name = "WireRefusal"; + this.code = code; + } +} + type Frame = { kind: string; ts: number; rid?: string; sender?: string }; type Listener = (frame: never) => void; @@ -267,7 +280,8 @@ export class Wire { if (waiting) { this.#pending.delete(frame.rid); if (frame.kind === "session:error") { - waiting.reject(new Error(String((frame as { message?: string }).message))); + let failure = frame as Partial; + waiting.reject(new WireRefusal(String(failure.message), failure.code)); } else { waiting.resolve(frame as never); } diff --git a/bun.lock b/bun.lock index 9f53945e6..85aeace52 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,7 @@ "@chopin/dialect": "workspace:*", "@chopin/draft": "workspace:*", "@chopin/experiment": "workspace:*", + "@chopin/planner-link": "workspace:*", "@chopin/protocol": "workspace:*", "@chopin/question": "workspace:*", "@earendil-works/pi-coding-agent": "catalog:harness", @@ -232,6 +233,16 @@ "react": "catalog:react", }, }, + "packages/planner-link": { + "name": "@chopin/planner-link", + "version": "0.0.0", + "dependencies": { + "zod": "catalog:harness", + }, + "devDependencies": { + "@types/bun": "catalog:bun", + }, + }, "packages/protocol": { "name": "@chopin/protocol", "version": "0.0.0", @@ -497,6 +508,8 @@ "@chopin/icons": ["@chopin/icons@workspace:packages/icons"], + "@chopin/planner-link": ["@chopin/planner-link@workspace:packages/planner-link"], + "@chopin/protocol": ["@chopin/protocol@workspace:packages/protocol"], "@chopin/question": ["@chopin/question@workspace:packages/question"], diff --git a/docs/architecture.md b/docs/architecture.md index 86969706d..0499232b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,8 +3,10 @@ Chopin is one Bun application, one browser client, and one PostgreSQL database. The application serves the built client, HTTP API, Streamable HTTP MCP endpoint, and WebSocket from the same origin. GitHub supplies identity and repository -authorization; the selected harness (GitHub Copilot by default, or Pi) supplies -the hosted document agent runtime, currently named Planner. +authorization; the selected harness (GitHub Copilot by default, Pi, or Atomic) +supplies the hosted document agent runtime, currently named Planner. Under +`HARNESS=remote` the Planner's model instead runs in a member's client attached +over the Planner link WebSocket, `/planner-link`, on the same origin. This document describes the system boundaries and collaborative document model. See [Repository channels](channels.md) for channel creation and access, @@ -33,8 +35,9 @@ and [Self-hosting](self-hosting.md) for deployment. blocks starting research from a child; the API may accept the request, but publication validation rejects linking a grandchild. - The **Planner** is the current name of Chopin's hosted document agent, backed - by GitHub Copilot by default, by Pi under `HARNESS=pi`, or by an in-process - Atomic session under `HARNESS=atomic`. + by GitHub Copilot by default, by Pi under `HARNESS=pi`, by an in-process + Atomic session under `HARNESS=atomic`, or by a client attached over the + Planner link under `HARNESS=remote`. - A **coding agent** is an external MCP client that creates, revises, or implements a document from its own local workspace. @@ -52,6 +55,7 @@ prompt remains optimized for planning. flowchart LR B[Browser] -->|HTTP and WebSocket| S[Bun application] C[Local coding agent] -->|Bearer-authenticated MCP| S + R[Remote Planner client] -->|Bearer-authenticated Planner link WebSocket| S S --> P[(PostgreSQL)] S --> G[GitHub API] S --> A[Harness model provider: Copilot, Pi, or Atomic] @@ -61,9 +65,10 @@ flowchart LR The browser uses a GitHub App user session backed by encrypted hosted credentials. Browser HTTP routes and the WebSocket intersect that identity's repository role with repositories -selected in a GitHub App installation. The external MCP endpoint instead checks -its caller-supplied GitHub bearer token directly. Both paths apply the instance -admission policy. +selected in a GitHub App installation. The external MCP endpoint and the +Planner link instead check their caller-supplied GitHub bearer token directly. +Every path applies the instance admission policy. Under `HARNESS=remote` the +server calls no model provider; the attached client does. The application holds an exclusive database-wide writer lease. It refuses to start beside another active Chopin process and stops if it can no longer renew @@ -71,17 +76,18 @@ the lease. This is a single-writer design, not an application cluster. ## Workspace packages -| Area | Responsibility | Internal dependencies | -| ------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -| `packages/dialect` | Restricted MDX dialect, parsing, serialization, and Lexical schema | none | -| `packages/protocol` | WebSocket types and shared addressing helper | none | -| `packages/question` | Questionnaire definitions, shared drafts, and answer derivation | `protocol` | -| `packages/draft` | Bounded collaborative plain-text draft codec | none | -| `packages/viewport` | Browser viewport geometry and subscriptions | none | -| `packages/diagrams` | SeeCode-derived, bounded diagram rendering and scoped React viewing | `icons` (React peer) | -| `packages/editor` | Collaborative editor, cursors, decisions, comments, and widgets | `diagrams`, `dialect`, `question`, `protocol`, `viewport`, `visuals` | -| `apps/server` | Authentication, channels, rooms, storage, Planner, jobs, MCP, and implementation lifecycle | `diagrams`, `dialect`, `draft`, `question`, `protocol` | -| `apps/web` | Repository picker, channel navigation, Chat, and workspace shell | `dialect`, `draft`, `editor`, `protocol`, `viewport` | +| Area | Responsibility | Internal dependencies | +| ----------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| `packages/dialect` | Restricted MDX dialect, parsing, serialization, and Lexical schema | none | +| `packages/protocol` | WebSocket types and shared addressing helper | none | +| `packages/planner-link` | Remote Planner link messages, schemas, and test client | none | +| `packages/question` | Questionnaire definitions, shared drafts, and answer derivation | `protocol` | +| `packages/draft` | Bounded collaborative plain-text draft codec | none | +| `packages/viewport` | Browser viewport geometry and subscriptions | none | +| `packages/diagrams` | SeeCode-derived, bounded diagram rendering and scoped React viewing | `icons` (React peer) | +| `packages/editor` | Collaborative editor, cursors, decisions, comments, and widgets | `diagrams`, `dialect`, `question`, `protocol`, `viewport`, `visuals` | +| `apps/server` | Authentication, channels, rooms, storage, Planner, jobs, MCP, and implementation lifecycle | `diagrams`, `dialect`, `draft`, `planner-link`, `question`, `protocol` | +| `apps/web` | Repository picker, channel navigation, Chat, and workspace shell | `dialect`, `draft`, `editor`, `protocol`, `viewport` | Runtime workspace packages do not depend on either application. The E2E suite and skill contract tests deliberately import server internals as test harnesses; @@ -135,6 +141,23 @@ 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). +### Remote Planner link + +Under `HARNESS=remote`, `/planner-link` accepts a WebSocket whose upgrade carries +a GitHub bearer, checked like `/mcp`. The client attaches to one document and +needs push or administration access to its repository, rechecked before every +session and turn and periodically while attached. Attaching makes the account's +live browser login the document's Planner owner, one client per document; a +document without one refuses `@chopin` and `invoke_planner` with +`planner-not-attached`. The link closes when that login ends or the document's +Planner is reset. +Turns run on the client's model; Chopin's host +tools, Decisions, and room writes still run in the server under the document's +Planner owner and the permission checks above. The client sees what a turn sees +and steers its document writes during turns writers start; its run reports never +start a turn. See [Remote Planner](hosted-agent.md#remote-planner) and +[Planner link](planner-link.md). + ## State ownership ### Local investigations @@ -520,6 +543,8 @@ Treat these as implementation work, not guarantees to build new behavior upon. `apps/server/src/harness/github-tools.ts` - Copilot SDK adapter: `apps/server/src/harness/copilot-sdk/adapter.ts` - Atomic SDK adapter: `apps/server/src/harness/atomic/adapter.ts` +- Remote Planner adapter and link endpoint: `apps/server/src/harness/remote/` and + `packages/planner-link` - Channel routes and IDs: `apps/server/src/channels/` - Research requests and child publication: `apps/server/src/research/service.ts` - Inline request state and anchored children: `apps/web/src/research-requests.ts` diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 369024138..879b8603f 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -5,7 +5,8 @@ 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. 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). +Atomic session, described [below](#full-atomic-planner); under `remote` its model +runs in a member's own client, described in [Remote Planner](#remote-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,10 @@ Chopin runs the Planner through `@ai-sdk/harness`: `HARNESS` selects one adapter 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. Under `copilot-sdk` and `pi`, Chopin owns -every tool the Planner can call. +further reviewed adapters. Both require an explicit `HARNESS_AUTH`. `remote` +runs no model in the server at all: each turn goes to a client attached over the +Planner link. The 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 @@ -31,7 +33,10 @@ The first eligible editor to invoke the Planner or start a model-backed research request supplies the GitHub App user access token for that channel and, under the default `copilot-sdk` harness, the Copilot entitlement. Under `HARNESS=pi` and `HARNESS=atomic` the owner supplies only the GitHub token; model access -comes from the operator's `HARNESS_AUTH` mode. The user must pass instance +comes from the operator's `HARNESS_AUTH` mode. Under `HARNESS=remote` the owner +also supplies only the GitHub token, and the model runs in the client attached +to the document; only [attaching](#remote-planner) makes an account the owner +there. The user must pass instance admission and have repository push or administration access. Ownership is assigned atomically in storage and guarded by a generation token. @@ -47,6 +52,20 @@ 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. +Under `HARNESS=remote` only attaching claims ownership. An `@chopin` message or +`invoke_planner` call for a document without an attached Planner is refused with +`planner-not-attached`, and with one it runs as a turn on that Planner under its +owner. Other Planner requests, such as sending a comment to the Planner or a +Build draft, claim nothing either: with a client attached they run on it, and +without one they fail as any request without a usable owner does, not with +`planner-not-attached`. Model-backed research is unavailable, because the remote +Planner requires `BACKGROUND_JOBS=off`; a research request claims no ownership +and is refused as not ready. + +An attachment lasts only as long as its owner. Signing that browser login out, +its expiry, or a repository writer's reset of the document's Planner closes the +attached link at once and frees the document for any eligible account to attach; +see [Planner link ownership](planner-link.md#ownership). Planner ownership stores only the owner session ID, referring to a login loaded in this process. Hosted logins separately persist encrypted credentials in @@ -248,6 +267,133 @@ 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`. +## Remote Planner + +Under `HARNESS=remote` the Planner's model runs in a client attached to the +document over the Planner link, usually a member's own Atomic session on their +machine. The server keeps the document, Decisions, room writes, and anchors; the +client keeps its code, model credentials, and its own tools. The adapter +(`apps/server/src/harness/remote`) forwards each Planner session as `start` and +each turn as `turn`, and re-emits the parts the client streams back. + +The link is a WebSocket at `/planner-link`, on the application's own origin. +[Planner link](planner-link.md) is its complete wire format, written for +clients that cannot import Chopin's packages. Its messages are declared and +validated by `@chopin/planner-link` (`packages/planner-link`), whose minimal +client in `@chopin/planner-link/client` uses only that wire format at runtime. Every message is a JSON text frame of at most +2 MiB, checked against a strict schema; a binary, malformed, out-of-order, or +oversized message ends the link (close codes 1008 or 1009) after an `error` +message naming the problem. + +| Server to client | Client to server | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `attached` or `refused` (answers `attach`) | `attach` (version, document id or URL, checkout identity) | +| `start` (session, `planner` or `isolated` mode) | `started` or `start-failed` | +| `turn` (prompt, instructions, host tool specs) | `tools` (the client's own tools for this turn) | +| `abort` | `part` (text, reasoning, tool input, own tool call/result, finish-step, finish, error) | +| `host-tool-result` | `host-tool-call` | +| `input-result` (answered, expired, cancelled, failed) | `input-request`, `input-cancel` | +| `stop`, `resume` (Stop and Resume Planner) | `control-result`, `runs` (run cards for Chat) | +| `destroy` | `turn-end`, `detach` | + +**Attaching.** The upgrade request carries `Authorization: Bearer `, +checked exactly as hosted MCP checks it: the token must resolve through instance +admission, a bad or missing token gets HTTP 401, a refused account 403, a foreign +`Origin` 403, and GitHub outages 503, all before anything is upgraded. The first +message must be `attach` with protocol version 1. Its document is resolved like +an MCP document locator, and the bearer's own account must have push or +administration access to the document's repository. An unsupported version, a +repository it cannot write, or a missing or archived document is refused with a +`refused` message and close code 4426, 4403, or 4404. Neither attaching nor a +refusal posts anything to the document. The account and repository access are +checked again before every session and every turn the link starts, including +turns of a session kept for its runs, and every 5 minutes while the link stays +attached; losing either fails that session or turn and closes the link with +`access-revoked`. + +**Ownership.** Attaching makes the account the document's Planner owner through +the same generation-guarded storage claim as any other owner +(`apps/server/src/harness/remote/attachments.ts`). The owner record names the +account's live browser login, not the link, because Chopin's repository tools +run with that login's GitHub App token and never with the link's bearer; an +account with no live sign-in since the server started is refused with +`sign-in-required` (4401). The login must pass the same eligibility as every +Planner owner: instance admission and push or administration access. This is +stricter than the design's "can read the repository", because an attached +client steers what the Planner writes and runs repository tools as the owner. +An owner left without an attachment, which only a race or an operator reset can +produce, is cleared under its generation and the document is claimed again. + +One client holds a document's Planner. While it is attached, every other +`attach` for that document is refused with `planner-attached` (4409) and a +message naming its GitHub login. A link that detaches or drops keeps the +ownership for `PLANNER_ATTACH_GRACE_MS` (2 minutes by default): the same account +can attach again within it and keeps the same ownership generation, and anyone +else is refused. When it ends, the ownership is cleared under its generation, so +a newer owner is never released by an old timer. An attach, including a grace +reattach, binds only if no sign-out, expiry, or Planner reset of its ownership +landed while it was checking, including the eligibility checks of the sign-in +it is about to claim with; otherwise it is refused like an attached link would +be closed. Attachments live in the server process; startup already clears +every owner reference, which releases every attachment. + +**Unattached documents.** Members, MCP callers, and research requests never claim +ownership under `HARNESS=remote`. An `@chopin` message or `invoke_planner` call +for a document with no attached client, including one within its grace period, +is refused with `planner-not-attached` before anything is posted or queued. +Before accepting either, the server rechecks an attached link as its periodic +recheck would: if the owner's browser sign-in has ended or storage no longer +assigns that owner, it closes the link, releases the ownership, and refuses the +request the same way. The +refusal names the document URL and says to connect a Planner client to it over +the Planner link from a session in a checkout of the repository, which attaches +that session as the Planner; the browser shows that text on the `@chopin` +request. Room messages are unaffected. Once a client is attached, both arrive as +turns on its link and run under its ownership whoever sent them. + +**Turns and tools.** Chopin's host tools (document, repository, and job tools) +still run in the server against the room, under the document's Planner owner and +the permission checks below. The client calls them with `host-tool-call` and +receives `host-tool-result`. Its own tools run on its machine: it must declare +them with `tools` before calling any, may not reuse a host tool's name, and +reports them as provider-executed `tool-call` and `tool-result` parts. An +undeclared tool fails the turn as a tool boundary failure. Background job +sessions start in `isolated` mode, where the client may offer no tools of its own +and ask nothing. A turn ends with `turn-end`. An abort waits briefly for the +client's `turn-end`, then ends the turn anyway. A turn that ends while one of +its host tools still runs, such as an `ask` waiting on Decisions, stops that tool +and withdraws the cards it opened. A link that closes mid-turn ends the turn +with an error rather than as a stop, its open questions are withdrawn, and runs +it reported are shown stopped; nothing waits on a client that has gone. A +session the client refuses or does not start in time is destroyed, and any +question it asked while starting is withdrawn. + +**Questions.** An `input-request` carries one Atomic `HostInput` call +(`questionnaire`, `confirm`, `select`, `input`, or `editor`) and becomes Decisions +through the same path as the [full Atomic Planner](#full-atomic-planner): the same +verbatim text, the same 30-minute expiry, cancellation that withdraws open +cards, and answers to a workflow question held while the client reports that +run's root as paused. `input-result` returns what Atomic would have received in +process, with a status that tells an expiry from a cancellation. + +**Runs.** The client reports its workflow runs with `runs`; Chat shows those +cards and keeps the session after its turn while any run is live or paused, as +for an Atomic Planner. **Stop Planner** and **Resume Planner** send `stop` and +`resume`, optionally for one run, and wait for `control-result`. Unlike an +Atomic Planner's, a reported run that ends blocked or failed between turns gets +only its Chat notice: the reports are the client's word, so they never start a +Planner turn that no writer asked for. + +**Trust boundary.** The server never holds the client's model credentials and +gains no shell or filesystem access from this harness. The client, in turn, +receives everything a Planner turn sees: the conversation, the document, and +the results of repository tools that run with the document Planner owner's +GitHub App token, limited to the document's repository, which its own account +can already read. The attached client decides what the Planner writes to the +document during turns writers start, which is why attaching requires the push +or administration access every Planner owner needs. Summary and research workers +have no model in the server, so `HARNESS=remote` requires `BACKGROUND_JOBS=off`. + ## Permission checks Before each Chopin host tool or its repository-bound GitHub MCP tool executes, callbacks recheck: diff --git a/docs/local-agent-mcp.md b/docs/local-agent-mcp.md index 1fa31cc87..eaddde681 100644 --- a/docs/local-agent-mcp.md +++ b/docs/local-agent-mcp.md @@ -92,6 +92,22 @@ browser `@chopin` message would: - Without either, the call is refused with `planner-owner-unavailable` and nothing is posted. +A server running `HARNESS=remote` claims no ownership here. Its Planner runs in +a client attached to the document over the [Planner link](planner-link.md), and +only attaching makes an account the owner. With a client attached the turn goes +to it and runs under its owner, whoever invoked it. Without one, including +while a detached Planner's grace period runs and once the attached Planner's +owner signs out of the browser, that sign-in expires, or its Planner is reset, +the call is refused with +`planner-not-attached` and a `message`, and nothing is posted or queued: + +```json +{ + "code": "planner-not-attached", + "message": "No Planner is attached to this document. To attach one, connect a Planner client to https://chopin.example/documents/octo-org/score/release-readiness over the Planner link from a session in a checkout of octo-org/score; that session becomes this document's Planner." +} +``` + 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. @@ -131,8 +147,13 @@ 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`: +Refusals return an object with `code`, and `planner-not-attached` also a +`message`: +- `planner-not-attached`: under `HARNESS=remote`, no client is attached as the + document's Planner. `message` names the document URL and says to connect a + Planner client to it over the Planner link from a session in a checkout of + the repository, which attaches that session as the Planner. - `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. diff --git a/docs/planner-link.md b/docs/planner-link.md new file mode 100644 index 000000000..390ff3e2d --- /dev/null +++ b/docs/planner-link.md @@ -0,0 +1,430 @@ +# Planner link + +The Planner link is the wire contract between a Chopin server running +`HARNESS=remote` and the client that runs its Planner's model, usually a +member's own Atomic session. This page is complete enough to implement a client +from: it states the endpoint, authentication, version handshake, every message, +its limits, and how the link ends. [Hosted agent](hosted-agent.md#remote-planner) +describes what the server does with it, and +[Self-hosting](self-hosting.md#the-remote-planner) how to deploy it. + +`@chopin/planner-link` (`packages/planner-link`) holds the server's schemas. +Its client, `@chopin/planner-link/client`, uses nothing at runtime but this +format, JSON, and the platform `WebSocket`; the remote harness contract suite +runs it in Bun and as a separate Node.js process against a real socket. + +## Endpoint and authentication + +Open a WebSocket to `wss:///planner-link` (`ws:` only for a +loopback development server). It shares the application's origin and port with +`/ws` and `/mcp`, behind the same TLS-terminating proxy. The route exists only +when the server runs with `AGENT` on and `HARNESS=remote`; otherwise every +request to it, upgrade or not, gets HTTP 404 before the bearer is checked. + +The upgrade request must carry `Authorization: Bearer `, the same +bearer [local agent MCP](local-agent-mcp.md) uses. Bun's and Node.js's built-in +`WebSocket` accept it as a `headers` option (`new WebSocket(url, { headers })`; +checked on Node.js 24); browsers cannot send it. A client should not send an +`Origin` header; if it does, it must be the server's own origin. + +The server checks the bearer before upgrading and answers a refusal over HTTP, +so nothing is opened: + +| Status | Meaning | +| ------ | ----------------------------------------------------------- | +| 404 | The server does not run `HARNESS=remote` with `AGENT` on | +| 401 | Missing or malformed bearer, or GitHub rejected the token | +| 403 | The account fails instance admission, or a foreign `Origin` | +| 503 | GitHub is unavailable or rate limiting; retry later | +| 500 | Any other admission failure | + +## Frames + +Every message is one JSON object in one text frame, with a string `type`. A +frame may hold at most **2 MiB** (2,097,152 bytes) of UTF-8. Objects are strict: +a field this page does not list is refused. Binary frames are refused. + +Shared limits, in characters unless stated: + +| Kind | Fields | Limit | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| id | `session`, `turn`, `request`, `toolCallId`, part `id`, run and stage `id`, `run`, `workflowRunId`, `workflowStageId`, `rootRunId`, `active` and `paused` entries, `document.id` in `attached` | 1 to 128 | +| name | tool names, `toolName`, `names` entries, `modelId`, `responseFormat.name` | 1 to 128 | +| short | question `header`, `finishReason.raw` | up to 128 | +| note | `message`, every `title`, `error`, `turn.model`, `responseFormat.description`, question text, option `label` and `description`, `choices` entries, `placeholder`, `initial`, run and stage `name` | up to 4000 | +| text | `prompt`, `instructions`, `delta`, `input` | the frame | + +Other fields state their own limits where they appear. + +`?` marks an optional field. `json` is any JSON value. + +## Handshake + +The client's first message must be `attach`, and it must send nothing else +until the server answers with `attached` or `refused`. + +```text +{ "type": "attach", "version": 1, "document": "", "checkout?": { "origin?": "", "branch?": "", "head?": "" } } +``` + +- `version` is the protocol version the client speaks; this page is version `1`. +- `document` is a document id or its Chopin URL, at most 2048 characters. +- `checkout` describes the client's working tree: `origin` (up to 2048), `branch` + (up to 256), `head` (up to 128). It is informational; the server does not + verify it. + +The server answers: + +```text +{ "type": "attached", "version": 1, "document": { "id": "", "title": "", "repository": "owner/name", "url?": "" } } +{ "type": "refused", "code": "", "message": "" } +``` + +`attached.document.repository` is `owner/name`, at most 257 characters, and +`url` at most 2048. After `refused` the server closes the link. + +### Ownership + +Attaching makes the client the document's Planner and its account the +document's Planner owner. `attach` succeeds when all of these hold: + +- The bearer's GitHub account has push or administration access to the + document's repository. Pull access alone is refused with + `repository-forbidden`. +- The same account is signed in to Chopin in a browser, on this server, since + the server last started. Chopin's repository tools run with that sign-in's + GitHub App token, never with the link's bearer. Without one, `attach` is + refused with `sign-in-required`; sign in at the server's origin and attach + again. +- That sign-in also passes instance admission and has push or administration + access to the repository. +- No other client holds the document's Planner (see below). + +These are the checks every Planner owner passes. A document has one Planner at a +time. While a client is attached, any other `attach` for that document is +refused with `planner-attached`, including one from the same account, and the +`message` names the holder's GitHub login: + +```text +octocat is attached as this document's Planner. A document has one Planner at a time. +``` + +When the link closes, whether by `detach` or a dropped socket, its account keeps +the ownership for a grace period, 2 minutes unless the operator sets +`PLANNER_ATTACH_GRACE_MS`. During it the same account can attach again and keeps +the ownership; another account is refused with `planner-attached`, and the +`message` says until when the holder keeps it: + +```text +octocat detached from this document's Planner and holds it until 2026-10-10T19:42:00.000Z unless they reattach. A document has one Planner at a time. +``` + +When the grace period ends the ownership is released and any eligible account +can attach. A server restart releases every attachment and ownership at once; +reattach after it. + +The server checks the account's admission and push or administration access +again before every `start` and every `turn`, and every 5 minutes while the link +is attached. Once either is lost, or the document is archived or deleted, it +sends `refused` with `access-revoked` and closes the link with 4403; the turn +that triggered the check fails. A GitHub outage during a periodic check leaves +the link open. + +The ownership also ends, without a grace period, when the browser sign-in it +rests on ends or the document's Planner is reset. The server then sends +`refused` and closes an attached link at once, and any eligible account, +including the same one, can attach again immediately: + +| Cause | Refusal code | Close code | +| -------------------------------------------------------------- | ------------------ | ---------- | +| The owner's browser sign-in signs out or expires | `sign-in-required` | 4401 | +| A repository writer resets the document's Planner | `access-revoked` | 4403 | +| The stored ownership no longer names this attachment's sign-in | `access-revoked` | 4403 | + +The same checks before every `start` and `turn`, and the periodic one, also +find an expired sign-in or a released ownership that nothing reported; an +expired sign-in is reported as `sign-in-required` even when the stored +ownership already reads as released. An `@chopin` message or `invoke_planner` +call makes the same check first, so a link it finds stale is closed this way and +the request is refused as [without an attached Planner](#without-an-attached-planner). +An `attach` that is still in progress when one of these causes lands, including +a reattach within the grace period, is refused with the same code and close code +instead of attaching; a fresh attach whose sign-in ends while the server checks +it is refused with `sign-in-required` (4401). The `message` says what happened: + +```text +The Chopin browser sign-in that made octocat this document's Planner ended, so the Planner was released. Sign in to Chopin at https://chopin.example in a browser as octocat, then attach again. +A repository writer reset this document's Planner, so octocat is no longer attached. Attach again to resume. +This document's Planner ownership was released, so octocat is no longer attached. Attach again to resume. +``` + +| Refusal code | Close code | Meaning | +| ---------------------- | ---------- | ---------------------------------------------------------------------- | +| `unsupported-version` | 4426 | The server speaks another `version` | +| `repository-forbidden` | 4403 | The account cannot push to or administer the document's repository | +| `sign-in-required` | 4401 | The account has no live Chopin browser sign-in, or it ended | +| `planner-attached` | 4409 | Another client holds the document's Planner; `message` names its login | +| `document-unavailable` | 4404 | No document matches, or it is being deleted | +| `document-archived` | 4404 | The document is archived | +| `access-revoked` | 4403 | Access or the Planner ownership was lost after attaching (see above) | +| `unavailable` | 1011 | The server could not check the document | + +### Without an attached Planner + +On a server running `HARNESS=remote`, an `@chopin` message in the browser and +an MCP `invoke_planner` call for a document with no attached client are refused +with the code `planner-not-attached`, and nothing is posted or queued. A +document whose Planner is within its grace period counts as unattached, and so +does one whose attached link the request finds stale: its owner's browser +sign-in has ended or the stored ownership no longer names it. The request closes +that link before it is refused. The refusal's message names the document URL: + +```text +No Planner is attached to this document. To attach one, connect a Planner client to over the Planner link from a session in a checkout of ; that session becomes this document's Planner. +``` + +[Local agent MCP](local-agent-mcp.md#hand-an-instruction-to-the-planner) shows +how `invoke_planner` returns it. Once a client is attached, both arrive as +`turn`s on its link. + +## Server to client + +```text +{ "type": "start", "session": "", "mode": "planner" } +{ "type": "turn", "session": "", "turn": "", "prompt": "", "instructions?": "", "model?": "", "tools": [], "responseFormat?": {} } +{ "type": "abort", "session": "", "turn": "" } +{ "type": "host-tool-result", "session": "", "turn": "", "toolCallId": "", "output": json, "isError?": true } +{ "type": "input-result", "request": "", "status": "answered", "value?": json, "message?": "" } +{ "type": "stop", "request": "", "session": "", "run?": "" } +{ "type": "resume", "request": "", "session": "", "run?": "" } +{ "type": "destroy", "session": "" } +{ "type": "error", "code": "malformed-message", "message": "" } +``` + +- `start` opens a Planner session. `mode` is `planner` for a Planner turn + session or `isolated` for a background job's session, which may offer no + tools of its own and gets no Decisions. Answer within **10 seconds**. A + session refused with `start-failed` or not answered in time gets `destroy`, + and any question asked for it meanwhile is withdrawn and answered + `cancelled`. +- `turn` runs one turn. `tools` lists at most 256 of Chopin's host tools as + `{ "name": "", "description?": "", "inputSchema?": {json schema} }`; + `description` may be 64,000 characters. `instructions` is the Planner's + system prompt for the turn. `model` is set only when the operator configured + `MODEL`. `responseFormat` is `{ "type": "text" }` or + `{ "type": "json", "schema?": {}, "name?": "", "description?": "" }`; for + `json`, the turn's text must be that JSON. +- `abort` stops the turn. Answer with `turn-end`; after **10 seconds** the + server ends the turn anyway. The server sends no `host-tool-result` for the + turn's calls after `abort`, so settle any call still waiting as failed. +- `host-tool-result` answers a `host-tool-call`. `output` is the tool's result + as JSON. A failed call has `"isError": true` and `output` + `{ "error": "" }`; that includes a tool that threw, invalid input, a + name outside the turn's `tools`, a call for a turn or session that has ended, + and a result too large for one frame. A call Chopin's permission policy + refuses has `output` `{ "type": "execution-denied", "reason?": "" }`. +- `input-result` answers an `input-request` (see [Questions](#questions)). +- `stop` and `resume` are **Stop Planner** and **Resume Planner**: pause or + resume every workflow run of the session, or only `run`. Answer with + `control-result` within **10 seconds**. +- `destroy` ends the session; abandon its turn, its waiting host calls, and its + questions. +- `error` precedes a close for a protocol violation (see [Closing](#closing)). + +## Client to server + +```text +{ "type": "started", "session": "" } +{ "type": "start-failed", "session": "", "message": "" } +{ "type": "tools", "session": "", "turn": "", "names": [] } +{ "type": "part", "session": "", "turn": "", "part": {} } +{ "type": "host-tool-call", "session": "", "turn": "", "toolCallId": "", "toolName": "", "input": "{}" } +{ "type": "turn-end", "session": "", "turn": "", "status": "finished", "message?": "" } +{ "type": "input-request", "request": "", "session": "", "workflowRunId?": "", "workflowStageId?": "", "rootRunId?": "", "input": {} } +{ "type": "input-cancel", "request": "" } +{ "type": "runs", "session": "", "active": [], "paused": [], "cards": [] } +{ "type": "control-result", "request": "", "ok": true, "message?": "" } +{ "type": "detach" } +``` + +- `started` or `start-failed` answers `start`. Cut `message` here, in + `turn-end`, `control-result`, and an `error` part to 4000 characters: a longer + one is a malformed message and closes the link. +- `tools` declares, before calling any of them, the client's own tools this turn + may call (at most 256). A name may not repeat a host tool's name. An isolated + session must declare none. +- `part` streams the turn (see [Stream parts](#stream-parts)). +- `host-tool-call` runs one of the turn's host tools in the server; `input` is + its arguments as a JSON string. A `toolCallId` may be used once per turn, + whether by a `host-tool-call` or a `tool-call` part, including after its call + has finished or was answered with an error result; reusing one fails the turn + without running the tool again. A name outside the turn's `tools` is answered + with an error result. +- `turn-end` ends the turn: `finished`, `failed` (with `message` as the error + shown), or `aborted`. Every turn needs exactly one. A host tool still running + in the server when the turn ends is stopped, and a Decision it opened is + withdrawn. +- `input-request` and `input-cancel` ask and withdraw questions. A `request` id + may not repeat on the link, even after its question has its `input-result`. + The server remembers every open id and the last 1,024 ids it received; a + remembered id sent again is a `protocol` violation that closes the link. Use + fresh ids, such as UUIDs. +- `runs` reports the session's workflow runs whenever they change. Report them + before `turn-end` so the server keeps the session while runs are live. Reports + only change run cards and Chat notices; they never start a Planner turn. +- `control-result` answers `stop` or `resume`. +- `detach` closes the link with code 1000. The account keeps the document's + Planner for the grace period in [Ownership](#ownership). + +Messages for a session or turn that has ended are ignored, except a +`host-tool-call`, which gets an error result (`The turn has ended.` or +`The session has ended.`). + +### Stream parts + +`part.part` is one of these AI SDK `HarnessV1StreamPart`s, without metadata +fields: + +```text +{ "type": "stream-start", "modelId?": "" } +{ "type": "text-start", "id": "" } +{ "type": "text-delta", "id": "", "delta": "" } +{ "type": "text-end", "id": "" } +{ "type": "reasoning-start", "id": "" } +{ "type": "reasoning-delta", "id": "", "delta": "" } +{ "type": "reasoning-end", "id": "" } +{ "type": "tool-input-start", "id": "", "toolName": "", "providerExecuted?": true, "dynamic?": true, "title?": "" } +{ "type": "tool-input-delta", "id": "", "delta": "" } +{ "type": "tool-input-end", "id": "" } +{ "type": "tool-call", "toolCallId": "", "toolName": "", "input": "{}", "providerExecuted": true, "dynamic?": true } +{ "type": "tool-result", "toolCallId": "", "toolName": "", "result": json, "isError?": true, "preliminary?": true, "dynamic?": true } +{ "type": "finish-step", "finishReason": { "unified": "stop", "raw?": "" }, "usage": {} } +{ "type": "finish", "finishReason": { "unified": "stop", "raw?": "" }, "totalUsage": {} } +{ "type": "error", "error": "" } +``` + +- `tool-call` and `tool-result` parts are for the client's own declared tools + only, and `tool-call` must say `"providerExecuted": true`. A `tool-result` + must follow its own `tool-call`. Anything else fails the turn as a tool + boundary failure, and a `tool-call` reusing a `toolCallId` fails it as a + repeated call. Host tools are called with `host-tool-call`; the server + emits their call and result parts itself. +- `tool-input-start` may name a host tool (its input streams before the + `host-tool-call`) or a declared own tool. +- `finishReason.unified` is one of `stop`, `length`, `content-filter`, + `tool-calls`, `error`, `other`. `usage` and `totalUsage` are + `{ "inputTokens": { "total?", "noCache?", "cacheRead?", "cacheWrite?" }, "outputTokens": { "total?", "text?", "reasoning?" } }` + with non-negative integers. +- `error` fails the turn with that text. + +## Questions + +An `input-request` carries one Atomic `HostInput` call in `input`, and becomes +Decisions in the document: + +```text +{ "method": "questionnaire", "params": { "questions": [{ "question": "", "header": "", "options": [{ "label": "", "description": "", "preview?": "" }], "multiSelect?": true }] } } +{ "method": "confirm", "title": "", "message": "" } +{ "method": "select", "title": "", "choices": [] } +{ "method": "input", "title": "", "placeholder?": "" } +{ "method": "editor", "title": "", "initial?": "" } +``` + +A questionnaire has 1 to 16 questions of up to 32 options; `preview` may be +16,000 characters; `select` offers up to 32 choices. The document's own limits +still apply. Text is shown verbatim. + +`input-result` is `{ "type": "input-result", "request": "", "status": "", "value?": json, "message?": "" }`. +`status` and `value` by method: + +| `status` | `questionnaire` `value` | `confirm` | `select` | `input`, `editor` | `message` | +| ----------- | ---------------------------------------------------------------- | --------- | ---------------- | ----------------- | --------- | +| `answered` | `{ "answers": [answer for every question], "cancelled": false }` | boolean | choice or `null` | text | absent | +| `expired` | `{ "answers": [], "cancelled": true }` | `false` | `null` | `null` | absent | +| `cancelled` | `{ "answers": [answered questions only], "cancelled": true }` | `false` | `null` | `null` | absent | +| `failed` | absent | absent | absent | absent | why | + +- `answered`: members answered every question. `confirm` is `true` only for + **Yes**. `select` is the chosen string from `choices`, or `null` when a member + wrote an answer instead. `input` and `editor` are the text a member wrote. +- `expired`: nobody answered within 30 minutes. The cards stay in Decisions + marked expired and no partial answers are returned. +- `cancelled`: a member cancelled a question, the client sent `input-cancel`, + the question was withdrawn before it was asked, the session ended, or the link + closed. Open cards are withdrawn. When a member cancelled one question of a + questionnaire after others were answered, `answers` keeps those others; it is + empty in every other case. The package client settles a question withdrawn + before it was sent, or one still open when the link closes, with a local + `cancelled` result that has a `message` and no `value`. +- `failed`: the request could not become Decisions, for example in an isolated + session or with 16 requests already open on the link. + +A questionnaire `answers` array lists only answered questions, in question +order; an unanswered question has no entry. Each entry is Atomic's +`QuestionAnswer`: + +```text +{ "questionIndex": 0, "question": "", "kind": "option", "answer": "", "preview?": "" } +{ "questionIndex": 0, "question": "", "kind": "multi", "answer": null, "selected": [""] } +{ "questionIndex": 0, "question": "", "kind": "custom", "answer": "" } +{ "questionIndex": 0, "question": "", "kind": "chat", "answer": "" } +``` + +- `questionIndex` is the question's 0-based position and `question` its + verbatim text. +- `option`: a single-select question answered with one offered option. `answer` + is its label; `preview` is that option's `preview`, present only when it had + one. +- `multi`: a `multiSelect` question answered with offered options. `selected` + lists their labels; `answer` is `null`. +- `custom`: a member wrote the answer to a single-select question whose options + have no `preview`. `answer` is the text, or the labels of the options members + added, joined by a comma and a space. +- `chat`: the same written answer for a `multiSelect` question or one whose + options have a `preview`. + +A workflow-stage question names its `workflowRunId` and, when it differs, the +`rootRunId` of the run that owns it. While the latest `runs` report lists that +root as paused, an answer is held and sent only once the root is no longer +paused, so a paused run stays stopped. + +`runs.cards` are run cards as Chat shows them, at most 64: + +```text +{ "id": "", "name": "", "status": "running", "started": 0, "updated": 0, "ended?": 0, "stages": [], "earlierStages?": 0, "waiting": 0 } +``` + +`status` is one of `running`, `waiting`, `paused`, `finished`, `blocked`, +`failed`, `stopped`; times are whole seconds since the epoch; `waiting` counts +open questions. Up to 64 `stages` are +`{ "id": "", "name": "", "kind?": "tool", "status": "", "started?": 0, "ended?": 0 }` +with `status` one of `pending`, `running`, `awaiting_input`, `paused`, `blocked`, +`completed`, `failed`, `skipped`. `active` and `paused` list at most 64 root run +ids. When the link closes, the server shows the reported live runs as stopped. + +## Closing + +| Close code | Sent after | Meaning | +| ---------- | ---------------------------------------------- | --------------------------------------------- | +| 1000 | `detach` | The client detached | +| 1008 | `error` with `malformed-message` or `protocol` | Unparseable, invalid, or out-of-order message | +| 1009 | `error` with `message-too-large` | A frame over 2 MiB | +| 1011 | `refused` with `unavailable` | The server could not check the document | +| 4401 | `refused` | `sign-in-required` | +| 4403 | `refused` | `repository-forbidden` or `access-revoked` | +| 4404 | `refused` | `document-unavailable` or `document-archived` | +| 4409 | `refused` | `planner-attached` | +| 4426 | `refused` | `unsupported-version` | + +`protocol` covers a message before `attach` is answered, a second `attach`, and +a repeated `input-request` id the server remembers. When the link closes for +any reason, the server ends a running turn with an error, stops the turn's host +tools still running, withdraws open questions and the Decisions those tools +opened, and forgets the link; reattaching starts new sessions. The account keeps +the document's Planner ownership for the grace period described in +[Ownership](#ownership). A server never replays an interrupted turn. + +A client that receives a frame it cannot parse should close with 4400 and reason +`malformed-message`. Node.js's `WebSocket` lets a caller close only with 1000 or +3000 to 4999, so a client cannot answer with 1008. The package client does +this and settles everything still waiting on the link at once. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 954742271..62da78f2c 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -58,8 +58,9 @@ bearer tokens and browser sessions traverse it. Chopin runs its hosted agent through `@ai-sdk/harness`. `HARNESS` selects one adapter from a code-owned map: the default is `copilot-sdk`, a host-process wrapper over `@github/copilot-sdk`; `pi` is a second reviewed adapter over -`@ai-sdk/harness-pi`; and `atomic` embeds Atomic's headless SDK -(`@bastani/atomic`) in the server process. Adding an adapter to that map is a +`@ai-sdk/harness-pi`; `atomic` embeds Atomic's headless SDK +(`@bastani/atomic`) in the server process; and `remote` runs each Planner turn in +a client attached over the Planner link instead. Adding an adapter to that map is a reviewed trust decision, not a runtime plugin choice: an adapter's `builtinTools` and `supportsBuiltinToolFiltering` are self-declarations, and Chopin's contract suite checks those declarations and the tools a session @@ -241,6 +242,37 @@ resource loading, persistence, and workflow restart boundaries. modes. Other harnesses ignore its `checkout`: they neither verify nor use nor remember it. +### The remote Planner + +`HARNESS=remote` keeps the model out of the server. Each Planner session and turn +goes to a client attached to the document at `/planner-link`, usually a member's +own Atomic session; code, model credentials, and that client's tools stay on the +member's machine, while the document, Decisions, and every room write stay in +this server. The server needs no `MODEL` (one that is set is passed to the +client as the turn's requested model) and accepts no `HARNESS_AUTH`, so it may +bind to a public interface. Startup refuses `HARNESS_AUTH` and requires +`BACKGROUND_JOBS=off`, because the summary and research workers would have no +model. The route exists only with `AGENT` on and `HARNESS=remote`. + +The trust boundary moves to whoever attaches. A client authenticates with a +GitHub token bearer, checked like hosted MCP, and needs push or administration +access to the document's repository, rechecked before every session and turn and +every 5 minutes while it stays attached. The same account must have signed in to +this server in a browser: attaching makes that login the document's Planner +owner, one client per document, and a detached or dropped link keeps it for +`PLANNER_ATTACH_GRACE_MS` so the same account can reattach. The client then +receives what a Planner turn sees, including repository reads made with that +owner's token, and decides what the Planner writes during turns writers start; +its run reports never start a turn. The server never runs the client's tools and +gains no shell or filesystem access from this harness. Without an attached +client, `@chopin` and `invoke_planner` are refused with `planner-not-attached` +and instructions to attach one. See +[Remote Planner](hosted-agent.md#remote-planner) for the behaviour and +[Planner link](planner-link.md) for the wire format. The link shares the +application's origin and port with `/ws` and `/mcp`, so a TLS-terminating proxy +such as Caddy needs no extra listener; its default `reverse_proxy` already passes +WebSocket upgrades and the `Authorization` header. + ## Prerequisites - Docker for the application image, or Bun 1.4.2 for a source deployment. @@ -293,34 +325,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 hosted OAuth attempt cookie and derives a separate key for session credentials in PostgreSQL. Keep stable across releases and outside the database; replacing it invalidates hosted logins. 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. | -| `HARNESS_EXTENSIONS` | unset | Extension or package paths every atomic Planner session loads, as Atomic's `--extension` flag would, separated by the platform path delimiter (`:`, or `;` on Windows). A package's extensions, skills, and workflows all register. Each path must be absolute and exist; any other harness refuses the variable at startup. Background workers never load them. The code runs in the server process as its user. | -| `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. | -| `PLANNER_VISUALS` | off | Set exactly `on` to enable the bounded Jev visual handoff for foreground Planner turns. Requires `AGENT=on` and `JEV_API_KEY`. | -| `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` or `PLANNER_VISUALS=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 hosted OAuth attempt cookie and derives a separate key for session credentials in PostgreSQL. Keep stable across releases and outside the database; replacing it invalidates hosted logins. 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. Optional under `HARNESS=remote`, where the attached client chooses its model and a set value is only passed along as the turn's request. | +| `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, `atomic`, or `remote`). An unknown name refuses at startup. `remote` runs Planner turns in a client attached at `/planner-link`; see [The remote Planner](#the-remote-planner). `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). | +| `PLANNER_ATTACH_GRACE_MS` | `120000` | Under `HARNESS=remote`, how long a detached or dropped Planner link keeps the document's Planner ownership so the same account can reattach, in milliseconds, an integer between 0 and 3600000. | +| `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. For `remote`, any value is refused. | +| `HARNESS_EXTENSIONS` | unset | Extension or package paths every atomic Planner session loads, as Atomic's `--extension` flag would, separated by the platform path delimiter (`:`, or `;` on Windows). A package's extensions, skills, and workflows all register. Each path must be absolute and exist; any other harness refuses the variable at startup. Background workers never load them. The code runs in the server process as its user. | +| `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. `HARNESS=remote` requires `off`. | +| `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. | +| `PLANNER_VISUALS` | off | Set exactly `on` to enable the bounded Jev visual handoff for foreground Planner turns. Requires `AGENT=on` and `JEV_API_KEY`. | +| `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` or `PLANNER_VISUALS=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. @@ -422,8 +455,9 @@ The proxy must: - terminate TLS for the exact `APP_ORIGIN`; - forward the original `Host` and `Origin` headers; -- proxy WebSocket upgrades on `/ws`; -- proxy `/mcp` without removing its `Authorization` header; +- proxy WebSocket upgrades on `/ws`, and on `/planner-link` under `HARNESS=remote`; +- proxy `/mcp` and the `/planner-link` upgrade without removing their + `Authorization` header; - serve Chopin at `/`, not below a path prefix; and - redirect alternate hosts to the canonical origin before application traffic. diff --git a/packages/planner-link/package.json b/packages/planner-link/package.json new file mode 100644 index 000000000..d131609d7 --- /dev/null +++ b/packages/planner-link/package.json @@ -0,0 +1,13 @@ +{ + "name": "@chopin/planner-link", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { "types": "tsgo --noEmit --skipLibCheck" }, + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts" + }, + "dependencies": { "zod": "catalog:harness" }, + "devDependencies": { "@types/bun": "catalog:bun" } +} diff --git a/packages/planner-link/src/client.test.ts b/packages/planner-link/src/client.test.ts new file mode 100644 index 000000000..2a76610fa --- /dev/null +++ b/packages/planner-link/src/client.test.ts @@ -0,0 +1,275 @@ +import { afterEach, describe, expect, it } from "bun:test"; + +import { connectPlannerLink } from "./client"; +import { MAX_LINK_NOTE_LENGTH, parseClientMessage } from "./protocol"; + +import type { ServerWebSocket } from "bun"; +import type { HostToolResult, PlannerLinkHandlers, TurnContext } from "./client"; +import type { ClientMessage, ServerMessage } from "./protocol"; + +let stops: Array<() => unknown> = []; +afterEach(async () => { + for (let stop of stops.splice(0)) await stop(); +}); + +/** A server that speaks only the wire format, so each test drives the client frame by frame. */ +function fakeServer() { + let socket: ServerWebSocket | undefined; + let received: ClientMessage[] = []; + let arrivals = new Set<() => void>(); + let server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request, self) { + return self.upgrade(request) ? undefined : new Response("upgrade failed", { status: 400 }); + }, + websocket: { + open(ws) { + socket = ws; + }, + message(ws, raw) { + let parsed = parseClientMessage(String(raw)); + if (!parsed.ok) throw new Error(parsed.message); + received.push(parsed.message); + if (parsed.message.type === "attach") { + ws.send(JSON.stringify({ + type: "attached", + version: 1, + document: { id: "d", title: "T", repository: "o/r" }, + })); + } + for (let wake of arrivals) wake(); + }, + }, + }); + stops.push(() => server.stop(true)); + async function next( + type: T, + ): Promise> { + for (let deadline = Date.now() + 2_000; Date.now() < deadline;) { + let index = received.findIndex(message => message.type === type); + if (index >= 0) return received.splice(index, 1)[0] as Extract; + await new Promise(resolve => { + let wake = () => { + arrivals.delete(wake); + resolve(); + }; + arrivals.add(wake); + setTimeout(wake, 50); + }); + } + throw new Error(`no ${type} message arrived`); + } + return { + url: `http://127.0.0.1:${server.port}`, + received, + next, + send: (message: ServerMessage) => socket!.send(JSON.stringify(message)), + close: () => socket!.close(1000, "gone"), + }; +} + +async function connect(server: ReturnType, handlers: PlannerLinkHandlers) { + let client = await connectPlannerLink({ url: server.url, token: "t", document: "d", handlers }); + stops.push(() => client.detach()); + return client; +} + +const turn = (id: string) => ({ + type: "turn" as const, + session: "s", + turn: id, + prompt: "p", + tools: [{ name: "read_plan" }], +}); + +describe("Planner link client", () => { + it("settles a host call the server aborts or destroys instead of answering", async () => { + let server = fakeServer(); + let results: HostToolResult[] = []; + await connect(server, { + async turn(context) { + results.push(await context.callHostTool("read_plan", {})); + }, + }); + server.send(turn("aborted")); + await server.next("host-tool-call"); + server.send({ type: "abort", session: "s", turn: "aborted" }); + expect(await server.next("turn-end")).toMatchObject({ turn: "aborted", status: "aborted" }); + server.send(turn("destroyed")); + await server.next("host-tool-call"); + server.send({ type: "destroy", session: "s" }); + expect(await server.next("turn-end")).toMatchObject({ turn: "destroyed", status: "aborted" }); + expect(results).toEqual([ + { output: { error: "The turn was aborted." }, isError: true }, + { output: { error: "The turn was aborted." }, isError: true }, + ]); + }); + + it("withdraws a question whose signal is already aborted without asking it", async () => { + let server = fakeServer(); + let client = await connect(server, { turn() {} }); + let controller = new AbortController(); + controller.abort(); + let asked = await client.ask("s", { method: "confirm", title: "Ship?", message: "Now." }, { + signal: controller.signal, + }); + expect(asked).toMatchObject({ type: "input-result", status: "cancelled" }); + client.reportRuns("s", { active: [], paused: [], cards: [] }); + await server.next("runs"); + expect(server.received.some(message => message.type === "input-request")).toBe(false); + }); + + it("cuts generated error text to the wire limit instead of breaking the link", async () => { + let server = fakeServer(); + let long = "x".repeat(MAX_LINK_NOTE_LENGTH * 3); + let client = await connect(server, { + start() { + throw new Error(long); + }, + turn(context) { + context.emit({ type: "error", error: long }); + throw new Error(long); + }, + control() { + throw new Error(long); + }, + }); + server.send({ type: "start", session: "s", mode: "planner" }); + expect((await server.next("start-failed")).message).toHaveLength(MAX_LINK_NOTE_LENGTH); + server.send(turn("failing")); + let part = (await server.next("part")).part; + expect(part.type === "error" && part.error).toHaveLength(MAX_LINK_NOTE_LENGTH); + let ended = await server.next("turn-end"); + expect(ended).toMatchObject({ status: "failed" }); + expect(ended.message).toHaveLength(MAX_LINK_NOTE_LENGTH); + server.send({ type: "stop", request: "r", session: "s" }); + expect((await server.next("control-result")).message).toHaveLength(MAX_LINK_NOTE_LENGTH); + expect(client.send({ type: "runs", session: "s", active: [], paused: [], cards: [] })) + .toBe(true); + }); + + it("settles requests made once the link has closed instead of leaving them pending", async () => { + let server = fakeServer(); + let holding = Promise.withResolvers(); + let client = await connect(server, { + async turn(context) { + holding.resolve(context); + await new Promise(resolve => context.signal.addEventListener("abort", resolve)); + }, + }); + server.send(turn("open")); + let held = await holding.promise; + server.close(); + await client.closed; + expect(await client.ask("s", { method: "input", title: "Name?" })).toMatchObject({ + status: "cancelled", + message: "The Planner link closed.", + }); + expect(await held.callHostTool("read_plan", {})).toEqual({ + output: { error: "The turn has ended." }, + isError: true, + }); + expect(client.send({ type: "detach" })).toBe(false); + }); +}); + +describe("Planner link client facing a faulty server", () => { + /** Answers attach, then a frame no client can parse in place of the question's answer. */ + function garblingServer() { + let closes: { code: number; reason: string }[] = []; + let server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request, self) { + return self.upgrade(request) ? undefined : new Response("upgrade failed", { status: 400 }); + }, + websocket: { + message(ws, raw) { + let parsed = parseClientMessage(String(raw)); + if (!parsed.ok) throw new Error(parsed.message); + if (parsed.message.type === "attach" && parsed.message.document === "garbled") { + ws.send("not JSON"); + } else if (parsed.message.type === "attach") { + ws.send(JSON.stringify({ + type: "attached", + version: 1, + document: { id: "d", title: "T", repository: "o/r" }, + })); + } else if (parsed.message.type === "input-request") { + ws.send(new Uint8Array([1, 2, 3])); + } + }, + close(_ws, code, reason) { + closes.push({ code, reason }); + }, + }, + }); + stops.push(() => server.stop(true)); + return { url: `http://127.0.0.1:${server.port}`, closes }; + } + + async function faced(url: string) { + let early = await connectPlannerLink({ + url, + token: "t", + document: "garbled", + handlers: { + turn() {}, + }, + }).then(() => "attached", (error: Error) => error.message); + let client = await connectPlannerLink({ + url, + token: "t", + document: "d", + handlers: { + turn() {}, + }, + }); + let answer = await client.ask("s", { method: "input", title: "Name?" }); + return { early, answer, closed: await client.closed }; + } + + const expected = { + early: "The Planner link closed before attaching (4400 malformed-message).", + answer: { + type: "input-result", + request: expect.any(String), + status: "cancelled", + message: "The Planner link closed.", + }, + closed: { code: 4400, reason: "malformed-message" }, + }; + + it("closes the link and settles what waits on it", async () => { + let server = garblingServer(); + expect(await faced(server.url) as unknown).toEqual(expected); + await Bun.sleep(50); + expect(server.closes).toEqual([expected.closed, expected.closed]); + }); + + it("does the same under Node.js's WebSocket", async () => { + let node = Bun.which("node"); + let probe = node && Bun.spawnSync([node, "-p", "Boolean(process.features.typescript)"]); + if (!probe || probe.stdout.toString().trim() !== "true") return; + let server = garblingServer(); + let script = ` + import { connectPlannerLink } from ${ + JSON.stringify(new URL("./client.ts", import.meta.url).href) + }; + ${faced.toString().replace("async function faced", "async function run")} + console.log(JSON.stringify(await run(process.argv[1]))); + `; + let child = Bun.spawn([node!, "--input-type=module", "-e", script, server.url], { + stdout: "pipe", + stderr: "pipe", + }); + let [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect({ exit, stderr }).toEqual({ exit: 0, stderr: "" }); + expect(JSON.parse(stdout)).toEqual(expected); + }); +}); diff --git a/packages/planner-link/src/client.ts b/packages/planner-link/src/client.ts new file mode 100644 index 000000000..de6c5b061 --- /dev/null +++ b/packages/planner-link/src/client.ts @@ -0,0 +1,414 @@ +/** + * A minimal Planner link client. At runtime it uses only the documented wire + * format (docs/planner-link.md), JSON and the platform WebSocket, so it runs in + * Bun or Node.js and shows what an external client needs. + */ + +import type { + ClientMessage, + ClientMessageOf, + InputCall, + LinkRun, + RefusalCode, + ServerMessage, + ServerMessageOf, + StreamPart, +} from "./protocol"; + +/** The wire constants a client needs, as docs/planner-link.md states them. */ +export const WIRE = { + path: "/planner-link", + version: 1, + maxMessageBytes: 2 * 1024 * 1024, + maxNoteLength: 4_000, + malformedClose: 4400, +} as const; + +export class PlannerLinkRefused extends Error { + readonly code: RefusalCode; + + constructor(code: RefusalCode, message: string) { + super(message); + this.name = "PlannerLinkRefused"; + this.code = code; + } +} + +export type TurnEnd = ClientMessageOf<"turn-end">["status"]; + +/** One turn the server asked the attached Planner to run. */ +export type TurnContext = { + readonly message: ServerMessageOf<"turn">; + /** Aborted when the server aborts the turn, destroys its session, or the link closes. */ + readonly signal: AbortSignal; + emit(part: StreamPart): void; + /** The client's own tools this turn may call, declared before calling any. */ + offerTools(names: string[]): void; + /** + * Runs one of Chopin's tools in the server, against the document. Settles with an + * error result once the turn is aborted, destroyed, or ended, or the link closes. + */ + callHostTool(toolName: string, input: unknown): Promise; + end(status: TurnEnd, message?: string): void; +}; + +export type HostToolResult = { output: unknown; isError: boolean }; + +export type PlannerLinkHandlers = { + /** Throw to refuse the session. */ + start?(message: ServerMessageOf<"start">): void | Promise; + /** Ends the turn as finished when it resolves without calling `end`, failed when it rejects. */ + turn(context: TurnContext): void | Promise; + destroy?(message: ServerMessageOf<"destroy">): void; + /** Stop or resume the session's workflow runs; throw to report that it could not. */ + control?(message: ServerMessageOf<"stop"> | ServerMessageOf<"resume">): void | Promise; +}; + +export type PlannerLinkOptions = { + /** The Chopin origin, or the link's own `ws:`/`wss:` URL. */ + url: string | URL; + /** A GitHub token whose account can push to or administer the document's repository. */ + token: string; + /** The document's id or URL. */ + document: string; + checkout?: ClientMessageOf<"attach">["checkout"]; + handlers: PlannerLinkHandlers; + version?: number; +}; + +export type LinkClosed = { code: number; reason: string }; + +export type InputOptions = { + workflowRunId?: string; + workflowStageId?: string; + rootRunId?: string; + signal?: AbortSignal; +}; + +export type PlannerLinkClient = { + readonly document: ServerMessageOf<"attached">["document"]; + /** Asks the document's members through Decisions. */ + ask( + session: string, + input: InputCall, + options?: InputOptions, + ): Promise>; + reportRuns(session: string, runs: { active: string[]; paused: string[]; cards: LinkRun[] }): void; + /** Sends one message; false when the link is no longer open. */ + send(message: ClientMessage): boolean; + detach(): Promise; + readonly closed: Promise; +}; + +export function plannerLinkUrl(url: string | URL): URL { + let target = new URL(url); + if (target.protocol === "ws:" || target.protocol === "wss:") return target; + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new Error("The Planner link URL must be http(s) or ws(s)."); + } + target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; + target.pathname = WIRE.path; + target.search = ""; + target.hash = ""; + return target; +} + +/** A server message, trusted for its declared shape once it is a JSON object with a type. */ +function serverMessage(data: unknown): ServerMessage | undefined { + if (typeof data !== "string") return undefined; + try { + let value = JSON.parse(data) as { type?: unknown } | null; + return typeof value === "object" && value !== null && typeof value.type === "string" + ? value as ServerMessage + : undefined; + } catch { + return undefined; + } +} + +/** Bun's and Node's WebSocket accept request headers; the DOM declaration does not say so. */ +type HeaderWebSocket = new(url: URL, init: { headers: Record }) => WebSocket; + +/** A socket to the link URL carrying the bearer; browsers cannot send one. */ +export function linkSocket(url: URL, token: string): WebSocket { + return new (WebSocket as unknown as HeaderWebSocket)(url, { + headers: { authorization: `Bearer ${token}` }, + }); +} + +const LINK_CLOSED = "The Planner link closed."; + +type TurnState = { + controller: AbortController; + ended: boolean; + calls: Map void>; +}; + +function hostFailure(message: string): HostToolResult { + return { output: { error: message }, isError: true }; +} + +/** Text cut to the wire's limit for `message` and error fields. */ +function note(text: string): string { + return text.length > WIRE.maxNoteLength ? text.slice(0, WIRE.maxNoteLength) : text; +} + +function reason(error: unknown): string { + return note(error instanceof Error ? error.message : String(error)); +} + +function settleCalls(turn: TurnState, message: string): void { + for (let resolve of turn.calls.values()) resolve(hostFailure(message)); + turn.calls.clear(); +} + +/** Attaches to one document as its Planner and serves the server's requests. */ +export function connectPlannerLink(options: PlannerLinkOptions): Promise { + let socket = linkSocket(plannerLinkUrl(options.url), options.token); + let turns = new Map(); + let asks = new Map) => void>(); + let closing = Promise.withResolvers(); + let attaching = Promise.withResolvers(); + let attached = false; + + function send(message: ClientMessage): boolean { + let encoded = JSON.stringify(message); + if (new TextEncoder().encode(encoded).byteLength > WIRE.maxMessageBytes) { + throw new Error("The message exceeds the Planner link's size limit."); + } + if (socket.readyState !== WebSocket.OPEN) return false; + socket.send(encoded); + return true; + } + + function runTurn(message: ServerMessageOf<"turn">): void { + let key = `${message.session}/${message.turn}`; + let state: TurnState = { controller: new AbortController(), ended: false, calls: new Map() }; + turns.set(key, state); + state.controller.signal.addEventListener( + "abort", + () => settleCalls(state, "The turn was aborted."), + { once: true }, + ); + let calls = 0; + let context: TurnContext = { + message, + signal: state.controller.signal, + emit: part => { + if (state.ended) return; + send({ + type: "part", + session: message.session, + turn: message.turn, + part: part.type === "error" ? { ...part, error: note(part.error) } : part, + }); + }, + offerTools: names => + send({ + type: "tools", + session: message.session, + turn: message.turn, + names, + }), + callHostTool: (toolName, input) => { + if (state.ended || state.controller.signal.aborted) { + return Promise.resolve(hostFailure("The turn has ended.")); + } + let toolCallId = `host-${++calls}-${crypto.randomUUID()}`; + let result = Promise.withResolvers(); + state.calls.set(toolCallId, result.resolve); + let sent: boolean; + try { + sent = send({ + type: "host-tool-call", + session: message.session, + turn: message.turn, + toolCallId, + toolName, + input: JSON.stringify(input ?? {}), + }); + } catch (error) { + state.calls.delete(toolCallId); + return Promise.reject(error); + } + if (!sent) { + state.calls.delete(toolCallId); + return Promise.resolve(hostFailure(LINK_CLOSED)); + } + return result.promise; + }, + end: (status, text) => { + if (state.ended) return; + state.ended = true; + turns.delete(key); + settleCalls(state, "The turn has ended."); + send({ + type: "turn-end", + session: message.session, + turn: message.turn, + status, + ...(text === undefined ? {} : { message: note(text) }), + }); + }, + }; + Promise.resolve() + .then(() => options.handlers.turn(context)) + .then( + () => context.end(state.controller.signal.aborted ? "aborted" : "finished"), + error => context.end("failed", reason(error)), + ); + } + + async function serve(message: ServerMessage): Promise { + switch (message.type) { + case "start": + try { + await options.handlers.start?.(message); + send({ type: "started", session: message.session }); + } catch (error) { + send({ + type: "start-failed", + session: message.session, + message: reason(error), + }); + } + return; + case "turn": + return runTurn(message); + case "abort": + turns.get(`${message.session}/${message.turn}`)?.controller.abort(); + return; + case "host-tool-result": { + let calls = turns.get(`${message.session}/${message.turn}`)?.calls; + calls?.get(message.toolCallId)?.({ + output: message.output, + isError: message.isError ?? false, + }); + calls?.delete(message.toolCallId); + return; + } + case "input-result": + asks.get(message.request)?.(message); + asks.delete(message.request); + return; + case "stop": + case "resume": + try { + if (!options.handlers.control) throw new Error("This Planner has no workflow runs."); + await options.handlers.control(message); + send({ type: "control-result", request: message.request, ok: true }); + } catch (error) { + send({ + type: "control-result", + request: message.request, + ok: false, + message: reason(error), + }); + } + return; + case "destroy": + for (let [key, turn] of turns) { + if (key.startsWith(`${message.session}/`)) turn.controller.abort(); + } + options.handlers.destroy?.(message); + return; + case "attached": + case "refused": + case "error": + return; + } + } + + let attachedDocument: ServerMessageOf<"attached">["document"] | undefined; + let client: PlannerLinkClient = { + get document() { + return attachedDocument!; + }, + ask(session, input, inputOptions = {}) { + let request = crypto.randomUUID(); + let { signal, ...workflow } = inputOptions; + let withdrawn = (text: string) => + Promise.resolve>({ + type: "input-result", + request, + status: "cancelled", + message: text, + }); + if (signal?.aborted) return withdrawn("The question was withdrawn before it was asked."); + let answered = Promise.withResolvers>(); + asks.set(request, answered.resolve); + let sent: boolean; + try { + sent = send({ type: "input-request", request, session, ...workflow, input }); + } catch (error) { + asks.delete(request); + return Promise.reject(error); + } + if (!sent) { + asks.delete(request); + return withdrawn(LINK_CLOSED); + } + let cancel = () => send({ type: "input-cancel", request }); + signal?.addEventListener("abort", cancel, { once: true }); + return answered.promise.finally(() => signal?.removeEventListener("abort", cancel)); + }, + reportRuns(session, runs) { + send({ type: "runs", session, ...runs }); + }, + send, + detach() { + send({ type: "detach" }); + return closing.promise; + }, + closed: closing.promise, + }; + socket.addEventListener("open", () => { + send({ + type: "attach", + version: options.version ?? WIRE.version, + document: options.document, + ...(options.checkout ? { checkout: options.checkout } : {}), + }); + }); + let released = false; + function release(closed: LinkClosed): void { + if (released) return; + released = true; + for (let turn of turns.values()) { + settleCalls(turn, LINK_CLOSED); + turn.controller.abort(); + } + turns.clear(); + for (let [request, resolve] of asks) { + resolve({ type: "input-result", request, status: "cancelled", message: LINK_CLOSED }); + } + asks.clear(); + attaching.reject( + new Error(`The Planner link closed before attaching (${closed.code} ${closed.reason}).`), + ); + closing.resolve(closed); + } + socket.addEventListener("message", event => { + if (released) return; + let message = serverMessage(event.data); + if (!message) { + let closed = { code: WIRE.malformedClose, reason: "malformed-message" }; + socket.close(closed.code, closed.reason); + release(closed); + return; + } + if (!attached) { + if (message.type === "attached") { + attached = true; + attachedDocument = message.document; + attaching.resolve(client); + } else if (message.type === "refused") { + attaching.reject(new PlannerLinkRefused(message.code, message.message)); + } + return; + } + void serve(message); + }); + socket.addEventListener("close", event => release({ code: event.code, reason: event.reason })); + return attaching.promise; +} diff --git a/packages/planner-link/src/index.ts b/packages/planner-link/src/index.ts new file mode 100644 index 000000000..9810ef416 --- /dev/null +++ b/packages/planner-link/src/index.ts @@ -0,0 +1 @@ +export * from "./protocol"; diff --git a/packages/planner-link/src/protocol.test.ts b/packages/planner-link/src/protocol.test.ts new file mode 100644 index 000000000..21415b1ca --- /dev/null +++ b/packages/planner-link/src/protocol.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "bun:test"; + +import { plannerLinkUrl, WIRE } from "./client"; +import { + encodeMessage, + MAX_LINK_MESSAGE_BYTES, + MAX_LINK_NOTE_LENGTH, + parseClientMessage, + parseServerMessage, + PLANNER_LINK_PATH, + PLANNER_LINK_VERSION, +} from "./protocol"; + +describe("Planner link messages", () => { + it("accepts each client message the protocol declares", () => { + let messages = [ + { type: "attach", version: PLANNER_LINK_VERSION, document: "https://chopin.test/o/r/d" }, + { type: "started", session: "s" }, + { type: "tools", session: "s", turn: "t", names: ["read"] }, + { type: "part", session: "s", turn: "t", part: { type: "text-delta", id: "a", delta: "hi" } }, + { + type: "part", + session: "s", + turn: "t", + part: { + type: "tool-call", + toolCallId: "c", + toolName: "read", + input: "{}", + providerExecuted: true, + }, + }, + { + type: "part", + session: "s", + turn: "t", + part: { + type: "finish", + finishReason: { unified: "stop" }, + totalUsage: { inputTokens: {}, outputTokens: {} }, + }, + }, + { + type: "host-tool-call", + session: "s", + turn: "t", + toolCallId: "c", + toolName: "read_plan", + input: "{}", + }, + { type: "turn-end", session: "s", turn: "t", status: "aborted" }, + { + type: "input-request", + request: "r", + session: "s", + workflowRunId: "run", + input: { + method: "questionnaire", + params: { + questions: [{ + header: "H", + question: "Q?", + options: [{ label: "A", description: "" }], + }], + }, + }, + }, + { type: "input-cancel", request: "r" }, + { type: "runs", session: "s", active: [], paused: ["run"], cards: [] }, + { type: "control-result", request: "r", ok: false, message: "no" }, + { type: "detach" }, + ]; + for (let message of messages) { + expect(parseClientMessage(JSON.stringify(message))).toEqual({ ok: true, message } as never); + } + }); + + it("refuses text that is not JSON, binary frames, unknown types, and undeclared fields", () => { + for ( + let raw of [ + "{", + new Uint8Array([123, 125]), + JSON.stringify({ type: "unknown" }), + JSON.stringify({ type: "detach", extra: true }), + JSON.stringify({ type: "started", session: "" }), + JSON.stringify({ type: "started", session: "x".repeat(129) }), + JSON.stringify({ + type: "part", + session: "s", + turn: "t", + part: { type: "tool-call", toolCallId: "c", toolName: "read", input: "{}" }, + }), + JSON.stringify({ + type: "part", + session: "s", + turn: "t", + part: { type: "raw", rawValue: 1 }, + }), + ] + ) { + expect(parseClientMessage(raw)).toMatchObject({ ok: false, code: "malformed-message" }); + } + }); + + it("refuses a message over the size limit before parsing it", () => { + let raw = JSON.stringify({ type: "detach", padding: "x".repeat(MAX_LINK_MESSAGE_BYTES) }); + expect(parseClientMessage(raw)).toMatchObject({ ok: false, code: "message-too-large" }); + let wide = JSON.stringify({ + type: "part", + session: "s", + turn: "t", + part: { type: "text-delta", id: "a", delta: "é".repeat(MAX_LINK_MESSAGE_BYTES / 2) }, + }); + expect(wide.length).toBeLessThan(MAX_LINK_MESSAGE_BYTES); + expect(parseClientMessage(wide)).toMatchObject({ ok: false, code: "message-too-large" }); + expect(encodeMessage({ type: "destroy", session: "s".repeat(MAX_LINK_MESSAGE_BYTES) })) + .toBeUndefined(); + }); + + it("round-trips server messages and states its version", () => { + let message = { + type: "turn" as const, + session: "s", + turn: "t", + prompt: "plan", + tools: [{ name: "read_plan", inputSchema: { type: "object" } }], + responseFormat: { type: "json" as const, schema: { type: "object" } }, + }; + expect(parseServerMessage(encodeMessage(message))).toEqual({ ok: true, message }); + expect(parseServerMessage(encodeMessage({ + type: "attached", + version: PLANNER_LINK_VERSION, + document: { id: "d", title: "T", repository: "o/r" }, + }))).toMatchObject({ ok: true }); + }); + + it("gives the wire-only client the same constants the server enforces", async () => { + expect(WIRE).toEqual({ + path: PLANNER_LINK_PATH, + version: PLANNER_LINK_VERSION, + maxMessageBytes: MAX_LINK_MESSAGE_BYTES, + maxNoteLength: MAX_LINK_NOTE_LENGTH, + malformedClose: 4400, + }); + let source = await Bun.file(new URL("./client.ts", import.meta.url)).text(); + let imports = [...source.matchAll(/^import\s+(type\s+)?/gm)]; + expect(imports.every(match => match[1])).toBe(true); + }); + + it("derives the link URL from a Chopin origin", () => { + expect(plannerLinkUrl("https://chopin.test/some/doc?x=1").href) + .toBe("wss://chopin.test/planner-link"); + expect(plannerLinkUrl("http://127.0.0.1:8787").href).toBe("ws://127.0.0.1:8787/planner-link"); + expect(plannerLinkUrl("ws://127.0.0.1:1/custom").href).toBe("ws://127.0.0.1:1/custom"); + expect(() => plannerLinkUrl("ftp://chopin.test")).toThrow("http(s) or ws(s)"); + }); +}); diff --git a/packages/planner-link/src/protocol.ts b/packages/planner-link/src/protocol.ts new file mode 100644 index 000000000..329805662 --- /dev/null +++ b/packages/planner-link/src/protocol.ts @@ -0,0 +1,381 @@ +import { z } from "zod"; + +/** The Planner link version this package speaks. A client states it in `attach`. */ +export const PLANNER_LINK_VERSION = 1; + +/** Where a Chopin server accepts Planner links. */ +export const PLANNER_LINK_PATH = "/planner-link"; + +/** The largest message either side may send, in UTF-8 bytes. */ +export const MAX_LINK_MESSAGE_BYTES = 2 * 1024 * 1024; + +/** WebSocket close codes a Chopin server uses to end a link. */ +export const LINK_CLOSE = { + detached: 1000, + malformed: 1008, + tooLarge: 1009, + failed: 1011, + unauthorized: 4401, + forbidden: 4403, + unavailable: 4404, + attached: 4409, + unsupportedVersion: 4426, +} as const; + +/** The longest `message`, error, title or question text, in UTF-16 code units. */ +export const MAX_LINK_NOTE_LENGTH = 4_000; + +const MAX_ID = 128; +const MAX_NAME = 128; +const MAX_NOTE = MAX_LINK_NOTE_LENGTH; +const MAX_TOOLS = 256; +const MAX_RUNS = 64; +const MAX_STAGES = 64; +const MAX_QUESTIONS = 16; +const MAX_OPTIONS = 32; + +const id = z.string().min(1).max(MAX_ID); +const name = z.string().min(1).max(MAX_NAME); +const note = z.string().max(MAX_NOTE); +const text = z.string().max(MAX_LINK_MESSAGE_BYTES); +const count = z.number().int().nonnegative(); +const seconds = z.number().int().nonnegative(); +const json = z.json(); + +const finishReason = z.strictObject({ + unified: z.enum(["stop", "length", "content-filter", "tool-calls", "error", "other"]), + raw: z.string().max(MAX_NAME).optional(), +}); + +const usage = z.strictObject({ + inputTokens: z.strictObject({ + total: count.optional(), + noCache: count.optional(), + cacheRead: count.optional(), + cacheWrite: count.optional(), + }), + outputTokens: z.strictObject({ + total: count.optional(), + text: count.optional(), + reasoning: count.optional(), + }), +}); + +/** + * The stream parts a client may send for a turn: `HarnessV1StreamPart`s + * without metadata. A `tool-call` here is always one of the client's own tools; + * Chopin's host tools are called with `host-tool-call` instead. + */ +export const streamPartSchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("stream-start"), modelId: name.optional() }), + z.strictObject({ type: z.literal("text-start"), id }), + z.strictObject({ type: z.literal("text-delta"), id, delta: text }), + z.strictObject({ type: z.literal("text-end"), id }), + z.strictObject({ type: z.literal("reasoning-start"), id }), + z.strictObject({ type: z.literal("reasoning-delta"), id, delta: text }), + z.strictObject({ type: z.literal("reasoning-end"), id }), + z.strictObject({ + type: z.literal("tool-input-start"), + id, + toolName: name, + providerExecuted: z.boolean().optional(), + dynamic: z.boolean().optional(), + title: note.optional(), + }), + z.strictObject({ type: z.literal("tool-input-delta"), id, delta: text }), + z.strictObject({ type: z.literal("tool-input-end"), id }), + z.strictObject({ + type: z.literal("tool-call"), + toolCallId: id, + toolName: name, + input: text, + providerExecuted: z.literal(true), + dynamic: z.boolean().optional(), + }), + z.strictObject({ + type: z.literal("tool-result"), + toolCallId: id, + toolName: name, + result: json, + isError: z.boolean().optional(), + preliminary: z.boolean().optional(), + dynamic: z.boolean().optional(), + }), + z.strictObject({ type: z.literal("finish-step"), finishReason, usage }), + z.strictObject({ type: z.literal("finish"), finishReason, totalUsage: usage }), + z.strictObject({ type: z.literal("error"), error: note }), +]); + +const option = z.strictObject({ + label: z.string().max(MAX_NOTE), + description: z.string().max(MAX_NOTE), + preview: z.string().max(MAX_NOTE * 4).optional(), +}); + +/** Atomic's `ask_user_question` parameters, as a client's `HostInput` receives them. */ +export const questionParamsSchema = z.strictObject({ + questions: z.array(z.strictObject({ + question: z.string().max(MAX_NOTE), + header: z.string().max(MAX_NAME), + options: z.array(option).max(MAX_OPTIONS), + multiSelect: z.boolean().optional(), + })).min(1).max(MAX_QUESTIONS), +}); + +/** One `HostInput` method call and its arguments. */ +export const inputSchema = z.discriminatedUnion("method", [ + z.strictObject({ method: z.literal("questionnaire"), params: questionParamsSchema }), + z.strictObject({ method: z.literal("confirm"), title: note, message: note }), + z.strictObject({ + method: z.literal("select"), + title: note, + choices: z.array(z.string().max(MAX_NOTE)).max(MAX_OPTIONS), + }), + z.strictObject({ method: z.literal("input"), title: note, placeholder: note.optional() }), + z.strictObject({ method: z.literal("editor"), title: note, initial: note.optional() }), +]); + +const runStage = z.strictObject({ + id, + name: z.string().max(MAX_NOTE), + kind: z.literal("tool").optional(), + status: z.enum([ + "pending", + "running", + "awaiting_input", + "paused", + "blocked", + "completed", + "failed", + "skipped", + ]), + started: seconds.optional(), + ended: seconds.optional(), +}); + +/** A workflow run card, as Chat shows it. */ +export const runSchema = z.strictObject({ + id, + name: z.string().max(MAX_NOTE), + status: z.enum(["running", "waiting", "paused", "finished", "blocked", "failed", "stopped"]), + started: seconds, + updated: seconds, + ended: seconds.optional(), + stages: z.array(runStage).max(MAX_STAGES), + earlierStages: count.optional(), + waiting: count, +}); + +const checkout = z.strictObject({ + origin: z.string().max(2_048).optional(), + branch: z.string().max(256).optional(), + head: z.string().max(MAX_ID).optional(), +}); + +/** Every message a client sends. */ +export const clientMessageSchema = z.discriminatedUnion("type", [ + z.strictObject({ + type: z.literal("attach"), + version: z.number().int(), + document: z.string().min(1).max(2_048), + checkout: checkout.optional(), + }), + z.strictObject({ type: z.literal("started"), session: id }), + z.strictObject({ type: z.literal("start-failed"), session: id, message: note }), + z.strictObject({ + type: z.literal("tools"), + session: id, + turn: id, + names: z.array(name).max(MAX_TOOLS), + }), + z.strictObject({ type: z.literal("part"), session: id, turn: id, part: streamPartSchema }), + z.strictObject({ + type: z.literal("host-tool-call"), + session: id, + turn: id, + toolCallId: id, + toolName: name, + input: text, + }), + z.strictObject({ + type: z.literal("turn-end"), + session: id, + turn: id, + status: z.enum(["finished", "failed", "aborted"]), + message: note.optional(), + }), + z.strictObject({ + type: z.literal("input-request"), + request: id, + session: id, + workflowRunId: id.optional(), + workflowStageId: id.optional(), + rootRunId: id.optional(), + input: inputSchema, + }), + z.strictObject({ type: z.literal("input-cancel"), request: id }), + z.strictObject({ + type: z.literal("runs"), + session: id, + active: z.array(id).max(MAX_RUNS), + paused: z.array(id).max(MAX_RUNS), + cards: z.array(runSchema).max(MAX_RUNS), + }), + z.strictObject({ + type: z.literal("control-result"), + request: id, + ok: z.boolean(), + message: note.optional(), + }), + z.strictObject({ type: z.literal("detach") }), +]); + +const toolSpec = z.strictObject({ + name, + description: z.string().max(64_000).optional(), + inputSchema: z.record(z.string(), json).optional(), +}); + +const responseFormat = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("text") }), + z.strictObject({ + type: z.literal("json"), + schema: z.record(z.string(), json).optional(), + name: name.optional(), + description: note.optional(), + }), +]); + +/** Why a server refused an attach. */ +export const refusalCodeSchema = z.enum([ + "unsupported-version", + "repository-forbidden", + "document-unavailable", + "document-archived", + "access-revoked", + "planner-attached", + "sign-in-required", + "unavailable", +]); + +/** Every message a server sends. */ +export const serverMessageSchema = z.discriminatedUnion("type", [ + z.strictObject({ + type: z.literal("attached"), + version: z.number().int(), + document: z.strictObject({ + id, + title: z.string().max(MAX_NOTE), + repository: z.string().max(2 * MAX_NAME + 1), + url: z.string().max(2_048).optional(), + }), + }), + z.strictObject({ type: z.literal("refused"), code: refusalCodeSchema, message: note }), + z.strictObject({ + type: z.literal("error"), + code: z.enum(["malformed-message", "message-too-large", "protocol"]), + message: note, + }), + z.strictObject({ + type: z.literal("start"), + session: id, + mode: z.enum(["planner", "isolated"]), + }), + z.strictObject({ + type: z.literal("turn"), + session: id, + turn: id, + prompt: text, + instructions: text.optional(), + model: z.string().max(MAX_NOTE).optional(), + tools: z.array(toolSpec).max(MAX_TOOLS), + responseFormat: responseFormat.optional(), + }), + z.strictObject({ type: z.literal("abort"), session: id, turn: id }), + z.strictObject({ + type: z.literal("host-tool-result"), + session: id, + turn: id, + toolCallId: id, + output: json, + isError: z.boolean().optional(), + }), + z.strictObject({ + type: z.literal("input-result"), + request: id, + status: z.enum(["answered", "expired", "cancelled", "failed"]), + value: json.optional(), + message: note.optional(), + }), + z.strictObject({ type: z.literal("stop"), request: id, session: id, run: id.optional() }), + z.strictObject({ type: z.literal("resume"), request: id, session: id, run: id.optional() }), + z.strictObject({ type: z.literal("destroy"), session: id }), +]); + +export type ClientMessage = z.infer; +export type ServerMessage = z.infer; +export type StreamPart = z.infer; +export type InputCall = z.infer; +export type InputMethod = InputCall["method"]; +export type LinkRun = z.infer; +export type RefusalCode = z.infer; +export type ProtocolErrorCode = Extract["code"]; +export type ClientMessageOf = Extract; +export type ServerMessageOf = Extract; + +export type Parsed = + | { ok: true; message: T } + | { ok: false; code: Exclude; message: string }; + +/** Whether a string's UTF-8 encoding exceeds the limit, encoding only when it might. */ +function oversized(value: string): boolean { + return value.length * 3 > MAX_LINK_MESSAGE_BYTES + && new TextEncoder().encode(value).byteLength > MAX_LINK_MESSAGE_BYTES; +} + +function parse(schema: z.ZodType, raw: unknown): Parsed { + if (typeof raw !== "string") { + return { ok: false, code: "malformed-message", message: "Messages are JSON text frames." }; + } + if (oversized(raw)) { + return { + ok: false, + code: "message-too-large", + message: `Messages are limited to ${MAX_LINK_MESSAGE_BYTES} bytes.`, + }; + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return { ok: false, code: "malformed-message", message: "The message is not JSON." }; + } + let result: ReturnType; + try { + result = schema.safeParse(value); + } catch { + return { ok: false, code: "malformed-message", message: "The message is nested too deeply." }; + } + if (result.success) return { ok: true, message: result.data }; + let issue = result.error.issues[0]; + let path = issue?.path.join(".") || "message"; + return { + ok: false, + code: "malformed-message", + message: `Invalid ${path}: ${issue?.message ?? "unknown"}`.slice(0, MAX_NOTE), + }; +} + +export function parseClientMessage(raw: unknown): Parsed { + return parse(clientMessageSchema, raw); +} + +export function parseServerMessage(raw: unknown): Parsed { + return parse(serverMessageSchema, raw); +} + +/** The message as one text frame, or undefined when it would exceed the limit. */ +export function encodeMessage(message: ClientMessage | ServerMessage): string | undefined { + let encoded = JSON.stringify(message); + return oversized(encoded) ? undefined : encoded; +} diff --git a/packages/planner-link/tsconfig.json b/packages/planner-link/tsconfig.json new file mode 100644 index 000000000..93201ad0f --- /dev/null +++ b/packages/planner-link/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"], + "compilerOptions": { "types": ["bun"], "lib": ["esnext"] } +} diff --git a/packages/protocol/index.d.ts b/packages/protocol/index.d.ts index e789f2183..f042b0843 100644 --- a/packages/protocol/index.d.ts +++ b/packages/protocol/index.d.ts @@ -110,6 +110,12 @@ export declare namespace Session { /** A request could not be served. Carries the `rid` it answers. */ export type Failure = KIND<"session:error"> & { message: string; + /** + * Set for a refusal the client presents in full rather than as a generic + * failure. `planner-not-attached`: a Planner request under `HARNESS=remote` + * to a document no client is attached to; `message` says how to attach one. + */ + code?: "planner-not-attached"; }; /** Liveness, and the smallest thing that proves request correlation works. */