From 3d6337488b9598029f6a8087239e7bbd39de9bde Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:14:37 +0100 Subject: [PATCH 01/21] Run local work on the clicker's own repository-scoped connection A connector pairing now binds one owner to one repository and serves every document there. Investigation runs and builds no longer name a connection: the server picks the caller's most recently active, idle connection for the document's repository, or refuses with no-workspace. Builds still record the connection and starting user, and derive their checkout from the connection. Run tokens carry their document. Connector calls recheck push access on the connection's repository, and write access, archive state and the implementation lock on the target document when work is claimed. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/experiments/connections.ts | 59 +++--- apps/server/src/experiments/routes.test.ts | 119 +++++++++--- apps/server/src/experiments/routes.ts | 199 ++++++++++++--------- apps/server/src/main.ts | 10 +- apps/server/src/tasks/connector.ts | 26 +-- apps/server/src/tasks/routes.test.ts | 69 +++---- apps/server/src/tasks/routes.ts | 78 ++++---- packages/protocol/implementation.d.ts | 3 +- 8 files changed, 338 insertions(+), 225 deletions(-) diff --git a/apps/server/src/experiments/connections.ts b/apps/server/src/experiments/connections.ts index 4bed8251b..476a01862 100644 --- a/apps/server/src/experiments/connections.ts +++ b/apps/server/src/experiments/connections.ts @@ -3,9 +3,9 @@ import type { Source } from "@chopin/experiment"; import { fail, fingerprint } from "./service"; export type PairingInput = Omit & { label: string }; +/** One owner's checkout of one repository; it serves every document in that repository. */ export type Connection = { id: string; - documentId: string; owner: string; login: string; sessionId: string; @@ -23,7 +23,7 @@ type Pending = { }; export type Grant = { connectionId: string; - run?: { id: string; generation: number; kind?: "implementation" }; + run?: { id: string; documentId: string; generation: number; kind?: "implementation" }; }; export class Connections { @@ -32,6 +32,7 @@ export class Connections { #tokens = new Map(); #runTokens = new Map(); #waiters = new Map void>>(); + #builds = new Map(); #locks = new Map>(); constructor(private now = () => Date.now()) {} create(input: PairingInput) { @@ -55,23 +56,20 @@ export class Connections { approve( id: string, owner: { id: string; login: string; sessionId: string }, - documentId: string, repositoryId: string, ) { let value = this.pending(id); if (value.connection) { - if (value.connection.owner !== owner.id || value.connection.documentId !== documentId) { - fail("already-paired"); - } + if ( + value.connection.owner !== owner.id + || value.connection.source.repositoryId !== repositoryId + ) fail("already-paired"); return value.connection; } - if (this.list(documentId).filter(item => item.owner === owner.id).length >= 8) { - fail("connection-limit"); - } + if (this.list(repositoryId, owner.id).length >= 8) fail("connection-limit"); let { label, ...source } = value.input; let connection: Connection = { id: crypto.randomUUID(), - documentId, owner: owner.id, login: owner.login, sessionId: owner.sessionId, @@ -103,12 +101,27 @@ export class Connections { touch(connection: Connection) { connection.expiresAt = this.now() + 90_000; } - list(documentId: string) { + /** Live connections for a repository, most recently heard from first. */ + list(repositoryId: string, owner?: string) { return [...this.#connections.values()].filter(value => - value.documentId === documentId && value.expiresAt > this.now() - ); + value.source.repositoryId === repositoryId && value.expiresAt > this.now() + && (owner === undefined || value.owner === owner) + ).sort((a, b) => b.expiresAt - a.expiresAt); + } + /** Remember which document holds the build a connection was last given. */ + assign(connectionId: string, documentId: string) { + if (this.#connections.has(connectionId)) this.#builds.set(connectionId, documentId); + } + assigned(connectionId: string) { + return this.#builds.get(connectionId); } - runToken(connectionId: string, id: string, generation: number, kind?: "implementation") { + runToken( + connectionId: string, + documentId: string, + id: string, + generation: number, + kind?: "implementation", + ) { let key = `${connectionId}:${id}:${generation}:${kind ?? "experiment"}`; let existing = this.#runTokens.get(key); if (existing) return existing; @@ -116,18 +129,19 @@ export class Connections { this.#runTokens.set(key, token); this.#tokens.set(fingerprint(token), { connectionId, - run: { id, generation, ...(kind ? { kind } : {}) }, + run: { id, documentId, generation, ...(kind ? { kind } : {}) }, }); return token; } revoke(id: string) { let connection = this.#connections.get(id); this.#connections.delete(id); + this.#builds.delete(id); for (let key of this.#runTokens.keys()) { if (key.startsWith(`${id}:`)) this.#runTokens.delete(key); } for (let [key, grant] of this.#tokens) if (grant.connectionId === id) this.#tokens.delete(key); - if (connection) this.wake(connection.documentId); + if (connection) this.wake(connection.source.repositoryId); } revokeSession(sessionId: string) { for (let connection of this.#connections.values()) { @@ -142,17 +156,18 @@ export class Connections { if (connection.expiresAt <= this.now()) this.revoke(id); } } - wake(documentId: string) { - for (let resolve of this.#waiters.get(documentId) ?? []) resolve(); + /** Wake every connector waiting for work in a repository. */ + wake(repositoryId: string) { + for (let resolve of this.#waiters.get(repositoryId) ?? []) resolve(); } - wait(documentId: string, signal: AbortSignal) { + wait(repositoryId: string, signal: AbortSignal) { return new Promise(resolve => { - let set = this.#waiters.get(documentId) ?? new Set(); - this.#waiters.set(documentId, set); + let set = this.#waiters.get(repositoryId) ?? new Set(); + this.#waiters.set(repositoryId, set); let finish = () => { clearTimeout(timer); set.delete(finish); - if (!set.size) this.#waiters.delete(documentId); + if (!set.size) this.#waiters.delete(repositoryId); signal.removeEventListener("abort", finish); resolve(); }; diff --git a/apps/server/src/experiments/routes.test.ts b/apps/server/src/experiments/routes.test.ts index c7297c552..bceedbdcf 100644 --- a/apps/server/src/experiments/routes.test.ts +++ b/apps/server/src/experiments/routes.test.ts @@ -8,20 +8,28 @@ import { MemoryStorage } from "../storage/memory/adapter"; import { registerExperimentRoutes } from "./routes"; import { Connections } from "./connections"; -test("pairing binds the browser owner; only that owner dispatches and scoped claims publish", async () => { +function text(value: { result: { content: Array<{ text: string }> } }) { + return JSON.parse(value.result.content[0].text); +} + +async function setup() { let storage = new MemoryStorage(); - let documentId = crypto.randomUUID(); let now = new Date(); for (let id of ["alice", "bob"]) await storage.users.put({ id, login: id, avatarUrl: "", now }); - await storage.channels.create({ - id: documentId, - repositoryId: "repo", - repositoryOwner: "org", - repositoryName: "repo", - title: "Investigate", - createdBy: "alice", - now, - }); + async function document(repository = "repo") { + let id = crypto.randomUUID(); + await storage.channels.create({ + id, + repositoryId: repository, + repositoryOwner: "org", + repositoryName: repository, + title: `Investigate ${id}`, + createdBy: "alice", + now, + }); + return id; + } + let documentId = await document(); let lease = (await storage.leases.acquire("writer", "test", 60_000))!; let sessions = new Sessions(storage, true); let grant = { @@ -33,8 +41,8 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla let alice = await sessions.issue("alice", grant); let bob = await sessions.issue("bob", grant); let github = { - repositoryAccess: async () => ({ - id: "repo", + repositoryAccess: async (_token: string, _owner: string, name: string) => ({ + id: name, permissions: { pull: true, push: true, admin: false }, }), } as unknown as GitHub; @@ -72,6 +80,33 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla }), ))!; } + async function tool(token: string, name: string, args: unknown) { + return (await request( + "/connector/mcp", + { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, + undefined, + token, + )).json(); + } + async function pair(cookie: string, repository = "org/repo") { + let pending = await (await request("/api/connector/pairings", { + repository, + commit: "a".repeat(40), + label: "Laptop", + })).json(); + await request(`/api/connector/pairings/${pending.id}/approve`, {}, cookie); + return { + ...pending, + ...await (await request(`/api/connector/pairings/${pending.id}/claim`, { + secret: pending.secret, + })).json(), + }; + } + return { storage, sessions, config, alice, bob, document, documentId, request, tool, pair }; +} + +test("work runs on the clicker's own connection; scoped claims publish", async () => { + let { sessions, config, alice, bob, documentId, request } = await setup(); let pair = await (await request("/api/connector/pairings", { repository: "org/repo", commit: "a".repeat(40), @@ -80,10 +115,9 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla expect( (await request(`/api/connector/pairings/${pair.id}/claim`, { secret: "x".repeat(32) })).status, ).toBe(403); - expect( - (await request(`/api/connector/pairings/${pair.id}/approve`, { documentId }, alice.cookie)) - .status, - ).toBe(200); + let approved = await request(`/api/connector/pairings/${pair.id}/approve`, {}, alice.cookie); + expect(approved.status).toBe(200); + expect((await approved.json()).url).toBe("/documents/org/repo"); let paired = await (await request(`/api/connector/pairings/${pair.id}/claim`, { secret: pair.secret })) .json(); @@ -94,11 +128,12 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla bob.cookie, ); let runPath = `/api/documents/${documentId}/experiments/${id}/run`; - expect((await request(runPath, { connectionId: paired.connection.id }, bob.cookie)).status).toBe( - 403, - ); + let refused = await request(runPath, {}, bob.cookie); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("no-workspace"); expect((await request(runPath, { connectionId: paired.connection.id }, alice.cookie)).status) - .toBe(200); + .toBe(409); + expect((await request(runPath, {}, alice.cookie)).status).toBe(200); async function tool(name: string, args: unknown, token = paired.token) { return (await request( "/connector/mcp", @@ -139,6 +174,46 @@ test("pairing binds the browser owner; only that owner dispatches and scoped cla ).toBe(401); }); +test("one connection serves every document in its repository and no other", async () => { + let { alice, bob, document, documentId, request, tool, pair } = await setup(); + let second = await document(); + let elsewhere = await document("other"); + let connection = await pair(alice.cookie); + for (let target of [documentId, second]) { + let id = crypto.randomUUID(); + await request(`/api/documents/${target}/experiments`, { id, brief: "Measure" }, alice.cookie); + let run = await request(`/api/documents/${target}/experiments/${id}/run`, {}, alice.cookie); + expect((await run.json()).connectionId).toBe(connection.connection.id); + expect(text(await tool(connection.token, "wait_for_experiment", {}))).toEqual({ id }); + let claim = text(await tool(connection.token, "claim_experiment", { id })); + expect(claim.input.documentId).toBe(target); + let read = text(await tool(claim.runToken, "read_experiment", {})); + expect(read.input.documentId).toBe(target); + await tool(connection.token, "fail_experiment", { + id, + generation: claim.generation, + error: "Stopped", + }); + } + let id = crypto.randomUUID(); + await request(`/api/documents/${elsewhere}/experiments`, { id, brief: "Measure" }, alice.cookie); + let refused = await request( + `/api/documents/${elsewhere}/experiments/${id}/run`, + {}, + alice.cookie, + ); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("no-workspace"); + let foreign = crypto.randomUUID(); + await request(`/api/documents/${second}/experiments`, { id: foreign, brief: "Bob" }, bob.cookie); + expect( + (await request(`/api/documents/${second}/experiments/${foreign}/run`, {}, bob.cookie)) + .status, + ).toBe(409); + expect((await tool(connection.token, "claim_experiment", { id: foreign })).result.isError) + .toBe(true); +}); + test("expired pairing and connections cannot be revived by replay", () => { let now = 0; let connections = new Connections(() => now); @@ -147,7 +222,7 @@ test("expired pairing and connections cannot be revived by replay", () => { commit: "a".repeat(40), label: "Laptop", }); - connections.approve(pending.id, { id: "alice", login: "alice", sessionId: "s" }, "d", "repo"); + connections.approve(pending.id, { id: "alice", login: "alice", sessionId: "s" }, "repo"); let result = connections.claim(pending.id, pending.secret); if (!("token" in result)) throw new Error("missing token"); now = 91_000; diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index cf9e6176f..58e9a5021 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -6,8 +6,9 @@ import type { AuthenticatedSession } from "../auth/session"; import type { Router } from "../http/router"; import { canonical as canonicalReport } from "../mcp/create"; import { parse } from "@chopin/dialect"; -import { childDocumentPath, documentPath } from "@chopin/protocol/document-url"; +import { documentsPath } from "@chopin/protocol/document-url"; import { Connections } from "./connections"; +import type { Connection } from "./connections"; import { Experiments, fail } from "./service"; import { datasetCsv } from "./export"; import { implementationSchemas } from "../tasks/connector"; @@ -55,7 +56,10 @@ function json(value: unknown, status = 200) { export function registerExperimentRoutes(router: Router, auth: HostedAuth, options: Options) { let connections = new Connections(); let changed = (id: string) => { - connections.wake(id); + void auth.storage.channels.get(id).then( + channel => channel && connections.wake(channel.repositoryId), + () => {}, + ); options.changed(id); }; let service = new Experiments(auth.storage.experiments, options.lease, changed); @@ -74,6 +78,31 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio if (write && channel.archivedAt) fail("document-archived"); return { channel, repository, session }; } + /** Push or admin on a repository named by a checkout, resolved through the session. */ + async function repositoryWrite(session: AuthenticatedSession | undefined, fullName: string) { + if (!session) fail("authentication-required"); + let [owner, name] = fullName.split("/"); + let { value: repository } = await auth.sessions.use( + session, + token => auth.github.repositoryAccess(token, owner, name), + ); + if (!repository || !(repository.permissions.push || repository.permissions.admin)) { + fail("repository-forbidden"); + } + return repository; + } + /** Work only ever runs on the clicker's own live connection for the document's repository. */ + async function busy(connection: Connection) { + return (await service.store.active()).some(item => + item.connectionId === connection.id && ["running", "publishing"].includes(item.state) + ) || !!await options.implementations?.()?.busy(connection); + } + async function choose(owner: string, repositoryId: string) { + let live = connections.list(repositoryId, owner); + if (!live.length) fail("no-workspace"); + for (let connection of live) if (!await busy(connection)) return connection; + return live[0]; + } async function mutationAllowed(id: string) { if (options.canMutate && !await options.canMutate(id)) fail("document-locked"); } @@ -121,50 +150,22 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let session = await auth.sessions.authenticate(request); if (!session) fail("authentication-required"); let pending = connections.pending(params.id); - let [owner, name] = pending.input.repository.split("/"); - let { value: repository } = await auth.sessions.use( - session, - token => auth.github.repositoryAccess(token, owner, name), - ); - if (!repository || !(repository.permissions.push || repository.permissions.admin)) { - fail("repository-forbidden"); - } - let documents = await auth.storage.channels.list(repository.id, 100); - return json({ input: pending.input, documents: documents.channels, owner: session.user.login }); + await repositoryWrite(session, pending.input.repository); + return json({ input: pending.input, owner: session.user.login }); }); route("POST", "/api/connector/pairings/:id/approve", async (request, params) => { - let { documentId } = z.object({ documentId: z.string().uuid() }).strict().parse( - await body(request), - ); - let { session, channel } = await access( - await auth.sessions.authenticate(request), - documentId, - true, - ); + z.object({}).strict().parse(await body(request)); + let session = await auth.sessions.authenticate(request); let pending = connections.pending(params.id); - if ( - pending.input.repository.toLowerCase() - !== `${channel.repositoryOwner}/${channel.repositoryName}`.toLowerCase() - ) fail("repository-forbidden"); + let repository = await repositoryWrite(session, pending.input.repository); let connection = connections.approve( params.id, - { id: session.user.id, login: session.user.login, sessionId: session.session.id }, - documentId, - channel.repositoryId, + { id: session!.user.id, login: session!.user.login, sessionId: session!.session.id }, + repository.id, ); - changed(documentId); - let parent = channel.parentChannelId - ? await auth.storage.channels.get(channel.parentChannelId) - : undefined; - let url = parent - ? childDocumentPath( - channel.repositoryOwner, - channel.repositoryName, - parent.slug, - channel.slug, - ) - : documentPath(channel.repositoryOwner, channel.repositoryName, channel.slug); - return json({ id: connection.id, documentId, url }); + connections.wake(repository.id); + let [owner, name] = pending.input.repository.split("/"); + return json({ id: connection.id, url: documentsPath(owner, name) }); }); route("POST", "/api/connector/pairings/:id/claim", async (request, params) => { let { secret } = z.object({ secret: z.string().min(32).max(100) }).strict().parse( @@ -174,9 +175,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio }); route("GET", "/api/documents/:id/experiments", async (request, params) => { await access(await auth.sessions.authenticate(request), params.id, false); - connections.sweep(); return json({ - connections: connections.list(params.id).map(({ sessionId: _session, ...value }) => value), experiments: (await service.store.list(params.id)).map(value => ({ id: value.id, brief: value.brief, @@ -210,21 +209,20 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio return json(publicInvestigation(value)); }); route("POST", "/api/documents/:id/experiments/:experiment/run", async (request, params) => { - let { session } = await access(await auth.sessions.authenticate(request), params.id, true); - await mutationAllowed(params.id); - let { connectionId } = z.object({ connectionId: z.string().uuid() }).strict().parse( - await body(request), + let { session, channel } = await access( + await auth.sessions.authenticate(request), + params.id, + true, ); - let connection = connections.get(connectionId); - if ( - !connection || connection.owner !== session.user.id || connection.documentId !== params.id - ) fail("connection-forbidden"); + await mutationAllowed(params.id); + z.object({}).strict().parse(await body(request)); let value = await service.store.get(params.experiment); if (!value || value.documentId !== params.id) fail("not-found"); let context = await options.context(params.id); if (!context) fail("not-found"); let previous = (await service.store.list(params.id)).toReversed().find(item => item.input); - let result = await service.authorize(value.id, connectionId, { + let connection = await choose(session.user.id, channel.repositoryId); + let result = await service.authorize(value.id, connection.id, { id: value.id, documentId: params.id, requester: value.requester, @@ -247,16 +245,31 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio ); }); + /** Re-authorize the connection's owner for its repository, and for a run's document. */ async function connector(token: string) { let found = connections.lookup(token); - await access( - await auth.sessions.resolve(found.connection.sessionId), - found.connection.documentId, - true, - ); + let session = await auth.sessions.resolve(found.connection.sessionId); + let repository = await repositoryWrite(session, found.connection.source.repository); + if (repository.id !== found.connection.source.repositoryId) fail("repository-forbidden"); + if (found.grant.run) await target(session, found.connection, found.grant.run.documentId); if (!connections.get(found.connection.id)) fail("connection-unavailable"); connections.touch(found.connection); - return found; + return { ...found, session: session! }; + } + /** Write access to a run's document, which must belong to the connection's repository. */ + async function target( + session: AuthenticatedSession | undefined, + connection: Connection, + documentId: string, + ) { + let { channel } = await access(session, documentId, true); + if (channel.repositoryId !== connection.source.repositoryId) fail("repository-forbidden"); + } + /** The connection's queued, running, or publishing investigations across its repository. */ + async function work(connection: Connection, states: string[]) { + return (await service.store.active()).filter(item => + item.connectionId === connection.id && states.includes(item.state) + ); } route( "GET", @@ -332,12 +345,11 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio (request.headers.get("authorization") ?? "").replace(/^Bearer /, ""), ); if (!grant.run) connections.revoke(connection.id); - changed(connection.documentId); return new Response(null, { status: 204 }); }); route("POST", "/connector/mcp", async request => { let token = (request.headers.get("authorization") ?? "").replace(/^Bearer /, ""); - let { connection, grant } = await connector(token); + let { connection, grant, session } = await connector(token); let implementation = options.implementations?.(); let implementationRun = grant.run?.kind === "implementation"; let schemas = implementationRun ? implementationSchemas(true) : { @@ -385,12 +397,8 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let value: unknown; if (name === "disconnect_workspace") { connections.revoke(connection.id); - changed(connection.documentId); - for (let active of await service.store.list(connection.documentId)) { - if ( - active.connectionId === connection.id - && ["queued", "running", "publishing"].includes(active.state) - ) await service.stop(active.id, "interrupted", "Workspace disconnected."); + for (let active of await work(connection, ["queued", "running", "publishing"])) { + await service.stop(active.id, "interrupted", "Workspace disconnected."); } value = { disconnected: true }; } else if (name === "wait_for_work") { @@ -398,37 +406,34 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio await implementation!.waiting(connection) ?? (await implementation!.busy(connection) ? undefined - : (await service.store.list(connection.documentId)).filter(item => - item.connectionId === connection.id && item.state === "queued" - ).map(item => ({ id: item.id, kind: "experiment" }))[0]); + : (await work(connection, ["queued"])).map(item => ({ + id: item.id, + kind: "experiment", + }))[0]); value = await queued(); if (!value) { - await connections.wait(connection.documentId, request.signal); + await connections.wait(connection.source.repositoryId, request.signal); await connector(token); value = await queued(); } value ??= { waiting: true }; } else if (implementationRun || Object.hasOwn(implementationSchemas(false), name)) { if (!implementation) fail("tool-forbidden"); + let documentId = grant.run?.documentId ?? connections.assigned(connection.id); + if (documentId) await target(session, connection, documentId); if (name === "claim_implementation_build") { value = await connections.locked(connection.id, async () => { - if ( - (await service.store.list(connection.documentId)).some(item => - item.connectionId === connection.id - && ["running", "publishing"].includes(item.state) - ) - ) fail("workspace-busy"); + if ((await work(connection, ["running", "publishing"])).length) { + fail("workspace-busy"); + } return implementation.call(connection, grant, name, args); }); } else value = await implementation.call(connection, grant, name, args); } else if (name === "wait_for_experiment") { - let queued = async () => - (await service.store.list(connection.documentId)).find(item => - item.connectionId === connection.id && item.state === "queued" - ); + let queued = async () => (await work(connection, ["queued"]))[0]; value = await queued(); if (!value) { - await connections.wait(connection.documentId, request.signal); + await connections.wait(connection.source.repositoryId, request.signal); await connector(token); value = await queued(); } @@ -436,17 +441,38 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } else if (name === "claim_experiment") { let id = z.string().uuid().parse(args.id); value = await connections.locked(connection.id, async () => { - let active = (await service.store.list(connection.documentId)).find(item => - item.connectionId === connection.id && ["running", "publishing"].includes(item.state) - ); + let active = (await work(connection, ["running", "publishing"]))[0]; if (active && active.id !== id || await implementation?.busy(connection)) { fail("workspace-busy"); } + let offered = await service.store.get(id); + if (!offered || offered.connectionId !== connection.id) fail("connection-forbidden"); + try { + await target(session, connection, offered.documentId); + await mutationAllowed(offered.documentId); + } catch (error) { + // One archived or building document must not stall the repository's connector. + if ( + !(error instanceof ExperimentError) + || !["document-archived", "document-locked"].includes(error.code) + ) throw error; + await service.stop( + id, + "interrupted", + "The document changed before your local agent started. Propose a retry.", + ); + fail("invalid-state"); + } let claimed = await service.claim(id, connection.id); return { input: claimed.input, generation: claimed.generation, - runToken: connections.runToken(connection.id, id, claimed.generation), + runToken: connections.runToken( + connection.id, + claimed.documentId, + id, + claimed.generation, + ), }; }); } else { @@ -455,9 +481,10 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio ?? z.number().int().positive().parse(args.generation); let current = await service.store.get(id); if ( - !current || current.documentId !== connection.documentId - || current.connectionId !== connection.id + !current || current.connectionId !== connection.id + || grant.run && current.documentId !== grant.run.documentId ) fail("connection-forbidden"); + if (!grant.run) await target(session, connection, current.documentId); if (name === "complete_experiment") { value = await service.complete(id, connection.id, generation); } else { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 45d7e4504..57c44f8e8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -386,7 +386,7 @@ function conversation( investigations: (await experiments!.service.store.list(room.id)).slice(0, 20).map( value => ({ id: value.id, brief: value.brief.slice(0, 400), state: value.state }), ), - workspaces: experiments!.connections.list(room.id).map(value => ({ + workspaces: experiments!.connections.list(repository.id).map(value => ({ label: value.label, owner: value.login, })), @@ -1964,10 +1964,10 @@ registerMcpRoutes(router, hostedAuth, { }); implementations = registerImplementationRoutes(router, hostedAuth, { connections: experiments.connections, - busy: async id => - (await experiments!.service.store.list(id)).filter(item => - ["running", "publishing"].includes(item.state) - ).map(item => item.connectionId!), + busy: async connectionId => + (await experiments!.service.store.active()).some(item => + item.connectionId === connectionId && ["running", "publishing"].includes(item.state) + ), withPlan: (id, action) => withDocumentTransition(id, async () => { if (deletingChannels.has(id)) throw new Error("document is unavailable"); diff --git a/apps/server/src/tasks/connector.ts b/apps/server/src/tasks/connector.ts index 78e5b2d09..8baba4685 100644 --- a/apps/server/src/tasks/connector.ts +++ b/apps/server/src/tasks/connector.ts @@ -51,13 +51,17 @@ export function implementationConnector( documentExists: (id: string) => Promise, ) { let tracked = new Set(); - let pending = async (connection: Connection) => - withPlan(connection.documentId, async plan => { - let build = plan.builds.at(-1); - return build?.connectionId === connection.id && build.user === connection.owner - ? build - : undefined; - }); + let pending = async (connection: Connection) => { + let documentId = connections.assigned(connection.id); + return documentId + ? withPlan(documentId, async plan => { + let build = plan.builds.at(-1); + return build?.connectionId === connection.id && build.user === connection.owner + ? build + : undefined; + }) + : undefined; + }; return { track(id: string) { tracked.add(id); @@ -73,8 +77,10 @@ export function implementationConnector( return !!build && ["queued", "starting", "running"].includes(build.state); }, async call(connection: Connection, grant: Grant, name: string, args: Record) { - tracked.add(connection.documentId); - return withPlan(connection.documentId, async plan => { + let documentId = grant.run?.documentId ?? connections.assigned(connection.id); + if (!documentId) fail("build-forbidden"); + tracked.add(documentId); + return withPlan(documentId, async plan => { let build = plan.builds.at(-1); if ( !build || build.connectionId !== connection.id || build.user !== connection.owner @@ -118,7 +124,7 @@ export function implementationConnector( build: picked, documentId: plan.id, source: { ...picked.checkout, repositoryId: picked.repositoryId }, - runToken: connections.runToken(connection.id, picked.id, 1, "implementation"), + runToken: connections.runToken(connection.id, plan.id, picked.id, 1, "implementation"), }; } if (!["starting", "running"].includes(build.state) || build.expiresAt <= Date.now()) { diff --git a/apps/server/src/tasks/routes.test.ts b/apps/server/src/tasks/routes.test.ts index d4455c029..c6ab5a128 100644 --- a/apps/server/src/tasks/routes.test.ts +++ b/apps/server/src/tasks/routes.test.ts @@ -77,10 +77,10 @@ async function setup() { }); implementations = registerImplementationRoutes(router, auth, { connections: experiments.connections, - busy: async id => - (await experiments.service.store.list(id)).filter(item => - ["running", "publishing"].includes(item.state) - ).map(item => item.connectionId!), + busy: async connectionId => + (await experiments.service.store.active()).some(item => + item.connectionId === connectionId && ["running", "publishing"].includes(item.state) + ), withPlan: async (_id, action) => action(context.plan), }); await implementationGraphs().revise(context.plan, { @@ -137,9 +137,7 @@ async function paired(context: Awaited>, cookie?: strin await (await context.call("/api/connector/pairings", { ...checkout, label: "Laptop" })).json(); await context.call( `/api/connector/pairings/${pairing.id}/approve`, - { - documentId: context.plan.id, - }, + {}, undefined, undefined, cookie, @@ -168,10 +166,9 @@ test("a paired workspace claims a browser build once and reports through run-sco let context = await setup(); let connection = await paired(context); let snapshot = await (await context.call(context.path)).json(); - expect(snapshot.workspaces[0].label).toBe("Laptop"); + expect(snapshot.localAgent).toBe(true); + expect(JSON.stringify(snapshot)).not.toContain("Laptop"); let review = { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -230,27 +227,31 @@ test("a paired workspace claims a browser build once and reports through run-sco await Plan.close(context.plan); }); -test("build authorization rejects another owner's workspace and changed checkout", async () => { +test("a build runs only on the clicker's own connection and records who started it", async () => { let context = await setup(); let foreign = await paired(context, context.otherCookie); let review = { - connectionId: foreign.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, }; - expect((await context.call(context.path, review)).status).toBe(409); - let own = await paired(context); - expect( - (await context.call(context.path, { - ...review, - connectionId: own.connection.id, - checkout: { ...checkout, commit: "b".repeat(40) }, - })).status, - ).toBe(409); + let refused = await context.call(context.path, review); + expect(refused.status).toBe(409); + expect((await refused.json()).error).toBe("no-workspace"); + expect((await (await context.call(context.path)).json()).localAgent).toBe(false); expect(context.plan.builds).toEqual([]); expect(context.plan.graph?.versions[0].state).toBe("draft"); + let own = await paired(context); + expect((await context.call(context.path, { ...review, planRevision: 1 })).status).toBe(409); + let built = await (await context.call(context.path, review)).json(); + expect(built.connectionId).toBe(own.connection.id); + expect(built.connectionId).not.toBe(foreign.connection.id); + expect(built.user).toBe("U_test"); + expect(built.checkout).toEqual(checkout); + expect( + (await tool(context, foreign.token, "claim_implementation_build", { id: built.id })).isError, + ) + .toBe(true); await Plan.close(context.plan); }); @@ -261,12 +262,8 @@ test("investigations and implementations cannot execute on the same workspace to id: crypto.randomUUID(), brief: "Inspect a fixture", })).json(); - await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, { - connectionId: connection.connection.id, - }); + await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, {}); let build = await (await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -291,8 +288,6 @@ test("disconnect reconciles a pending build without automatic replay", async () let context = await setup(); let connection = await paired(context); await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -309,8 +304,6 @@ test("a deleted document cannot block implementation recovery sweeps", async () let context = await setup(); let connection = await paired(context); await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -330,16 +323,12 @@ test("Build waits for an investigation-busy workspace before approving the graph id: crypto.randomUUID(), brief: "Inspect a fixture", })).json(); - await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, { - connectionId: connection.connection.id, - }); + await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, {}); await tool(context, connection.token, "claim_experiment", { id: investigation.id }); let snapshot = await (await context.call(context.path)).json(); - expect(snapshot.workspaces[0].available).toBe(false); + expect(snapshot.localAgent).toBe(false); expect( (await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -356,11 +345,9 @@ test("a committed build wakes the waiting connector and a failed pickup can be r let abort = new AbortController(); try { let woken = false; - let waiting = context.experiments.connections.wait(context.plan.id, abort.signal) + let waiting = context.experiments.connections.wait("R_test", abort.signal) .then(() => woken = true); let review = { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, @@ -390,8 +377,6 @@ test("returning a stopped implementation for changes durably unlocks it once", a let connection = await paired(context); try { let built = await (await context.call(context.path, { - connectionId: connection.connection.id, - checkout, planRevision: 0, graphVersion: 1, graphRevision: 1, diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index 103bb3fdf..ae551cec7 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -1,6 +1,6 @@ import { documentPath } from "@chopin/protocol/document-url"; import { z } from "zod"; -import { checkoutSchema, queueBuild } from "./builds"; +import { queueBuild } from "./builds"; import { implementationLifecycle } from "./lifecycle"; import { implementationReadiness, reportImplementationLifecycle } from "./plan-graphs"; import { exclusive } from "../plan/service"; @@ -16,12 +16,11 @@ import type { ImplementationSnapshot } from "@chopin/protocol/implementation"; type Options = { withPlan(id: string, action: (plan: Plan) => Promise): Promise; connections: Connections; - busy?: (id: string) => Promise; + /** Whether an investigation is executing on a connection, in any document. */ + busy?: (connectionId: string) => Promise; }; let buildSchema = z.object({ retryOf: z.string().uuid().optional(), - connectionId: z.string().uuid(), - checkout: checkoutSchema, planRevision: z.number().int().nonnegative(), graphVersion: z.number().int().positive(), graphRevision: z.number().int().positive(), @@ -59,7 +58,7 @@ async function body(request: Request, schema: z.ZodType): Promise { } } -/** Browser approval dispatches through the existing owner-paired connector. */ +/** A browser build runs on the clicker's own connection for the document's repository. */ export function registerImplementationRoutes(router: Router, auth: HostedAuth, options: Options) { let connector = implementationConnector( options.withPlan, @@ -98,13 +97,30 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o } return { session, channel, repository }; } + /** + * The caller's live connections for a repository that can take a build for `id`, most + * recently heard from first. A build already waiting on this document does not make its + * connection busy, so a repeated request can return that build. + */ + async function available(owner: string, repositoryId: string, id: string) { + let free = []; + for (let connection of options.connections.list(repositoryId, owner)) { + let here = options.connections.assigned(connection.id) === id; + if (await options.busy?.(connection.id) || !here && await connector.busy(connection)) { + continue; + } + if (here) free.unshift(connection); + else free.push(connection); + } + return free; + } router.on( "GET", "/api/channels/:id/implementation", safe(async (request, _url, { id }) => { - let { session } = await browser(request, id); + let { session, channel } = await browser(request, id); connector.track(id); - let busy = await options.busy?.(id) ?? []; + let localAgent = (await available(session.user.id, channel.repositoryId, id)).length > 0; let current = await options.withPlan(id, plan => exclusive(plan, async () => { let ready = implementationReadiness(plan, plan.revision); @@ -117,20 +133,10 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o return build.graphVersion === graph?.number && build.graphRevision === graph.revision; }), blockers: ready.ok ? [] : ready.blockers, - workspaces: options.connections.list(id).filter(item => item.owner === session.user.id) - .map(item => ({ - id: item.id, - label: item.label, - checkout: { - repository: item.source.repository, - commit: item.source.commit, - ...(item.source.branch ? { branch: item.source.branch } : {}), - }, - available: !busy.includes(item.id) && !plan.builds.some(build => - build.connectionId === item.id - && ["queued", "starting", "running"].includes(build.state) - ), - })), + localAgent: localAgent && !plan.builds.some(build => + build.user === session.user.id + && ["queued", "starting", "running"].includes(build.state) + ), lifecycle: plan.graph ? implementationLifecycle({ graph: plan.graph, @@ -158,25 +164,21 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o throw new GitHubError("repository write access is required", 403); } let input = await body(request, buildSchema); - let connection = options.connections.get(input.connectionId); - if ( - !connection || connection.owner !== session.user.id || connection.documentId !== id - || connection.source.repositoryId !== channel.repositoryId - ) { - throw new GitHubError("workspace is unavailable", 409); - } - if ( - input.checkout.repository.toLowerCase() !== connection.source.repository.toLowerCase() - || input.checkout.branch !== connection.source.branch - || input.checkout.commit !== connection.source.commit - ) { - throw new GitHubError("workspace checkout changed; review again", 409); + if (!options.connections.list(channel.repositoryId, session.user.id).length) { + throw new GitHubError("no-workspace", 409); } + let [connection] = await available(session.user.id, channel.repositoryId, id); + if (!connection) throw new GitHubError("workspace is offline or busy", 409); + let checkout = { + repository: connection.source.repository, + commit: connection.source.commit, + ...(connection.source.branch ? { branch: connection.source.branch } : {}), + }; connector.track(id); return options.connections.locked(connection.id, async () => { if ( !options.connections.get(connection.id) - || (await options.busy?.(id) ?? []).includes(connection.id) + || !(await available(session.user.id, channel.repositoryId, id)).includes(connection) ) { throw new GitHubError("workspace is offline or busy", 409); } @@ -185,10 +187,12 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o let build = await queueBuild(plan, { ...input, user: session.user.id, + connectionId: connection.id, repositoryId: channel.repositoryId, - checkout: input.checkout, + checkout, }); - options.connections.wake(id); + options.connections.assign(connection.id, id); + options.connections.wake(channel.repositoryId); return json(build); } catch (error) { throw new GitHubError(error instanceof Error ? error.message : "build refused", 409); diff --git a/packages/protocol/implementation.d.ts b/packages/protocol/implementation.d.ts index a7648a2db..5601fdf0a 100644 --- a/packages/protocol/implementation.d.ts +++ b/packages/protocol/implementation.d.ts @@ -42,7 +42,8 @@ export type ImplementationSnapshot = { build?: BuildRequest; /** The GitHub login of whoever requested `build`, when it is still known. */ startedBy?: string; - workspaces: Array<{ id: string; label: string; checkout: CheckoutContext; available: boolean }>; + /** Whether the viewer has a connected local agent for this repository that is free to build. */ + localAgent: boolean; blockers: string[]; lifecycle: Pick; }; From f5197aceb45760e0c4414fcbf43a198fe84112ad Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:14:37 +0100 Subject: [PATCH 02/21] Remove workspace pickers: Run and Build use your own local agent Pairing asks only to connect the checkout's repository. The inline investigation card drops its workspace select, offers Run, names its state in plain words, and shows the connector command when you have no local agent. Build on my laptop just posts. Docs describe one repository-scoped connection. Co-Authored-By: Claude Opus 5.5 --- apps/connector/src/main.ts | 6 ++- apps/web/src/build-model.test.ts | 2 +- apps/web/src/build-view.tsx | 10 +---- apps/web/src/experiments/card.tsx | 62 ++++++++++++++-------------- apps/web/src/experiments/pairing.tsx | 56 +++++++++++-------------- apps/web/src/experiments/store.ts | 16 +++---- docs/architecture.md | 6 ++- docs/implementation-lifecycle.md | 6 ++- docs/local-experiments.md | 28 ++++++++----- docs/local-launcher.md | 20 +++++---- e2e/connector.e2e.ts | 14 +++++-- e2e/experiments.e2e.ts | 52 +++++++++++++++-------- e2e/implementation-launcher.e2e.ts | 21 ++++++---- packages/experiment/src/records.ts | 10 ----- 14 files changed, 164 insertions(+), 145 deletions(-) diff --git a/apps/connector/src/main.ts b/apps/connector/src/main.ts index 68b5c5031..9495efc71 100644 --- a/apps/connector/src/main.ts +++ b/apps/connector/src/main.ts @@ -77,7 +77,7 @@ async function connect(args: string[]) { try { let { root: _root, ...source } = info; let pairing = await post("/api/connector/pairings", { ...source, label: basename(info.root) }); - console.error(`Approve this workspace in Chopin: ${pairing.url}`); + console.error(`Connect this checkout of ${info.repository} in Chopin: ${pairing.url}`); let token: string | undefined; while (!abort.signal.aborted && !token) { let claim = await post(`/api/connector/pairings/${pairing.id}/claim`, { @@ -88,7 +88,9 @@ async function connect(args: string[]) { } if (!token) return; api = await remote(url, token); - console.error("Connected. Waiting for owner-authorized local work. Ctrl-C disconnects."); + console.error( + `Connected. Work you start in any ${info.repository} document runs here. Ctrl-C disconnects.`, + ); while (!abort.signal.aborted) { let offered = await api.call("wait_for_work", {}, abort.signal) as { id?: string; diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index fe6dc30af..0144a36d5 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -36,7 +36,7 @@ function snapshot(change: Partial = {}): ImplementationS }], }, }, - workspaces: [], + localAgent: false, blockers: [], lifecycle: { execution: { state: "idle" }, history: [] }, ...change, diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index b7b5ce5d3..aebfbbaef 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -42,7 +42,7 @@ function sentence(text: string): string { /** The server's refusals for a connection it cannot reach mean the local agent is not running. */ function offline(error: unknown): boolean { return error instanceof ApiError && error.status === 409 - && /workspace is (?:offline|unavailable)|offline or busy/.test(error.message); + && /^no-workspace$|offline or busy/.test(error.message); } export function BuildView( @@ -254,12 +254,8 @@ export function BuildView( let start = async () => { if (!snapshot?.graph || busy) return; - let workspace = snapshot.workspaces.find(item => item.available); setActionError(undefined); - if (!workspace) { - setNeedsAgent(true); - return; - } + // The server picks the clicker's own connection; a snapshot can predate connecting. setNeedsAgent(false); setBusy(true); try { @@ -269,8 +265,6 @@ export function BuildView( headers: { "content-type": "application/json" }, body: JSON.stringify({ ...(snapshot.build ? { retryOf: snapshot.build.id } : {}), - connectionId: workspace.id, - checkout: workspace.checkout, planRevision: snapshot.planRevision, graphVersion: snapshot.graph.number, graphRevision: snapshot.graph.revision, diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index ef4c5b064..4d86c3c6c 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -3,9 +3,20 @@ import { ExperimentView } from "@chopin/experiment/react"; import { StaticPlanEditor } from "@chopin/editor/static"; import type { ExperimentStore } from "./store"; import { EvidenceActions } from "./evidence"; -import type { InvestigationSummary } from "@chopin/experiment/records"; +import type { InvestigationState, InvestigationSummary } from "@chopin/experiment/records"; import { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; +const WORDS: Record = { + requested: "Ready to run", + queued: "Waiting for your local agent", + running: "Running", + publishing: "Writing up", + completed: "Done", + failed: "Failed", + cancelled: "Cancelled", + interrupted: "Interrupted", +}; + export function InvestigationCard({ store, summary, userId, canEdit, locked }: { store: ExperimentStore; summary: InvestigationSummary; @@ -13,13 +24,12 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { canEdit: boolean; locked: boolean; }) { - let [connectionId, setConnectionId] = useState(""); + let [needsAgent, setNeedsAgent] = useState(false); let [busy, setBusy] = useState(false); let [error, setError] = useState(""); let [loadFailed, setLoadFailed] = useState(false); let [loading, setLoading] = useState(!store.get(summary.id)); let item = store.get(summary.id); - let owned = store.connections.filter(connection => connection.owner === userId); // Work that still needs a person opens; a finished result folds to its brief. let body = useUnfold(summary.state !== "completed", "card"); let load = async () => { @@ -35,10 +45,13 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { async function act(action: () => Promise) { setBusy(true); setError(""); + setNeedsAgent(false); try { await action(); } catch (error) { - setError(String((error as Error).message)); + let message = String((error as Error).message); + if (message === "no-workspace") setNeedsAgent(true); + else setError(message); } finally { setBusy(false); } @@ -58,7 +71,8 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { : {summary.brief}}

- {summary.state} {summary.progress && `· ${summary.progress}`} + {WORDS[summary.state] ?? summary.state} + {summary.state === "running" && summary.progress && ` · ${summary.progress}`}

{error &&

{error}

} {!item && loading &&

Loading investigation…

} @@ -75,35 +89,23 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: {
{canEdit && item.state === "requested" && ( -
- {owned.length === 0 && ( -

- Connect a local workspace to run this investigation. -

- )} - +
+ {needsAgent && ( +
+

Start your local agent to run this:

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

Open the link it prints, then press Run again.

+
+ )}
)} {canEdit && ["queued", "running", "publishing"].includes(item.state) diff --git a/apps/web/src/experiments/pairing.tsx b/apps/web/src/experiments/pairing.tsx index eaba615b2..13da4338e 100644 --- a/apps/web/src/experiments/pairing.tsx +++ b/apps/web/src/experiments/pairing.tsx @@ -3,15 +3,13 @@ import { experimentRequest } from "./api"; import { LocalLoginShell } from "../local-login-shell"; type Pairing = { - input: { label: string; repository: string; commit: string }; + input: { repository: string }; owner: string; - documents: Array<{ id: string; title: string }>; }; export default function PairingPage() { let id = new URLSearchParams(location.search).get("pairing"); let [pairing, setPairing] = useState(); - let [documentId, setDocumentId] = useState(""); let [error, setError] = useState(""); let [busy, setBusy] = useState(false); let [url, setUrl] = useState(""); @@ -37,7 +35,7 @@ export default function PairingPage() { try { let result = await experimentRequest<{ url: string }>( `/api/connector/pairings/${encodeURIComponent(id!)}/approve`, - { documentId }, + {}, ); setUrl(result.url); } catch (error) { @@ -46,41 +44,35 @@ export default function PairingPage() { setBusy(false); } } + let repository = pairing?.input.repository; return ( -

Connect local workspace

+

+ {repository ? `Connect this checkout of ${repository}` : "Connect your checkout"} +

{error &&

{error}

} {pairing && (
-

- {pairing.input.label} · {pairing.input.repository} · {pairing.input.commit.slice(0, 8)} -

-

Connected as {pairing.owner}. You authorize each investigation on this workspace.

- {url - ? Open document + ? ( + <> +

+ Connected. Chopin can now run work for you in any {repository} document. +

+ Open {repository} + + ) : ( - + <> +

Signed in as {pairing.owner}.

+ + )}
)} diff --git a/apps/web/src/experiments/store.ts b/apps/web/src/experiments/store.ts index 0ae3e5cfd..06b252248 100644 --- a/apps/web/src/experiments/store.ts +++ b/apps/web/src/experiments/store.ts @@ -1,8 +1,4 @@ -import type { - InvestigationSummary, - PublishedInvestigation, - WorkspaceConnection, -} from "@chopin/experiment/records"; +import type { InvestigationSummary, PublishedInvestigation } from "@chopin/experiment/records"; import { experimentRequest } from "./api"; import type { Wire } from "../wire"; import type { SelectionPatch } from "@chopin/experiment"; @@ -14,7 +10,6 @@ export class ExperimentStore { #request = 0; #details = new Map(); items: InvestigationSummary[] = []; - connections: WorkspaceConnection[] = []; values = new Map(); error = ""; constructor(readonly documentId: string) {} @@ -36,12 +31,13 @@ export class ExperimentStore { let request = ++this.#request; let controller = this.#controller; try { - let result = await experimentRequest< - { experiments: InvestigationSummary[]; connections: WorkspaceConnection[] } - >(`/api/documents/${this.documentId}/experiments`, undefined, this.#controller.signal); + let result = await experimentRequest<{ experiments: InvestigationSummary[] }>( + `/api/documents/${this.documentId}/experiments`, + undefined, + this.#controller.signal, + ); if (request !== this.#request) return; this.items = result.experiments; - this.connections = result.connections; this.error = ""; this.changed(); for (let item of result.experiments) { diff --git a/docs/architecture.md b/docs/architecture.md index a34475eab..22104f385 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -165,8 +165,10 @@ start a turn. See [Remote Planner](hosted-agent.md#remote-planner) and An outbound local connector uses ACP to control a participant's coding agent and a separate, scoped `/connector/mcp` surface for work claims and bounded result data. -Browser pairing binds the connector to an authenticated owner and document. Only -that owner can authorize execution. Connector grants remain process-local; their +Browser pairing binds the connector to an authenticated owner and one +repository, and it serves every document there. Work always runs on the +connection of the person who starts it; only that owner can authorize or cancel +execution. Connector grants remain process-local; their durable investigations, captured results, shared view state and evidence decisions live in PostgreSQL. New native document references project these records without evaluating uploaded code. See [Local investigations](local-experiments.md). diff --git a/docs/implementation-lifecycle.md b/docs/implementation-lifecycle.md index b07ff75fc..6026f8033 100644 --- a/docs/implementation-lifecycle.md +++ b/docs/implementation-lifecycle.md @@ -5,7 +5,8 @@ to an external coding agent. It persists a versioned dependency graph, lets a coding agent claim an approved version, records task and pull-request progress, and requires graph-wide verification before releasing a successful run. The ordinary MCP read-before-claim path accepts MCP-created documents. Browser plans -use the owner-paired ACP connector and its run-scoped bridge; see the +run on the clicker's own repository-scoped ACP connector and its run-scoped +bridge; see the [local implementation tracer](local-launcher.md). The web application can prepare tasks through the Planner, review and approve @@ -19,7 +20,8 @@ implementation, automatic restart and an installer remain future work. `read_implementation_graph` and `edit_implementation_graph`. - A **person** is the only actor allowed by the domain model to approve a draft. The browser's “Build on my laptop” action approves the exact reviewed - graph and queues its assigned paired workspace in one durable commit. + graph and queues it, with the clicker's own connection and checkout, in one + durable commit. - The **local connector** creates an exact-commit worktree and ACP session, then claims a browser-approved graph before prompting the orchestrator. Its scoped bridge fixes document and run identity for lifecycle reports. diff --git a/docs/local-experiments.md b/docs/local-experiments.md index f1dabd488..f7edbdd18 100644 --- a/docs/local-experiments.md +++ b/docs/local-experiments.md @@ -19,9 +19,11 @@ client implementation; it does not invoke a vendor-specific SDK. The connector requires Bun 1.4.2 and Git. The agent must support local stdio ACP sessions and MCP tools through either advertised HTTP support or the default stdio transport. Authenticate the agent normally before connecting it. -Open the printed pairing link, sign into Chopin, select a document in the same -repository, and choose **Connect**. Both the browser and connector contact the -server over outbound requests. The browser does not access your filesystem. +Open the printed pairing link, sign into Chopin with push access to the +checkout's repository, and choose **Connect**. One connection serves every +document in that repository; there is no per-document pairing. Both the browser +and connector contact the server over outbound requests. The browser does not +access your filesystem. The connector credential is held in memory and tied to your browser login. Connector grants are deliberately process-bound even though hosted browser @@ -30,13 +32,19 @@ sessions can survive releases. Reconnect after a server restart or logout. ## Run and inspect work At the end of the document, choose **Propose investigation** and enter a brief. -The request appears as an inline card. Choose one of your connected workspaces -and select **Run on my workspace**. A Planner can also propose an investigation; -its card still needs a workspace owner's action. Progress and published results -appear in that card automatically, without opening another tab or dialog. - -Several people can connect to the same document. Only a connection's owner can -authorize its local work. Other document writers can inspect published results, +The request appears as an inline card marked **Ready to run**. Select **Run**: +the work runs on your own local agent, never on another person's. Chopin uses +your most recently active connection for the repository that is not already +busy. Without one, the card shows the connector command to start, and you select +**Run** again once connected. A Planner can also propose an investigation; its +card still needs someone to run it. Progress and published results appear in +that card automatically, without opening another tab or dialog. + +Several people can connect checkouts of the same repository. Only a +connection's owner can start or cancel work on it. The server rechecks the +owner's repository push access on every connector call, and write access, +archive state and the implementation lock on the target document when work is +started and claimed. Other document writers can inspect published results, change shared selections, and record decisions. The first authorized investigation supplies the document's default experiment diff --git a/docs/local-launcher.md b/docs/local-launcher.md index 6a1f0e3c0..e811311e5 100644 --- a/docs/local-launcher.md +++ b/docs/local-launcher.md @@ -15,8 +15,9 @@ CHOPIN_URL=https://your-chopin-instance.example \ bun run connector connect /absolute/path/to/project -- copilot --acp ``` -Open the printed pairing link, sign into Chopin, choose the document and select -**Connect**. This is the existing investigation pairing flow. No GitHub bearer +Open the printed pairing link, sign into Chopin and select **Connect**. This is +the existing investigation pairing flow; one connection serves every document in +the checkout's repository. No GitHub bearer needs to be configured in a second companion. The connector credential is held in memory and tied to the owner's browser login. Reconnect after a server restart or logout. @@ -38,11 +39,12 @@ project instructions and configured services. [drafting tasks from Build](implementation-lifecycle.md#drafting-tasks-from-build). 3. Review task goals, acceptance criteria and dependencies. To change them, ask Chopin in Chat. -4. Select **Build on my laptop**. It uses your first available connected - workspace; without one, Build shows the connector command to start, and you - select it again once connected. Approval persists the reviewed document and - graph counters, checkout commit and connection owner, then freezes planning - edits. +4. Select **Build on my laptop**. The build runs on your own local agent: the + server picks your most recently active connection for the repository that is + not busy, and derives the checkout from it. Without one, Build shows the + connector command to start, and you select it again once connected. Approval + persists the reviewed document and graph counters, checkout commit, + connection and the person who started it, then freezes planning edits. 5. The connector creates an isolated worktree at that exact commit and an implementation branch, preserving existing local edits. It creates an ACP session and claims the graph before prompting the orchestrator. @@ -65,8 +67,8 @@ not stop a run, and editing locks continue to update through the document socket. A `#task-` link opens Build with that task expanded. -Duplicate Build requests for the same owner, workspace and reviewed graph return -the same durable intent. A picked-up build is never automatically replayed. +Duplicate Build requests from the same person for the same reviewed graph +return the same durable intent. A picked-up build is never automatically replayed. Refresh retains task progress, blockers, session identity and reported PRs. Expired connections or missing heartbeats mark tracked builds as needing attention; after a server restart, opening the document tracks diff --git a/e2e/connector.e2e.ts b/e2e/connector.e2e.ts index 0d71fa196..b96105566 100644 --- a/e2e/connector.e2e.ts +++ b/e2e/connector.e2e.ts @@ -4,6 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "./room"; import { ROOT } from "./servers"; +import { testChannelPath } from "./database"; + +/** A connection serves every document in its repository, so each pairing test is its own person. */ +function person() { + return `ana-${crypto.randomUUID().slice(0, 8)}`; +} for (let transport of ["stdio", "http"] as const) { test(`the connector executes an ACP turn and publishes through ${transport} MCP`, async ({ baseURL, join: enter, room, seed }) => { @@ -52,11 +58,11 @@ for (let transport of ["stdio", "http"] as const) { }); }); try { - let page = await enter("ana"); + let page = await enter(person()); await page.goto(await pairing); - await page.getByRole("combobox", { name: "Document", exact: true }).selectOption(room); await page.getByRole("button", { name: "Connect", exact: true }).click(); - await page.getByRole("link", { name: "Open document", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("Connected."); + await page.goto(testChannelPath(room)); await page.getByRole("button", { name: "Propose investigation", exact: true, @@ -67,7 +73,7 @@ for (let transport of ["stdio", "http"] as const) { "Produce the captured startup fixture", ); await proposal.getByRole("button", { name: "Propose investigation", exact: true }).click(); - await page.getByRole("button", { name: "Run on my workspace", exact: true }).click(); + await page.getByRole("button", { name: "Run", exact: true }).click(); await expect(page.getByRole("table", { name: "Median startup time", exact: true })) .toContainText("183", { timeout: 30_000 }); expect(output).toContain("Running"); diff --git a/e2e/experiments.e2e.ts b/e2e/experiments.e2e.ts index e03aa67e8..9de167b38 100644 --- a/e2e/experiments.e2e.ts +++ b/e2e/experiments.e2e.ts @@ -1,9 +1,15 @@ import { performance } from "../packages/experiment/src/fixtures"; +import { testChannelPath } from "./database"; import { content, expect, test } from "./room"; -test("owner pairs a workspace and publishes evidence that survives disconnect", async ({ baseURL, join, room, seed }) => { +/** A connection serves every document in its repository, so each pairing test is its own person. */ +function person() { + return `ana-${crypto.randomUUID().slice(0, 8)}`; +} + +test("the clicker's own agent runs work and publishes evidence that survives disconnect", async ({ baseURL, join, room, seed }) => { await seed("# Investigation\n\nCompare measured startup time.\n"); - let page = await join("ana"); + let page = await join(person()); let pairing = await (await fetch(`${baseURL}/api/connector/pairings`, { method: "POST", headers: { "content-type": "application/json" }, @@ -14,9 +20,15 @@ test("owner pairs a workspace and publishes evidence that survives disconnect", }), })).json(); await page.goto(pairing.url); - await page.getByRole("combobox", { name: "Document", exact: true }).selectOption(room); + await expect(page.getByRole("heading", { name: "Connect this checkout of octo-org/score" })) + .toBeVisible(); + await expect(page.getByRole("combobox")).toHaveCount(0); await page.getByRole("button", { name: "Connect", exact: true }).click(); - await page.getByRole("link", { name: "Open document", exact: true }).click(); + await expect(page.getByRole("status")).toHaveText( + "Connected. Chopin can now run work for you in any octo-org/score document.", + ); + await expect(page.getByText("a".repeat(8))).toHaveCount(0); + await page.goto(testChannelPath(room)); let paired = await (await fetch(`${baseURL}/api/connector/pairings/${pairing.id}/claim`, { method: "POST", headers: { "content-type": "application/json" }, @@ -73,9 +85,16 @@ test("owner pairs a workspace and publishes evidence that survives disconnect", await expect(proposal).toBeHidden(); let experiment = await created.json(); let peer = await join("leo"); - await expect(peer.getByRole("button", { name: "Run on my workspace", exact: true })) - .toBeDisabled(); - await page.getByRole("button", { name: "Run on my workspace", exact: true }).click(); + let proposal = page.getByRole("article", { name: "Compare startup approaches", exact: true }); + await expect(proposal.getByRole("status")).toHaveText("Ready to run"); + await expect(proposal.getByRole("combobox")).toHaveCount(0); + // Another person's Run never borrows Ana's agent. + let peerProposal = peer.getByRole("article", { name: "Compare startup approaches", exact: true }); + await peerProposal.getByRole("button", { name: "Run", exact: true }).click(); + await expect(peerProposal).toContainText("Start your local agent to run this:"); + await expect(proposal.getByRole("status")).toHaveText("Ready to run"); + await proposal.getByRole("button", { name: "Run", exact: true }).click(); + await expect(proposal.getByRole("status")).toHaveText("Waiting for your local agent"); async function tool(name: string, args: unknown, token = paired.token) { let response = await fetch(`${baseURL}/connector/mcp`, { method: "POST", @@ -139,18 +158,17 @@ test("owner pairs a workspace and publishes evidence that survives disconnect", }); expect(proposed.ok(), await proposed.text()).toBe(true); let queued = page.getByRole("article", { name: "Check memory use", exact: true }); - await queued.getByRole("button", { name: "Run on my workspace", exact: true }).click(); + await queued.getByRole("button", { name: "Run", exact: true }).click(); await expect(queued.getByRole("button", { name: "Cancel investigation", exact: true })) .toBeVisible(); await expect(peer.getByRole("button", { name: "Cancel investigation", exact: true })).toHaveCount( 0, ); await queued.getByRole("button", { name: "Cancel investigation", exact: true }).click(); - await expect(queued).toContainText("cancelled"); + await expect(queued.getByRole("status")).toHaveText("Cancelled"); await queued.getByRole("button", { name: "Propose retry", exact: true }).click(); await expect(queued).toHaveCount(2); - await expect(queued.getByRole("button", { name: "Run on my workspace", exact: true })) - .toBeVisible(); + await expect(queued.getByRole("button", { name: "Run", exact: true })).toBeVisible(); await tool("disconnect_workspace", {}); await page.reload(); @@ -169,7 +187,7 @@ test("owner pairs a workspace and publishes evidence that survives disconnect", test("an incoming proposal appears inline and recovers from a failed detail load", async ({ join, seed, room }) => { await seed("# Investigation\n\nCheck before choosing an approach.\n"); - let page = await join("ana", { + let page = await join(person(), { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, @@ -189,12 +207,12 @@ test("an incoming proposal appears inline and recovers from a failed detail load }); await expect(card.getByRole("button", { name: "Retry loading", exact: true })).toBeVisible(); await card.getByRole("button", { name: "Retry loading", exact: true }).click(); - await expect(card.getByRole("button", { name: "Run on my workspace", exact: true })) - .toBeDisabled(); - await expect(card).toContainText("Connect a local workspace"); + await expect(card.getByRole("status")).toHaveText("Ready to run"); + await expect(card.getByRole("combobox")).toHaveCount(0); + await card.getByRole("button", { name: "Run", exact: true }).click(); + await expect(card).toContainText("Start your local agent to run this:"); await expect(page.getByRole("alert")).toHaveCount(0); await expect(page.getByRole("dialog")).toHaveCount(0); await page.reload(); - await expect(card.getByRole("button", { name: "Run on my workspace", exact: true })) - .toBeDisabled(); + await expect(card.getByRole("button", { name: "Run", exact: true })).toBeEnabled(); }); diff --git a/e2e/implementation-launcher.e2e.ts b/e2e/implementation-launcher.e2e.ts index 254b72afb..a369b897e 100644 --- a/e2e/implementation-launcher.e2e.ts +++ b/e2e/implementation-launcher.e2e.ts @@ -3,11 +3,16 @@ import { execFileSync, spawn } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { seedChildChannel } from "./database"; +import { seedChildChannel, testChannelPath } from "./database"; import { ROOT } from "./servers"; import { storedQuestion } from "../apps/server/src/testing/plan"; import type { Graph } from "../apps/server/src/tasks/graphs"; +/** A connection serves every document in its repository, so each pairing test is its own person. */ +function person() { + return `ana-${crypto.randomUUID().slice(0, 8)}`; +} + function preparedGraph(): Graph { return { versions: [{ @@ -194,11 +199,11 @@ for (let mode of ["block", "complete", "startup-failure", "live"] as const) { ], ); try { - let page = await enter("ana"); + let page = await enter(person()); await page.goto(await local.pairing); - await page.getByRole("combobox", { name: "Document", exact: true }).selectOption(room); await page.getByRole("button", { name: "Connect", exact: true }).click(); - await page.getByRole("link", { name: "Open document", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("Connected."); + await page.goto(testChannelPath(room)); let view = page.getByRole("region", { name: "Build", exact: true }); let openBuild = () => page.getByRole("group", { name: "Document view" }) @@ -328,7 +333,7 @@ test("a #task link opens Build on that task, and rows toggle from the keyboard", test("Build without a running local agent says how to start one", async ({ seed, join: enter }) => { await seed("# Offline build\n\nNo local agent is connected.\n", { graph: preparedGraph() }); - let page = await enter("ana"); + let page = await enter(person()); await openBuildView(page); let view = buildView(page); await expect(view).toContainText("2 tasks"); @@ -422,11 +427,11 @@ test("a build in progress shows who started it and offers no action", async ({ s // An agent that never answers keeps the build starting until this test closes it. let local = await connector(baseURL!, ["bun", "-e", "setInterval(() => {}, 1 << 30)"]); try { - let page = await enter("ana"); + let page = await enter(person()); await page.goto(await local.pairing); - await page.getByRole("combobox", { name: "Document", exact: true }).selectOption(room); await page.getByRole("button", { name: "Connect", exact: true }).click(); - await page.getByRole("link", { name: "Open document", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("Connected."); + await page.goto(testChannelPath(room)); await openBuildView(page); let view = buildView(page); await view.getByRole("button", { name: "Build on my laptop", exact: true }).click(); diff --git a/packages/experiment/src/records.ts b/packages/experiment/src/records.ts index 4e9eebec6..116429175 100644 --- a/packages/experiment/src/records.ts +++ b/packages/experiment/src/records.ts @@ -62,16 +62,6 @@ export type InvestigationSummary = Investigation, "id" | "brief" | "state" | "revision" | "requester" | "progress" | "createdAt" >; -export type WorkspaceConnection = { - id: string; - documentId: string; - owner: string; - login: string; - label: string; - source: import("./index").Source; - expiresAt: number; -}; - export function publicInvestigation( value: Investigation, ): Omit { From 13878152b67ac462107d253f0b803a705ebe554c Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:35:12 +0100 Subject: [PATCH 03/21] Harden repository-scoped connections after security review - A build in a deleted or finished document no longer strands its connection: pickup treats a missing document as no build, and the assignment is released at a terminal state and in the sweep. A build in an archived document is not offered to the connector. - Pairing shows a confirmation code that the connector prints, so a person can check the page belongs to their own terminal. - Runs and builds prefer the connection last used for the document. - The Planner no longer sees other people's connection labels. - The card shows why work failed or was interrupted; Build knows from its snapshot when no local agent is connected. - Tests route builds through two real documents and cover cross-repository claims, archived or locked documents at claim, and logout mid-run. Co-Authored-By: Claude Opus 5.5 --- apps/connector/src/main.ts | 1 + apps/server/src/agent/experiment-tools.ts | 4 +- apps/server/src/experiments/connections.ts | 30 ++++- apps/server/src/experiments/routes.test.ts | 118 +++++++++++++++++++- apps/server/src/experiments/routes.ts | 27 +++-- apps/server/src/main.ts | 4 - apps/server/src/tasks/connector.ts | 27 +++-- apps/server/src/tasks/routes.test.ts | 122 ++++++++++++++++++--- apps/server/src/tasks/routes.ts | 18 +-- apps/server/src/testing/plan.ts | 19 ++++ apps/web/src/build-view.tsx | 5 +- apps/web/src/experiments/card.tsx | 3 +- apps/web/src/experiments/pairing.tsx | 21 ++-- apps/web/src/local-login-shell.tsx | 6 +- docs/local-experiments.md | 19 ++-- docs/local-launcher.md | 7 +- e2e/connector.e2e.ts | 6 +- e2e/experiments.e2e.ts | 11 +- e2e/implementation-launcher.e2e.ts | 3 +- packages/protocol/implementation.d.ts | 2 +- 20 files changed, 368 insertions(+), 85 deletions(-) diff --git a/apps/connector/src/main.ts b/apps/connector/src/main.ts index 9495efc71..6f7af116f 100644 --- a/apps/connector/src/main.ts +++ b/apps/connector/src/main.ts @@ -78,6 +78,7 @@ async function connect(args: string[]) { let { root: _root, ...source } = info; let pairing = await post("/api/connector/pairings", { ...source, label: basename(info.root) }); console.error(`Connect this checkout of ${info.repository} in Chopin: ${pairing.url}`); + console.error(`Confirm code ${pairing.code} in Chopin.`); let token: string | undefined; while (!abort.signal.aborted && !token) { let claim = await post(`/api/connector/pairings/${pairing.id}/claim`, { diff --git a/apps/server/src/agent/experiment-tools.ts b/apps/server/src/agent/experiment-tools.ts index cdf2f95cc..f6ddb582a 100644 --- a/apps/server/src/agent/experiment-tools.ts +++ b/apps/server/src/agent/experiment-tools.ts @@ -22,7 +22,7 @@ export const experimentTools = { contextSchema, inputSchema: z.object({}).strict(), description: - "List this document's local investigations and available workspace labels. This does not start work.", + "List this document's local investigations and their states. This does not start work.", execute: (_input, { context: { room } }) => answer(async () => { if (!room.investigations) throw new Error("Investigations unavailable"); @@ -51,7 +51,7 @@ export const experimentTools = { key: z.string().min(1).max(128), }).strict(), description: - "Propose a local investigation from the current member request. Explain the objective and required evidence. A workspace owner must explicitly authorize it in Investigations; this tool never executes code. Reuse key for retries.", + "Propose a local investigation from the current member request. Explain the objective and required evidence. A person must select Run on its card, which runs it on their own local agent; this tool never executes code. Reuse key for retries.", execute: (input, { context: { room } }) => answer(async () => { let request = room.currentMemberRequest?.(); diff --git a/apps/server/src/experiments/connections.ts b/apps/server/src/experiments/connections.ts index 476a01862..595a17c7a 100644 --- a/apps/server/src/experiments/connections.ts +++ b/apps/server/src/experiments/connections.ts @@ -17,6 +17,8 @@ type Pending = { id: string; input: PairingInput; proof: string; + /** Shown by both the connector and the pairing page so a person can cross-check them. */ + code: string; expiresAt: number; connection?: Connection; token?: string; @@ -26,6 +28,13 @@ export type Grant = { run?: { id: string; documentId: string; generation: number; kind?: "implementation" }; }; +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +function confirmation() { + let bytes = randomBytes(8); + let code = [...bytes].map(byte => CODE_ALPHABET[byte % CODE_ALPHABET.length]).join(""); + return `${code.slice(0, 4)}-${code.slice(4)}`; +} + export class Connections { #pending = new Map(); #connections = new Map(); @@ -33,6 +42,7 @@ export class Connections { #runTokens = new Map(); #waiters = new Map void>>(); #builds = new Map(); + #used = new Map(); #locks = new Map>(); constructor(private now = () => Date.now()) {} create(input: PairingInput) { @@ -40,13 +50,15 @@ export class Connections { if (this.#pending.size >= 100) fail("pairing-capacity"); let secret = randomBytes(32).toString("base64url"); let id = crypto.randomUUID(); + let code = confirmation(); this.#pending.set(id, { id, input, proof: fingerprint(secret), + code, expiresAt: this.now() + 300_000, }); - return { id, secret }; + return { id, secret, code }; } pending(id: string) { let value = this.#pending.get(id); @@ -115,6 +127,21 @@ export class Connections { assigned(connectionId: string) { return this.#builds.get(connectionId); } + /** Forget a finished, deleted, or unreachable build so it cannot strand the connection. */ + release(connectionId: string, documentId: string) { + if (this.#builds.get(connectionId) === documentId) this.#builds.delete(connectionId); + } + /** Remember the connection that last ran work for a document, to prefer it next time. */ + use(documentId: string, connectionId: string) { + this.#used.set(documentId, connectionId); + } + /** Live connections for a repository, the one last used for `documentId` first. */ + candidates(repositoryId: string, owner: string, documentId: string) { + let last = this.#used.get(documentId); + return this.list(repositoryId, owner).sort((a, b) => + Number(b.id === last) - Number(a.id === last) + ); + } runToken( connectionId: string, documentId: string, @@ -137,6 +164,7 @@ export class Connections { let connection = this.#connections.get(id); this.#connections.delete(id); this.#builds.delete(id); + for (let [document, used] of this.#used) if (used === id) this.#used.delete(document); for (let key of this.#runTokens.keys()) { if (key.startsWith(`${id}:`)) this.#runTokens.delete(key); } diff --git a/apps/server/src/experiments/routes.test.ts b/apps/server/src/experiments/routes.test.ts index bceedbdcf..08ac1fa87 100644 --- a/apps/server/src/experiments/routes.test.ts +++ b/apps/server/src/experiments/routes.test.ts @@ -12,6 +12,10 @@ function text(value: { result: { content: Array<{ text: string }> } }) { return JSON.parse(value.result.content[0].text); } +function error(value: { result: { isError?: boolean; content: Array<{ text: string }> } }) { + return value.result.isError ? value.result.content[0].text : ""; +} + async function setup() { let storage = new MemoryStorage(); let now = new Date(); @@ -41,8 +45,10 @@ async function setup() { let alice = await sessions.issue("alice", grant); let bob = await sessions.issue("bob", grant); let github = { - repositoryAccess: async (_token: string, _owner: string, name: string) => ({ + repositoryAccess: async (_token: string, owner: string, name: string) => ({ id: name, + owner, + name, permissions: { pull: true, push: true, admin: false }, }), } as unknown as GitHub; @@ -54,7 +60,8 @@ async function setup() { encryptionKey: new Uint8Array(32), }; let router = new Router(); - registerExperimentRoutes(router, { + let lock = { locked: false }; + let runtime = registerExperimentRoutes(router, { storage, sessions, github, @@ -65,6 +72,7 @@ async function setup() { lease: () => lease, changed: () => {}, context: async () => ({ source: "# Context", revision: 0 }), + canMutate: async () => !lock.locked, }); async function request(path: string, body?: unknown, cookie?: string, token?: string) { return (await router.handle( @@ -102,7 +110,20 @@ async function setup() { })).json(), }; } - return { storage, sessions, config, alice, bob, document, documentId, request, tool, pair }; + return { + storage, + sessions, + config, + alice, + bob, + document, + documentId, + request, + tool, + pair, + lock, + runtime, + }; } test("work runs on the clicker's own connection; scoped claims publish", async () => { @@ -210,8 +231,95 @@ test("one connection serves every document in its repository and no other", asyn (await request(`/api/documents/${second}/experiments/${foreign}/run`, {}, bob.cookie)) .status, ).toBe(409); - expect((await tool(connection.token, "claim_experiment", { id: foreign })).result.isError) - .toBe(true); + let claimed = await tool(connection.token, "claim_experiment", { id: foreign }); + expect(claimed.result.isError).toBe(true); + expect(claimed.result.content[0].text).toStartWith("connection-forbidden"); +}); + +test("a claim refuses a document in another repository and interrupts archived or locked ones", async () => { + let { alice, document, documentId, request, tool, pair, lock, runtime, storage } = await setup(); + let connection = await pair(alice.cookie); + async function queued(target: string) { + let id = crypto.randomUUID(); + await request(`/api/documents/${target}/experiments`, { id, brief: "Measure" }, alice.cookie); + expect( + (await request(`/api/documents/${target}/experiments/${id}/run`, {}, alice.cookie)) + .status, + ).toBe(200); + return id; + } + + // A record bound to this connection in another repository is never claimable through it. + let elsewhere = await document("other"); + let foreign = crypto.randomUUID(); + await runtime.service.create(elsewhere, "alice", "Foreign", foreign); + await runtime.service.authorize(foreign, connection.connection.id, { + id: foreign, + documentId: elsewhere, + requester: "alice", + authorizer: "alice", + brief: "Foreign", + source: { repositoryId: "other", repository: "org/other", commit: "a".repeat(40) }, + context: "Document revision 0", + }); + expect(error(await tool(connection.token, "claim_experiment", { id: foreign }))) + .toStartWith("repository-forbidden"); + await runtime.service.stop(foreign, "cancelled"); + + let locked = await queued(documentId); + lock.locked = true; + expect(error(await tool(connection.token, "claim_experiment", { id: locked }))) + .toStartWith("invalid-state"); + expect((await runtime.service.store.get(locked))?.state).toBe("interrupted"); + lock.locked = false; + + let second = await document(); + let archived = await queued(second); + await storage.channels.archive({ id: second, now: new Date() }); + expect(error(await tool(connection.token, "claim_experiment", { id: archived }))) + .toStartWith("invalid-state"); + expect((await runtime.service.store.get(archived))?.state).toBe("interrupted"); +}); + +test("logging out revokes a run already in progress", async () => { + let { alice, documentId, request, tool, pair, sessions, config } = await setup(); + let connection = await pair(alice.cookie); + let id = crypto.randomUUID(); + await request(`/api/documents/${documentId}/experiments`, { id, brief: "Measure" }, alice.cookie); + await request(`/api/documents/${documentId}/experiments/${id}/run`, {}, alice.cookie); + let claim = text(await tool(connection.token, "claim_experiment", { id })); + expect(text(await tool(claim.runToken, "read_experiment", {})).input.id).toBe(id); + await sessions.revoke( + new Request(config.origin, { headers: { cookie: alice.cookie.split(";")[0] } }), + ); + let refused = await request( + "/connector/mcp", + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "read_experiment", arguments: {} }, + }, + undefined, + claim.runToken, + ); + expect(refused.status).toBe(401); +}); + +test("a pairing carries a confirmation code the page can show", async () => { + let { alice, request } = await setup(); + let pending = await (await request("/api/connector/pairings", { + repository: "org/repo", + commit: "a".repeat(40), + label: "Laptop", + })).json(); + expect(pending.code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/); + expect(pending.url).not.toContain(pending.code); + let shown = + await (await request(`/api/connector/pairings/${pending.id}`, undefined, alice.cookie)) + .json(); + expect(shown.code).toBe(pending.code); + expect(shown.documents).toBeUndefined(); }); test("expired pairing and connections cannot be revived by replay", () => { diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index 58e9a5021..7c1ef553c 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -97,8 +97,12 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio item.connectionId === connection.id && ["running", "publishing"].includes(item.state) ) || !!await options.implementations?.()?.busy(connection); } - async function choose(owner: string, repositoryId: string) { - let live = connections.list(repositoryId, owner); + /** + * The clicker's idle connection, preferring the one last used for this document. When every + * connection is busy the work queues behind the first. + */ + async function choose(owner: string, repositoryId: string, documentId: string) { + let live = connections.candidates(repositoryId, owner, documentId); if (!live.length) fail("no-workspace"); for (let connection of live) if (!await busy(connection)) return connection; return live[0]; @@ -151,7 +155,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio if (!session) fail("authentication-required"); let pending = connections.pending(params.id); await repositoryWrite(session, pending.input.repository); - return json({ input: pending.input, owner: session.user.login }); + return json({ input: pending.input, owner: session.user.login, code: pending.code }); }); route("POST", "/api/connector/pairings/:id/approve", async (request, params) => { z.object({}).strict().parse(await body(request)); @@ -164,8 +168,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio repository.id, ); connections.wake(repository.id); - let [owner, name] = pending.input.repository.split("/"); - return json({ id: connection.id, url: documentsPath(owner, name) }); + return json({ id: connection.id, url: documentsPath(repository.owner, repository.name) }); }); route("POST", "/api/connector/pairings/:id/claim", async (request, params) => { let { secret } = z.object({ secret: z.string().min(32).max(100) }).strict().parse( @@ -221,7 +224,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let context = await options.context(params.id); if (!context) fail("not-found"); let previous = (await service.store.list(params.id)).toReversed().find(item => item.input); - let connection = await choose(session.user.id, channel.repositoryId); + let connection = await choose(session.user.id, channel.repositoryId, params.id); let result = await service.authorize(value.id, connection.id, { id: value.id, documentId: params.id, @@ -231,6 +234,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio source: previous?.input?.source ?? connection.source, context: `Document revision ${context.revision}\n${context.source}`, }); + connections.use(params.id, connection.id); return json(publicInvestigation(result)); }); route("POST", "/api/documents/:id/experiments/:experiment/cancel", async (request, params) => { @@ -402,8 +406,17 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio } value = { disconnected: true }; } else if (name === "wait_for_work") { + // A build in an archived document cannot be claimed; its expiry fails it in the sweep. + let build = async () => { + let offered = await implementation!.waiting(connection); + let documentId = connections.assigned(connection.id); + let channel = offered && documentId + ? await auth.storage.channels.get(documentId) + : undefined; + return channel && !channel.archivedAt ? offered : undefined; + }; let queued = async () => - await implementation!.waiting(connection) + await build() ?? (await implementation!.busy(connection) ? undefined : (await work(connection, ["queued"])).map(item => ({ diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 57c44f8e8..3a40f304b 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -386,10 +386,6 @@ function conversation( investigations: (await experiments!.service.store.list(room.id)).slice(0, 20).map( value => ({ id: value.id, brief: value.brief.slice(0, 400), state: value.state }), ), - workspaces: experiments!.connections.list(repository.id).map(value => ({ - label: value.label, - owner: value.login, - })), }; }, async read(id, dataset, offset = 0) { diff --git a/apps/server/src/tasks/connector.ts b/apps/server/src/tasks/connector.ts index 8baba4685..56cc533fc 100644 --- a/apps/server/src/tasks/connector.ts +++ b/apps/server/src/tasks/connector.ts @@ -53,14 +53,23 @@ export function implementationConnector( let tracked = new Set(); let pending = async (connection: Connection) => { let documentId = connections.assigned(connection.id); - return documentId - ? withPlan(documentId, async plan => { - let build = plan.builds.at(-1); - return build?.connectionId === connection.id && build.user === connection.owner - ? build - : undefined; - }) - : undefined; + if (!documentId) return undefined; + try { + let build = await documentExists(documentId) + ? await withPlan(documentId, async plan => { + let build = plan.builds.at(-1); + return build?.connectionId === connection.id && build.user === connection.owner + ? build + : undefined; + }) + : undefined; + if (!build || !["queued", "starting", "running"].includes(build.state)) { + connections.release(connection.id, documentId); + } + return build; + } catch { + return undefined; + } }; return { track(id: string) { @@ -176,6 +185,7 @@ export function implementationConnector( await withPlan(id, async plan => { let build = plan.builds.at(-1); if (!build || !["queued", "starting", "running"].includes(build.state)) { + if (build) connections.release(build.connectionId, id); tracked.delete(id); return; } @@ -184,6 +194,7 @@ export function implementationConnector( state: "failed", error: "Workspace disconnected or agent stopped responding. No automatic replay.", }); + connections.release(build.connectionId, id); tracked.delete(id); } }); diff --git a/apps/server/src/tasks/routes.test.ts b/apps/server/src/tasks/routes.test.ts index c6ab5a128..b17cf35ba 100644 --- a/apps/server/src/tasks/routes.test.ts +++ b/apps/server/src/tasks/routes.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { Sessions } from "../auth/session"; import { Admission } from "../auth/admission"; import { Router } from "../http/router"; -import { openPlan } from "../testing/plan"; +import { openPlan, openSiblingPlan } from "../testing/plan"; import * as Plan from "../plan/service"; import { registerExperimentRoutes } from "../experiments/routes"; import { implementationGraphs } from "./plan-graphs"; @@ -11,6 +11,24 @@ import type { GitHub } from "../github/client"; import type { HostedAuth } from "../auth/routes"; let checkout = { repository: "owner/repository", branch: "main", commit: "a".repeat(40) }; +async function prepare(plan: Plan.Plan) { + await implementationGraphs().revise(plan, { + planRevision: 0, + graphRevision: 0, + operations: [{ + op: "add", + task: { + id: "first", + title: "First", + context: "Tracer", + goal: "Launch", + acceptance: ["Starts", "Reports"], + dependsOn: [], + }, + }], + }); +} + async function setup() { let context = await openPlan("# Launcher plan\n"); let github = { @@ -81,23 +99,14 @@ async function setup() { (await experiments.service.store.active()).some(item => item.connectionId === connectionId && ["running", "publishing"].includes(item.state) ), - withPlan: async (_id, action) => action(context.plan), - }); - await implementationGraphs().revise(context.plan, { - planRevision: 0, - graphRevision: 0, - operations: [{ - op: "add", - task: { - id: "first", - title: "First", - context: "Tracer", - goal: "Launch", - acceptance: ["Starts", "Reports"], - dependsOn: [], - }, - }], + withPlan: async (id, action) => { + let plan = plans.get(id); + if (!plan) throw new Error("document is unavailable"); + return action(plan); + }, }); + let plans = new Map([[context.plan.id, context.plan]]); + await prepare(context.plan); let path = `/api/channels/${context.plan.id}/implementation`; let call = async ( url: string, @@ -129,6 +138,7 @@ async function setup() { otherCookie: other.cookie, implementations, experiments, + plans, }; } @@ -326,7 +336,7 @@ test("Build waits for an investigation-busy workspace before approving the graph await context.call(`/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, {}); await tool(context, connection.token, "claim_experiment", { id: investigation.id }); let snapshot = await (await context.call(context.path)).json(); - expect(snapshot.localAgent).toBe(false); + expect(snapshot.localAgent).toBe(true); expect( (await context.call(context.path, { planRevision: 0, @@ -414,3 +424,79 @@ test("returning a stopped implementation for changes durably unlocks it once", a await Plan.close(context.plan); } }); + +test("one connection builds two documents and a finished or deleted build never strands it", async () => { + let context = await setup(); + let second = await openSiblingPlan(context, "# Second plan\n"); + await prepare(second); + context.plans.set(second.id, second); + let secondPath = `/api/channels/${second.id}/implementation`; + let review = { planRevision: 0, graphVersion: 1, graphRevision: 1 }; + let connection = await paired(context); + try { + let first = await (await context.call(context.path, review)).json(); + expect((await context.call(secondPath, review)).status).toBe(409); + await tool(context, connection.token, "claim_implementation_build", { id: first.id }); + await tool(context, connection.token, "report_implementation_build", { + id: first.id, + state: "failed", + error: "Startup failed", + }); + let next = await (await context.call(secondPath, review)).json(); + expect(next.connectionId).toBe(connection.connection.id); + expect(second.builds.at(-1)?.id).toBe(next.id); + expect(context.plan.builds).toHaveLength(1); + expect(await tool(context, connection.token, "wait_for_work")).toEqual({ + id: next.id, + kind: "implementation", + }); + let claim = await tool(context, connection.token, "claim_implementation_build", { + id: next.id, + }); + expect(claim.documentId).toBe(second.id); + await tool(context, connection.token, "report_implementation_build", { + id: next.id, + state: "running", + session: "acp-session", + }); + expect((await tool(context, claim.runToken, "read_implementation")).document.id).toBe( + second.id, + ); + + // Deleting the document under a queued build must not break pickup or a new build. + await tool(context, connection.token, "report_implementation_build", { + id: next.id, + state: "stopped", + }); + let third = await openSiblingPlan(context, "# Third plan\n"); + await prepare(third); + context.plans.set(third.id, third); + let thirdPath = `/api/channels/${third.id}/implementation`; + let queued = await (await context.call(thirdPath, review)).json(); + expect(queued.state).toBe("queued"); + await Plan.close(third); + context.plans.delete(third.id); + await context.storage.channels.archive({ id: third.id, now: context.now }); + await context.storage.channels.delete(third.id); + let snapshot = await context.call(context.path); + expect(snapshot.status).toBe(200); + expect((await snapshot.json()).localAgent).toBe(true); + let investigation = await (await context.call( + `/api/documents/${context.plan.id}/experiments`, + { id: crypto.randomUUID(), brief: "Still reachable" }, + )).json(); + let run = await context.call( + `/api/documents/${context.plan.id}/experiments/${investigation.id}/run`, + {}, + ); + expect(run.status).toBe(200); + expect((await run.json()).connectionId).toBe(connection.connection.id); + expect(await tool(context, connection.token, "wait_for_work")).toEqual({ + id: investigation.id, + kind: "experiment", + }); + } finally { + await Plan.close(second); + await Plan.close(context.plan); + } +}); diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index ae551cec7..d363b0d3c 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -98,13 +98,14 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o return { session, channel, repository }; } /** - * The caller's live connections for a repository that can take a build for `id`, most - * recently heard from first. A build already waiting on this document does not make its - * connection busy, so a repeated request can return that build. + * The caller's live connections for a repository that can take a build for `id`: the one + * last used for this document first, then the most recently heard from. A build already + * waiting on this document does not make its connection busy, so a repeated request can + * return that build. */ async function available(owner: string, repositoryId: string, id: string) { let free = []; - for (let connection of options.connections.list(repositoryId, owner)) { + for (let connection of options.connections.candidates(repositoryId, owner, id)) { let here = options.connections.assigned(connection.id) === id; if (await options.busy?.(connection.id) || !here && await connector.busy(connection)) { continue; @@ -120,7 +121,8 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o safe(async (request, _url, { id }) => { let { session, channel } = await browser(request, id); connector.track(id); - let localAgent = (await available(session.user.id, channel.repositoryId, id)).length > 0; + // Cheap on purpose: whether one is free is decided when Build is pressed. + let localAgent = options.connections.list(channel.repositoryId, session.user.id).length > 0; let current = await options.withPlan(id, plan => exclusive(plan, async () => { let ready = implementationReadiness(plan, plan.revision); @@ -133,10 +135,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o return build.graphVersion === graph?.number && build.graphRevision === graph.revision; }), blockers: ready.ok ? [] : ready.blockers, - localAgent: localAgent && !plan.builds.some(build => - build.user === session.user.id - && ["queued", "starting", "running"].includes(build.state) - ), + localAgent, lifecycle: plan.graph ? implementationLifecycle({ graph: plan.graph, @@ -192,6 +191,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o checkout, }); options.connections.assign(connection.id, id); + options.connections.use(id, connection.id); options.connections.wake(channel.repositoryId); return json(build); } catch (error) { diff --git a/apps/server/src/testing/plan.ts b/apps/server/src/testing/plan.ts index dd1c37829..1bec9207b 100644 --- a/apps/server/src/testing/plan.ts +++ b/apps/server/src/testing/plan.ts @@ -205,6 +205,25 @@ export async function checkpointSource( document.doc.destroy(); } +/** Open another document in the same repository and storage as `context`. */ +export async function openSiblingPlan( + context: Awaited>, + source = "", + state: SeedState = {}, +) { + let channel = await context.storage.channels.create({ + repositoryId: "R_test", + repositoryOwner: "owner", + repositoryName: "repository", + createdBy: "U_test", + now: context.now, + id: crypto.randomUUID(), + title: `Sibling ${crypto.randomUUID()}`, + }); + await checkpointSource(context.storage, context.lease, channel.id, source, state, context.now); + return Service.open(channel.id, context.backend, context.server); +} + export async function openPlan(source = "", state: SeedState = {}) { let now = new Date("2026-08-13T12:00:00.000Z"); let storage = new MemoryStorage(); diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index aebfbbaef..c4b92ac31 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -441,13 +441,14 @@ export function BuildView( {since && {since}} {action}
- {needsAgent && (phase.kind === "review" || phase.kind === "failed") && ( + {(needsAgent || snapshot?.localAgent === false) + && (phase.kind === "review" || phase.kind === "failed") && (

Start your local agent to build:

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

Open the link it prints, then press {primaryLabel} again.

+

Open the link it prints, then press {primaryLabel}.

)} {phase.kind === "stopped" && returning && ( diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index 4d86c3c6c..cc7275b6e 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -72,7 +72,8 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: {

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

{error &&

{error}

} {!item && loading &&

Loading investigation…

} diff --git a/apps/web/src/experiments/pairing.tsx b/apps/web/src/experiments/pairing.tsx index 13da4338e..5cb0f8093 100644 --- a/apps/web/src/experiments/pairing.tsx +++ b/apps/web/src/experiments/pairing.tsx @@ -5,6 +5,7 @@ import { LocalLoginShell } from "../local-login-shell"; type Pairing = { input: { repository: string }; owner: string; + code: string; }; export default function PairingPage() { @@ -46,25 +47,27 @@ export default function PairingPage() { } let repository = pairing?.input.repository; return ( - -

- {repository ? `Connect this checkout of ${repository}` : "Connect your checkout"} -

- {error &&

{error}

} + + {error &&

{error}

} {pairing && (
{url ? ( <> -

- Connected. Chopin can now run work for you in any {repository} document. -

+

Connected.

Open {repository} ) : ( <> -

Signed in as {pairing.owner}.

+

+ Connect this checkout of {repository}{" "} + so Chopin can run work for you in any of its documents. +

+

+ Check that your terminal shows{" "} + {pairing.code} +

-

Open your workspace

+

{title}

{children}
diff --git a/docs/local-experiments.md b/docs/local-experiments.md index f7edbdd18..4dd25ad87 100644 --- a/docs/local-experiments.md +++ b/docs/local-experiments.md @@ -20,7 +20,8 @@ requires Bun 1.4.2 and Git. The agent must support local stdio ACP sessions and MCP tools through either advertised HTTP support or the default stdio transport. Authenticate the agent normally before connecting it. Open the printed pairing link, sign into Chopin with push access to the -checkout's repository, and choose **Connect**. One connection serves every +checkout's repository, check that the page shows the same confirmation code as +your terminal, and choose **Connect**. One connection serves every document in that repository; there is no per-document pairing. Both the browser and connector contact the server over outbound requests. The browser does not access your filesystem. @@ -33,18 +34,22 @@ sessions can survive releases. Reconnect after a server restart or logout. At the end of the document, choose **Propose investigation** and enter a brief. The request appears as an inline card marked **Ready to run**. Select **Run**: -the work runs on your own local agent, never on another person's. Chopin uses -your most recently active connection for the repository that is not already -busy. Without one, the card shows the connector command to start, and you select +the work runs on your own local agent, never on another person's. Chopin +prefers the connection that last ran work for this document, then your most +recently active one that is not busy. When all of yours are busy, the work +queues behind one of them and starts when it is free. Without any, the card shows the connector command to start, and you select **Run** again once connected. A Planner can also propose an investigation; its card still needs someone to run it. Progress and published results appear in that card automatically, without opening another tab or dialog. Several people can connect checkouts of the same repository. Only a connection's owner can start or cancel work on it. The server rechecks the -owner's repository push access on every connector call, and write access, -archive state and the implementation lock on the target document when work is -started and claimed. Other document writers can inspect published results, +owner's repository push access on every connector call, and write access and +archive state on the target document whenever work is claimed or reported. An +investigation also requires that the document is not locked by an active build, +both when it is started and when it is claimed; a document archived or locked +in between interrupts the investigation instead of stalling the connector. A +build is itself that lock, so builds do not check it. Other document writers can inspect published results, change shared selections, and record decisions. The first authorized investigation supplies the document's default experiment diff --git a/docs/local-launcher.md b/docs/local-launcher.md index e811311e5..5e228cc8b 100644 --- a/docs/local-launcher.md +++ b/docs/local-launcher.md @@ -15,7 +15,8 @@ CHOPIN_URL=https://your-chopin-instance.example \ bun run connector connect /absolute/path/to/project -- copilot --acp ``` -Open the printed pairing link, sign into Chopin and select **Connect**. This is +Open the printed pairing link, sign into Chopin, check that the page shows the +confirmation code printed in your terminal, and select **Connect**. This is the existing investigation pairing flow; one connection serves every document in the checkout's repository. No GitHub bearer needs to be configured in a second companion. The connector credential is held @@ -40,8 +41,8 @@ project instructions and configured services. 3. Review task goals, acceptance criteria and dependencies. To change them, ask Chopin in Chat. 4. Select **Build on my laptop**. The build runs on your own local agent: the - server picks your most recently active connection for the repository that is - not busy, and derives the checkout from it. Without one, Build shows the + server picks your idle connection for the repository, preferring the one that + last ran work for this document, and derives the checkout from it. Without one, Build shows the connector command to start, and you select it again once connected. Approval persists the reviewed document and graph counters, checkout commit, connection and the person who started it, then freezes planning edits. diff --git a/e2e/connector.e2e.ts b/e2e/connector.e2e.ts index b96105566..802e6490a 100644 --- a/e2e/connector.e2e.ts +++ b/e2e/connector.e2e.ts @@ -60,8 +60,12 @@ for (let transport of ["stdio", "http"] as const) { try { let page = await enter(person()); await page.goto(await pairing); + let printed = () => output.match(/Confirm code ([A-Z2-9]{4}-[A-Z2-9]{4}) in Chopin/)?.[1]; + await expect.poll(printed).toBeTruthy(); + let code = printed(); + await expect(page.getByText(code!, { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Connect", exact: true }).click(); - await expect(page.getByRole("status")).toContainText("Connected."); + await expect(page.getByRole("status")).toHaveText("Connected."); await page.goto(testChannelPath(room)); await page.getByRole("button", { name: "Propose investigation", diff --git a/e2e/experiments.e2e.ts b/e2e/experiments.e2e.ts index 9de167b38..8c074c963 100644 --- a/e2e/experiments.e2e.ts +++ b/e2e/experiments.e2e.ts @@ -20,13 +20,16 @@ test("the clicker's own agent runs work and publishes evidence that survives dis }), })).json(); await page.goto(pairing.url); - await expect(page.getByRole("heading", { name: "Connect this checkout of octo-org/score" })) + await expect(page.getByRole("heading", { name: "Connect your local agent", exact: true })) .toBeVisible(); + await expect(page.getByText("Connect this checkout of octo-org/score so Chopin can run work")) + .toBeVisible(); + await expect(page.getByText(pairing.code, { exact: true })).toBeVisible(); await expect(page.getByRole("combobox")).toHaveCount(0); await page.getByRole("button", { name: "Connect", exact: true }).click(); - await expect(page.getByRole("status")).toHaveText( - "Connected. Chopin can now run work for you in any octo-org/score document.", - ); + await expect(page.getByRole("status")).toHaveText("Connected."); + await expect(page.getByRole("link", { name: "Open octo-org/score", exact: true })) + .toHaveAttribute("href", "/documents/octo-org/score"); await expect(page.getByText("a".repeat(8))).toHaveCount(0); await page.goto(testChannelPath(room)); let paired = await (await fetch(`${baseURL}/api/connector/pairings/${pairing.id}/claim`, { diff --git a/e2e/implementation-launcher.e2e.ts b/e2e/implementation-launcher.e2e.ts index a369b897e..6c02427ba 100644 --- a/e2e/implementation-launcher.e2e.ts +++ b/e2e/implementation-launcher.e2e.ts @@ -337,7 +337,8 @@ test("Build without a running local agent says how to start one", async ({ seed, await openBuildView(page); let view = buildView(page); await expect(view).toContainText("2 tasks"); - await expect(view.getByText("Start your local agent to build:")).toHaveCount(0); + // The snapshot already knows nobody's agent is connected. + await expect(view.getByText("Start your local agent to build:")).toBeVisible(); await view.getByRole("button", { name: "Build on my laptop", exact: true }).click(); await expect(view.getByText("Start your local agent to build:", { exact: true })).toBeVisible(); await expect(view.locator("code")).toContainText("bun run connector connect"); diff --git a/packages/protocol/implementation.d.ts b/packages/protocol/implementation.d.ts index 5601fdf0a..0b34f2448 100644 --- a/packages/protocol/implementation.d.ts +++ b/packages/protocol/implementation.d.ts @@ -42,7 +42,7 @@ export type ImplementationSnapshot = { build?: BuildRequest; /** The GitHub login of whoever requested `build`, when it is still known. */ startedBy?: string; - /** Whether the viewer has a connected local agent for this repository that is free to build. */ + /** Whether the viewer has a connected local agent for this repository; it may be busy. */ localAgent: boolean; blockers: string[]; lifecycle: Pick; From 8348a61c5b2b2c0b744ba384e454c288b602ffa2 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:35:35 +0100 Subject: [PATCH 04/21] lane: sync run-on-clicker memory --- ...WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md | 12 ++++++++++++ ...BX8VD8B85DTNNDWFW-connections-are-per-person-p.md | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 .lane/memory/apps/server/src/experiments/connections.ts/01M4JHF89WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md create mode 100644 .lane/memory/e2e/experiments.e2e.ts/01M4JHF8ABX8VD8B85DTNNDWFW-connections-are-per-person-p.md diff --git a/.lane/memory/apps/server/src/experiments/connections.ts/01M4JHF89WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md b/.lane/memory/apps/server/src/experiments/connections.ts/01M4JHF89WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md new file mode 100644 index 000000000..5afad058a --- /dev/null +++ b/.lane/memory/apps/server/src/experiments/connections.ts/01M4JHF89WXY4D1JD3785H1TEJ-a-connection-serves-every-do.md @@ -0,0 +1,12 @@ +--- +id: 01M4JHF89WXY4D1JD3785H1TEJ +anchor: method assign +created: 2026-10-10T09:14:28Z +norm: '1' +sig: f8675142fa786e15 +body_hash: 0b2572e2b4c95f54 +raw_hash: 6cfbb54a446f4e21 +lines: 124-126 +--- + +A connection serves every document in its repository. Implementation pickup finds its build through assign()/assigned() (connection -> document of its last queued build), set by the Build POST; run tokens carry their own documentId. Both are in-memory, like connections themselves. diff --git a/.lane/memory/e2e/experiments.e2e.ts/01M4JHF8ABX8VD8B85DTNNDWFW-connections-are-per-person-p.md b/.lane/memory/e2e/experiments.e2e.ts/01M4JHF8ABX8VD8B85DTNNDWFW-connections-are-per-person-p.md new file mode 100644 index 000000000..ef426296e --- /dev/null +++ b/.lane/memory/e2e/experiments.e2e.ts/01M4JHF8ABX8VD8B85DTNNDWFW-connections-are-per-person-p.md @@ -0,0 +1,12 @@ +--- +id: 01M4JHF8ABX8VD8B85DTNNDWFW +anchor: function person +created: 2026-10-10T09:14:28Z +norm: '1' +sig: f9b02acb18645e83 +body_hash: edef4e47aa3087f9 +raw_hash: 9ed184827227bfb3 +lines: 6-8 +--- + +Connections are per person per repository and every e2e test shares octo-org/score, so any test that pairs a connector must sign in as a unique person; a shared 'ana' lets another test's connector pick up the work. From fb8838257f169d940a56025fecb7620ffd0e20f4 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:14 +0100 Subject: [PATCH 05/21] Serialize a run's lifecycle calls and explain refusals to the coding agent Coding agents send start_task, report_pr and complete_task in one parallel batch. Apply them per run in arrival order, briefly retry refusals a later call in the batch may resolve, and answer remaining refusals with the next step. block_task now implicitly starts a queued task. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/tasks/connector.ts | 93 ++++++++++++++++++++++++- apps/server/src/tasks/lifecycle.test.ts | 21 ++++++ apps/server/src/tasks/lifecycle.ts | 5 +- apps/server/src/tasks/routes.test.ts | 43 ++++++++++++ docs/implementation-lifecycle.md | 2 +- 5 files changed, 160 insertions(+), 4 deletions(-) diff --git a/apps/server/src/tasks/connector.ts b/apps/server/src/tasks/connector.ts index 56cc533fc..05308b166 100644 --- a/apps/server/src/tasks/connector.ts +++ b/apps/server/src/tasks/connector.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { Connection, Connections, Grant } from "../experiments/connections"; +import { ExperimentError } from "@chopin/experiment"; import { fail } from "../experiments/service"; import { LIFECYCLE_TOOLS, lifecycleCall } from "../mcp/lifecycle"; import { exclusive, source } from "../plan/service"; @@ -44,13 +45,72 @@ export function implementationSchemas( }; } +/** + * Next steps for a lifecycle refusal a coding agent can fix. `retry` marks refusals that a parallel + * call in the same batch may still resolve, so the server retries them before answering. + */ +const REFUSALS: Record> = { + start_task: { + dependency: { + retry: true, + advice: + "A task this one depends on is not complete. Finish it with report_pr then complete_task, and retry start_task with the same idempotencyKey.", + }, + "task-state": { + advice: + "The task is already in progress or completed. Continue with report_pr and complete_task.", + }, + }, + block_task: { + "task-state": { + advice: + "The task is already blocked or completed. Call start_task to resume a blocked task before blocking it again.", + }, + }, + report_pr: { + "task-state": { + retry: true, + advice: + "The task has not started. Call start_task for it, then retry report_pr with the same idempotencyKey.", + }, + "pull-request": { + advice: + "report_pr needs the pull request's full GitHub URL and a state of open, merged or closed. Fix the arguments and retry.", + }, + }, + complete_task: { + "pull-request": { + retry: true, + advice: + "The task has no pull request yet. Call report_pr for it, wait for that call to finish, then retry complete_task with the same idempotencyKey.", + }, + "task-state": { + retry: true, + advice: + "The task is not in progress. Call start_task (and report_pr) for it, then retry complete_task with the same idempotencyKey.", + }, + }, +}; + export function implementationConnector( withPlan: WithPlan, connections: Connections, documentUrl: (id: string) => Promise, documentExists: (id: string) => Promise, + options: { retryMs?: number; retries?: number } = {}, ) { let tracked = new Set(); + // Lifecycle calls of one run apply in arrival order, one at a time. + let chains = new Map>(); + let serial = (key: string, action: () => Promise): Promise => { + let operation = (chains.get(key) ?? Promise.resolve()).then(action, action); + let settled = operation.catch(() => {}); + chains.set(key, settled); + void settled.then(() => { + if (chains.get(key) === settled) chains.delete(key); + }); + return operation; + }; let pending = async (connection: Connection) => { let documentId = connections.assigned(connection.id); if (!documentId) return undefined; @@ -71,7 +131,7 @@ export function implementationConnector( return undefined; } }; - return { + let api = { track(id: string) { tracked.add(id); }, @@ -85,7 +145,37 @@ export function implementationConnector( let build = await pending(connection); return !!build && ["queued", "starting", "running"].includes(build.state); }, + /** + * Agents often send start_task, report_pr and complete_task in one parallel batch. Serializing + * them per run and briefly retrying order-dependent refusals lets the batch land in order. + */ async call(connection: Connection, grant: Grant, name: string, args: Record) { + let run = grant.run; + if (!run || !LIFECYCLE_TOOLS.some(tool => tool.name === name)) { + return api.invoke(connection, grant, name, args); + } + let retries = options.retries ?? 4; + for (let attempt = 0;; attempt++) { + try { + return await serial(run.id, () => api.invoke(connection, grant, name, args)); + } catch (error) { + let refusal = error instanceof ExperimentError ? REFUSALS[name]?.[error.code] : undefined; + if (!refusal) throw error; + if (refusal.retry && attempt < retries) { + await new Promise(resolve => setTimeout(resolve, options.retryMs ?? 250)); + continue; + } + fail((error as ExperimentError).code, refusal.advice); + } + } + }, + /** One connector call, unserialized. */ + async invoke( + connection: Connection, + grant: Grant, + name: string, + args: Record, + ) { let documentId = grant.run?.documentId ?? connections.assigned(connection.id); if (!documentId) fail("build-forbidden"); tracked.add(documentId); @@ -201,5 +291,6 @@ export function implementationConnector( } }, }; + return api; } export type ImplementationConnector = ReturnType; diff --git a/apps/server/src/tasks/lifecycle.test.ts b/apps/server/src/tasks/lifecycle.test.ts index 415ec8a86..eef024745 100644 --- a/apps/server/src/tasks/lifecycle.test.ts +++ b/apps/server/src/tasks/lifecycle.test.ts @@ -719,6 +719,27 @@ describe("implementation task lifecycle", () => { }); }); + it("starts a queued task implicitly when it is blocked before start_task", async () => { + let module = await lifecycle() as { + transition: (state: any, input: any) => any; + progressFor: (graph: Graph, lifecycle: unknown, execution?: Run) => any; + }; + let state: any = { graph, execution, lifecycle: { history: [] } }; + let result = module.transition(state, { + kind: "block", + runId: execution.id, + taskId: "delivery", + reason: "Needs a product decision.", + idempotencyKey: "block-delivery", + }); + expect(result.kind).toBe("accepted"); + expect(module.progressFor(graph, result.state.lifecycle, execution).tasks[1]).toEqual({ + id: "delivery", + state: "blocked", + blocker: "Needs a product decision.", + }); + }); + it("preserves revision request as an unsuccessful release", async () => { let module = await lifecycle() as { transition: (state: any, input: any) => any; diff --git a/apps/server/src/tasks/lifecycle.ts b/apps/server/src/tasks/lifecycle.ts index d191c5ad7..cf48a40e6 100644 --- a/apps/server/src/tasks/lifecycle.ts +++ b/apps/server/src/tasks/lifecycle.ts @@ -312,13 +312,14 @@ function reduceEvent(state: ReducerState, stored: ProgressEvent): Reduction { }; break; case "block": - if (item.state !== "in_progress") return refused("task-state"); + // A blocker found before start_task implicitly starts the task, so it is never lost. + if (item.state !== "in_progress" && item.state !== "queued") return refused("task-state"); if (!text(stored.reason)) return refused("reason"); next = { id: item.id, state: "blocked", blocker: stored.reason, - ...(item.pullRequest ? { pullRequest: item.pullRequest } : {}), + ...(item.state !== "queued" && item.pullRequest ? { pullRequest: item.pullRequest } : {}), }; break; case "report_pr": { diff --git a/apps/server/src/tasks/routes.test.ts b/apps/server/src/tasks/routes.test.ts index b17cf35ba..a58822079 100644 --- a/apps/server/src/tasks/routes.test.ts +++ b/apps/server/src/tasks/routes.test.ts @@ -237,6 +237,49 @@ test("a paired workspace claims a browser build once and reports through run-sco await Plan.close(context.plan); }); +test("a run's parallel lifecycle batch lands in order and refusals say what to do next", async () => { + let context = await setup(); + let connection = await paired(context); + let built = await (await context.call(context.path, { + planRevision: 0, + graphVersion: 1, + graphRevision: 1, + })).json(); + let claim = await tool(context, connection.token, "claim_implementation_build", { id: built.id }); + await tool(context, connection.token, "report_implementation_build", { + id: built.id, + state: "running", + session: "acp-session", + }); + let pr = "https://github.com/owner/repository/pull/7"; + // The agent sends the whole batch at once; complete_task arrives before report_pr. + let results = await Promise.all([ + tool(context, claim.runToken, "complete_task", { + taskId: "first", + summary: "Done.", + idempotencyKey: "complete-first", + }), + tool(context, claim.runToken, "report_pr", { + taskId: "first", + url: pr, + state: "open", + idempotencyKey: "pr-first", + }), + tool(context, claim.runToken, "start_task", { taskId: "first", idempotencyKey: "start-first" }), + ]); + expect(results.map(result => result.isError)).toEqual([undefined, undefined, undefined]); + let snapshot = await (await context.call(context.path)).json(); + expect(snapshot.lifecycle.activity.tasks[0]).toMatchObject({ state: "completed" }); + let refused = await tool(context, claim.runToken, "start_task", { + taskId: "first", + idempotencyKey: "start-again", + }); + expect(refused.content[0].text).toContain( + "task-state: The task is already in progress or completed", + ); + await Plan.close(context.plan); +}); + test("a build runs only on the clicker's own connection and records who started it", async () => { let context = await setup(); let foreign = await paired(context, context.otherCookie); diff --git a/docs/implementation-lifecycle.md b/docs/implementation-lifecycle.md index 6026f8033..72013f0bb 100644 --- a/docs/implementation-lifecycle.md +++ b/docs/implementation-lifecycle.md @@ -128,7 +128,7 @@ the next reviewed build is a new attempt, not an automatic session replay. | `read_implementation` | Read the approved graph, plan, and repository context by UUID or canonical URL. | | `start_implementation` | Claim that exact graph while reporting the coding agent's repository, branch, and commit. | | `start_task` | Move one dependency-ready task to in progress. | -| `block_task` | Record a task blocker without releasing the graph lock. | +| `block_task` | Record a task blocker without releasing the graph lock; a queued task starts implicitly. | | `report_pr` | Attach an open, merged, or closed pull request to a task. | | `complete_task` | Complete a task after its pull request and summary are recorded. | | `report_verification` | Submit graph-wide review evidence and return failed tasks to work. | From e7ccc5ed442f61a999eba59d87d6e0f347726686 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:10:32 +0100 Subject: [PATCH 06/21] Propose investigations from the slash menu as inline cards Typing / and choosing Investigate opens the research-style brief composer at the caret; submitting creates the investigation and places an id-only, atomic card there. The afterword proposal form and list are gone. Retry now returns settled work to Ready to run under the same id. Co-Authored-By: Claude Opus 5.5 --- ...FGXY4P07EG-investigation-references-sha.md | 8 + apps/server/src/experiments/routes.ts | 8 + apps/server/src/experiments/service.test.ts | 40 +++ apps/server/src/experiments/service.ts | 13 + apps/server/src/main.ts | 19 +- apps/server/src/plan/edit.test.ts | 21 ++ apps/server/src/plan/edit.ts | 11 +- .../src/plan/investigation-projection.test.ts | 87 +++++ apps/server/src/plan/projections.test.ts | 12 + apps/server/src/plan/projections.ts | 26 +- apps/server/src/plan/room.ts | 49 ++- apps/server/src/plan/service.ts | 31 +- apps/web/src/experiments/card.test.tsx | 82 +++-- apps/web/src/experiments/card.tsx | 307 ++++++++---------- apps/web/src/experiments/inline.tsx | 76 ----- apps/web/src/phone-skeleton.css | 2 +- apps/web/src/room-workspace.tsx | 28 +- docs/architecture.md | 5 +- docs/local-experiments.md | 24 +- e2e/connector.e2e.ts | 23 +- e2e/experiments.e2e.ts | 168 ++++------ packages/dialect/src/dialect.test.ts | 15 + packages/dialect/src/dialect.ts | 11 + packages/dialect/src/index.ts | 5 + packages/dialect/src/nodes/investigation.ts | 96 ++++++ packages/dialect/src/registry.test.ts | 3 + packages/dialect/src/registry.ts | 17 + packages/editor/src/index.ts | 3 + packages/editor/src/plan-editor.tsx | 13 +- packages/editor/src/research-draft.ts | 36 +- packages/editor/src/styles.css | 23 +- packages/editor/src/toolbar/index.tsx | 28 +- packages/editor/src/toolbar/research.tsx | 97 ++++-- packages/editor/src/toolbar/slash.test.ts | 9 +- packages/editor/src/toolbar/slash.tsx | 13 +- packages/editor/src/widget-options.ts | 14 + packages/editor/src/widgets/index.ts | 6 +- packages/editor/src/widgets/investigation.tsx | 211 ++++++++++++ packages/editor/src/widgets/research.tsx | 53 ++- skills/local-investigations/SKILL.md | 5 + 40 files changed, 1183 insertions(+), 515 deletions(-) create mode 100644 .lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md create mode 100644 apps/server/src/plan/investigation-projection.test.ts delete mode 100644 apps/web/src/experiments/inline.tsx create mode 100644 packages/dialect/src/nodes/investigation.ts create mode 100644 packages/editor/src/widgets/investigation.tsx diff --git a/.lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md b/.lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md new file mode 100644 index 000000000..287ec3005 --- /dev/null +++ b/.lane/memory/apps/server/src/plan/projections.ts/01M4QJKMX1FHTVXFFGXY4P07EG-investigation-references-sha.md @@ -0,0 +1,8 @@ +--- +id: 01M4QJKMX1FHTVXFFGXY4P07EG +anchor: function newResearchProjections +created: 2026-10-12T08:10:32Z +norm: '1' +--- + +Investigation references share Research's browser authority path: room.apply asks the authorizer per type; plan/service.ts allows add only for an existing record in this document and removal only when not queued/running/publishing. Only Research changes are persisted as researchProjections. diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index 7c1ef553c..54a331dbe 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -237,6 +237,14 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio connections.use(params.id, connection.id); return json(publicInvestigation(result)); }); + route("POST", "/api/documents/:id/experiments/:experiment/retry", async (request, params) => { + await access(await auth.sessions.authenticate(request), params.id, true); + await mutationAllowed(params.id); + z.object({}).strict().parse(await body(request)); + let value = await service.store.get(params.experiment); + if (!value || value.documentId !== params.id) fail("not-found"); + return json(publicInvestigation(await service.retry(value.id))); + }); route("POST", "/api/documents/:id/experiments/:experiment/cancel", async (request, params) => { let { session } = await access(await auth.sessions.authenticate(request), params.id, true); let value = await service.store.get(params.experiment); diff --git a/apps/server/src/experiments/service.test.ts b/apps/server/src/experiments/service.test.ts index c31d81293..1b44619c7 100644 --- a/apps/server/src/experiments/service.test.ts +++ b/apps/server/src/experiments/service.test.ts @@ -83,3 +83,43 @@ test("claims expire, stale workers cannot publish and completion replays atomica expect((await storage.experiments.get(another.id))?.state).toBe("interrupted"); expect(published).not.toContain(channelId); }); + +test("retry returns settled work to its card under the same identity and fences the old claim", async () => { + let storage = new MemoryStorage(); + let { userId, lease } = await userAndChannel(storage); + let documentId = crypto.randomUUID(); + await storage.channels.create({ + id: documentId, + repositoryId: "repo", + repositoryOwner: "org", + repositoryName: "repo", + title: "Experiment", + createdBy: userId, + now: new Date(), + }); + let service = new Experiments(storage.experiments, () => lease); + let draft = await service.create(documentId, userId, "Measure startup"); + await expect(service.retry(draft.id)).resolves.toMatchObject({ state: "requested" }); + let input = { + id: draft.id, + documentId, + requester: userId, + authorizer: userId, + brief: draft.brief, + context: "", + source: { repositoryId: "repo", repository: "org/repo", commit: "a".repeat(40) }, + }; + await service.authorize(draft.id, "connection", input); + let claimed = await service.claim(draft.id, "connection"); + await expect(service.retry(draft.id)).rejects.toThrow("invalid-state"); + await service.stop(draft.id, "failed", "Agent crashed."); + let retried = await service.retry(draft.id); + expect(retried).toMatchObject({ id: draft.id, state: "requested", progress: "" }); + expect(retried.input).toBeUndefined(); + expect(retried.connectionId).toBeUndefined(); + await expect(service.renew(draft.id, "connection", claimed.generation)).rejects.toThrow( + "stale-claim", + ); + await service.authorize(draft.id, "connection", input); + expect((await service.claim(draft.id, "connection")).generation).toBe(claimed.generation + 1); +}); diff --git a/apps/server/src/experiments/service.ts b/apps/server/src/experiments/service.ts index 44c067814..4f82ebb62 100644 --- a/apps/server/src/experiments/service.ts +++ b/apps/server/src/experiments/service.ts @@ -162,6 +162,19 @@ export class Experiments { delete value.candidate; }); } + /** Return settled, unfinished work to its card under the same identity; Run starts it again. */ + retry(id: string) { + return this.mutate(id, value => { + if (value.state === "requested") return; + if (!["failed", "cancelled", "interrupted"].includes(value.state)) fail("invalid-state"); + value.state = "requested"; + value.progress = ""; + value.expiresAt = 0; + delete value.connectionId; + delete value.input; + delete value.candidate; + }); + } async recover(all = false) { for (let value of await this.store.active()) { if (all || value.state !== "queued" && value.expiresAt <= this.now()) { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 3a40f304b..fbddb07bb 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -249,6 +249,20 @@ function placeResearchReference( }); } +/** + * Put a Planner-proposed investigation's card in its open document. Closing waits for Chat + * while holding the document lock, so a host tool skips placement rather than wait behind it. + */ +async function placeInvestigationReference(channelId: string, id: string): Promise { + if (Rooms.get(channelId)?.closing) return false; + return withDocumentLock(channelId, async () => { + let active = Rooms.get(channelId)?.plan; + if (!active || Rooms.get(channelId)?.closing || active.persistence.closing) return false; + await Service.placeInvestigationReference(active, id); + return true; + }); +} + function scheduleResearchRecovery(deferred: number): void { if (deferred === 0 || researchRecoveryTimer || draining) return; researchRecoveryTimer = setTimeout(() => { @@ -443,10 +457,13 @@ function conversation( hash.slice(17, 20) }-${hash.slice(20, 32)}`; let value = await experiments!.service.create(room.id, request.userId, request.brief, id); + let placed = await placeInvestigationReference(room.id, value.id); return { id: value.id, state: value.state, - message: "Open Investigations and authorize Run on my workspace to execute.", + message: placed + ? "Its card is now in the document. A person selects Run on it to execute." + : "The document is closing, so its card was not placed.", }; }, } diff --git a/apps/server/src/plan/edit.test.ts b/apps/server/src/plan/edit.test.ts index 3e66f50cc..3256965b7 100644 --- a/apps/server/src/plan/edit.test.ts +++ b/apps/server/src/plan/edit.test.ts @@ -413,6 +413,27 @@ describe("refusing a batch", () => { expect(room.project(held.document)).toContain("Context."); }); + it("refuses to author, move, or remove an investigation card", async () => { + let card = ''; + let authored = edit.apply(subject, 1, [{ op: "insert", index: 0, source: `${card}\n` }]); + expect(authored).toMatchObject({ ok: false, reason: "invalid" }); + if (!authored.ok && authored.reason === "invalid") { + expect(authored.message).toContain("proposing an investigation"); + } + + let held = await plan(`# Title\n\n${card}\n\nAfter.\n`); + for ( + let operations of [ + [{ op: "delete", index: 1 }], + [{ op: "move", index: 1, to: 0 }], + [{ op: "replace_root", source: "# Replacement\n" }], + ] as edit.Operation[][] + ) { + expect(edit.apply(held, 1, operations)).toMatchObject({ ok: false, reason: "invalid" }); + } + expect(room.project(held.document)).toContain(card); + }); + it("protects inline research nested inside another Planner block", async () => { let held = await plan( '\n' diff --git a/apps/server/src/plan/edit.ts b/apps/server/src/plan/edit.ts index 650a2849d..3f90a03b3 100644 --- a/apps/server/src/plan/edit.ts +++ b/apps/server/src/plan/edit.ts @@ -239,7 +239,7 @@ export function apply(plan: Plan, revision: number, operations: Operation[]): Re }; } -const PROTECTED_PROJECTIONS = new Set(["Questionnaire", "Decision", "Research"]); +const PROTECTED_PROJECTIONS = new Set(["Questionnaire", "Decision", "Research", "Investigation"]); export function replace(plan: Plan, revision: number, nextSource: string): Result { if (revision !== plan.revision) { @@ -725,6 +725,7 @@ const RESERVED_COMPONENTS = new Set([ "Decision", "Note", "Research", + "Investigation", ]); /** Parse an operation's source as a run of top-level blocks. */ @@ -764,6 +765,8 @@ function identify(root: Root): string | undefined { if (RESERVED_COMPONENTS.has(spec.name)) { refused = spec.name === "Research" ? "`Research` is created by starting inline research, not by editing the plan." + : spec.name === "Investigation" + ? "`Investigation` is created by proposing an investigation, not by editing the plan." : spec.name === "Decision" || spec.name === "Note" ? `\`${spec.name}\` is written when the room accepts a comment, not by editing the plan.` : `\`${spec.name}\` is created by asking a question, not by editing the plan.`; @@ -817,7 +820,7 @@ function protects(index: number): string { } function protectsResearch(index: number): string { - return `block ${index} is inline research owned by its request record; ` + return `block ${index} is inline research or an investigation owned by its record; ` + "it cannot be edited, moved, or removed."; } @@ -844,7 +847,9 @@ function decision(node: RootContent): boolean { } function research(node: Nodes): boolean { - if (node.type === "mdxJsxFlowElement" && node.name === "Research") return true; + if ( + node.type === "mdxJsxFlowElement" && (node.name === "Research" || node.name === "Investigation") + ) return true; return "children" in node && node.children.some(child => research(child as Nodes)); } diff --git a/apps/server/src/plan/investigation-projection.test.ts b/apps/server/src/plan/investigation-projection.test.ts new file mode 100644 index 000000000..d35ba1728 --- /dev/null +++ b/apps/server/src/plan/investigation-projection.test.ts @@ -0,0 +1,87 @@ +/** + * An Investigation reference is an atomic decorator that only its record can + * authorize. A browser may add one for an existing record and remove one once + * its work has settled; the server's headless mirror must sync it like any node. + */ + +import { describe, expect, it } from "bun:test"; +import { $createInvestigationNode, InvestigationNode } from "@chopin/dialect"; +import { $getRoot, $nodesOfType } from "lexical"; +import * as Y from "yjs"; + +import { peer } from "../testing/peer"; +import * as room from "./room"; + +const ID = "2b7e0c4a-5f1d-4e2a-9c3b-7d8e9f0a1b2c"; + +async function connected(source: string) { + let document = await room.create(source); + room.mark(document); + let client = peer(); + Y.applyUpdate(client.doc, room.sync(document), "remote"); + await room.settle(); + return { document, client }; +} + +function edit(client: ReturnType, change: () => void) { + let before = Y.encodeStateVector(client.doc); + client.editor.update(change, { discrete: true }); + return Y.encodeStateAsUpdate(client.doc, before); +} + +describe("Investigation references", () => { + it("accept a browser insertion only when the record authorizes it", async () => { + let seen: string[] = []; + let { document, client } = await connected("# Title\n"); + let update = edit(client, () => $getRoot().append($createInvestigationNode(ID))); + let outcome = await room.apply(document, [update], async (id, action, type) => { + seen.push(`${type}:${action}:${id}`); + return true; + }); + expect(outcome).toMatchObject({ ok: true, researchProjections: [] }); + expect(seen).toEqual([`Investigation:add:${ID}`]); + expect(room.project(document)).toBe(`# Title\n\n\n`); + + let refused = await connected("# Title\n"); + let forged = edit(refused.client, () => $getRoot().append($createInvestigationNode(ID))); + expect(await room.apply(refused.document, [forged], async () => false)).toEqual({ + ok: false, + issues: ["protected-projection"], + }); + }); + + it("refuse removal while the record keeps the reference", async () => { + let { document, client } = await connected(`# Title\n\n\n`); + let update = edit(client, () => { + for (let node of $nodesOfType(InvestigationNode)) node.remove(); + }); + expect(await room.apply(document, [update], async () => false)).toEqual({ + ok: false, + issues: ["protected-projection"], + }); + }); + + it("round-trips through the server's headless Lexical and Yjs mirror", async () => { + let source = `# Title\n\nBefore.\n\n\n\nAfter.\n`; + let document = await room.create(source); + expect(room.project(document)).toBe(source); + let client = peer(); + Y.applyUpdate(client.doc, room.sync(document), "remote"); + await room.settle(); + let ids: string[] = []; + client.editor.getEditorState().read(() => { + ids = $nodesOfType(InvestigationNode).map(node => node.getId()); + }); + expect(ids).toEqual([ID]); + let restored = await room.restore( + document.epoch, + Y.encodeStateAsUpdate(document.doc), + room.project(document), + [], + ); + expect(room.project(restored)).toBe(source); + let inserted = room.insertInvestigation(restored, crypto.randomUUID()); + expect(inserted).toBeDefined(); + expect(room.insertInvestigation(restored, ID)).toBeUndefined(); + }); +}); diff --git a/apps/server/src/plan/projections.test.ts b/apps/server/src/plan/projections.test.ts index 7451095c8..754acd5b2 100644 --- a/apps/server/src/plan/projections.test.ts +++ b/apps/server/src/plan/projections.test.ts @@ -78,3 +78,15 @@ test("Research additions and removals require the exact IDs in the correct autho expect(protectProjections(before, after, new Set(["new"]), new Set(["old"]))) .toBeUndefined(); }); + +test("Investigation references follow the same authority sets, by kind", () => { + let before = nodes('\n\n'); + let after = nodes('\n\n'); + expect(newResearchProjections(before, after)).toEqual([]); + expect(newResearchProjections(before, after, "Investigation")).toEqual(["new"]); + expect(removedResearchProjections(before, after, "Investigation")).toEqual(["old"]); + expect(protectProjections(before, after)).toContain("cannot be dropped"); + expect(protectProjections(before, after, new Set(["new"]), new Set(["old"]))).toBeUndefined(); + expect(protectProjections(nodes(''), nodes(''))) + .toContain("cannot be altered"); +}); diff --git a/apps/server/src/plan/projections.ts b/apps/server/src/plan/projections.ts index 212e2aa45..2e2a45b3e 100644 --- a/apps/server/src/plan/projections.ts +++ b/apps/server/src/plan/projections.ts @@ -2,7 +2,11 @@ import { serialize } from "@chopin/dialect/serialize"; import type { RootContent } from "mdast"; -const PROTECTED = new Set(["Questionnaire", "Decision", "Research", "Experiment"]); +const PROTECTED = new Set(["Questionnaire", "Decision", "Research", "Investigation", "Experiment"]); + +/** Record-backed references a browser may add or remove once their record authorizes it. */ +export type Reference = "Research" | "Investigation"; +const REFERENCES = new Set(["Research", "Investigation"]); function collect(nodes: RootContent[]): Map | string { let found = new Map(); @@ -40,22 +44,30 @@ function collect(nodes: RootContent[]): Map - value.type === "Research" && !current.has(id) ? [id] : [] + value.type === type && !current.has(id) ? [id] : [] ); } /** Existing research references removed from a browser batch need terminal request authority. */ -export function removedResearchProjections(base: RootContent[], next: RootContent[]): string[] { +export function removedResearchProjections( + base: RootContent[], + next: RootContent[], + type: Reference = "Research", +): string[] { let current = collect(base); let proposed = collect(next); if (typeof current === "string" || typeof proposed === "string") return []; return [...current].flatMap(([id, value]) => - value.type === "Research" && !proposed.has(id) ? [id] : [] + value.type === type && !proposed.has(id) ? [id] : [] ); } @@ -73,7 +85,7 @@ export function protectProjections( for (let [id, value] of current) { let replacement = proposed.get(id); if (!replacement) { - if (value.type === "Research" && removableResearch.has(id)) continue; + if (REFERENCES.has(value.type) && removableResearch.has(id)) continue; return "Existing Questionnaire, Decision, and Research projections cannot be dropped."; } if (replacement.type !== value.type || replacement.source !== value.source) { @@ -82,7 +94,7 @@ export function protectProjections( } for (let id of proposed.keys()) { if (!current.has(id)) { - if (proposed.get(id)?.type === "Research" && allowedResearch.has(id)) continue; + if (REFERENCES.has(proposed.get(id)!.type) && allowedResearch.has(id)) continue; return "Questionnaire, Decision, and Research projections cannot be authored by rewriting the plan."; } } diff --git a/apps/server/src/plan/room.ts b/apps/server/src/plan/room.ts index b0d9bda92..7f58e88b5 100644 --- a/apps/server/src/plan/room.ts +++ b/apps/server/src/plan/room.ts @@ -20,6 +20,7 @@ import * as Y from "yjs"; import { $createDecisionNode, $createExperimentNode, + $createInvestigationNode, $createPlanNodes, $createResearchNode, $exportPlanTree, @@ -28,6 +29,7 @@ import { assertIntroducedUrls, ExperimentNode, exportPlan, + InvestigationNode, limits, parse, PlanParseError, @@ -58,6 +60,7 @@ import { $nodesOfType, } from "lexical"; +import type { Reference } from "./projections"; import type { ResearchProjectionChange } from "../storage/model"; import type { Binding, Provider } from "@lexical/yjs"; import type { LexicalEditor, LexicalNode } from "lexical"; @@ -356,7 +359,11 @@ export type Applied = export async function apply( target: Document, updates: Uint8Array[], - authorizeResearch?: (id: string, action: "add" | "remove") => Promise, + authorizeResearch?: ( + id: string, + action: "add" | "remove", + type: Reference, + ) => Promise, open = 0, ): Promise { if (updates.length === 0) return { ok: true, seq: target.seq, researchProjections: [] }; @@ -376,22 +383,24 @@ export async function apply( assertIntroducedUrls(before, after); let added = new Set(); let removed = new Set(); - for ( - let [action, ids, allowed] of [ - ["add", newResearchProjections(before, after), added], - ["remove", removedResearchProjections(before, after), removed], - ] as const - ) { - for (let id of ids) { - let authorized = false; - try { - authorized = !!authorizeResearch && await authorizeResearch(id, action); - } catch { - return { ok: false, issues: ["research-reference-unverified"] }; + for (let type of ["Research", "Investigation"] as const) { + for ( + let [action, ids, allowed] of [ + ["add", newResearchProjections(before, after, type), added], + ["remove", removedResearchProjections(before, after, type), removed], + ] as const + ) { + for (let id of ids) { + let authorized = false; + try { + authorized = !!authorizeResearch && await authorizeResearch(id, action, type); + } catch { + return { ok: false, issues: ["research-reference-unverified"] }; + } + if (!authorized) return { ok: false, issues: ["protected-projection"] }; + allowed.add(id); + if (type === "Research") researchProjections.push({ id, action }); } - if (!authorized) return { ok: false, issues: ["protected-projection"] }; - allowed.add(id); - researchProjections.push({ id, action }); } } if (protectProjections(before, after, added, removed)) { @@ -1262,6 +1271,14 @@ export function insertResearch(target: Document, id: string): Mutation | undefin }); } +export function insertInvestigation(target: Document, id: string): Mutation | undefined { + return mutate(target, () => { + if ($nodesOfType(InvestigationNode).some(node => node.getId() === id)) return false; + $getRoot().append($createInvestigationNode(id)); + return true; + }); +} + export function placeExperiment( target: Document, experiment: string, diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 2a3a04084..26a7576e1 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1582,7 +1582,14 @@ async function commit(plan: Plan): Promise { let outcome = await room.apply( plan.document, batch.map(item => item.update), - async (id, action) => { + async (id, action, type) => { + if (type === "Investigation") { + let value = await plan.persistence.storage.experiments.get(id); + if (value && value.documentId !== plan.id) return false; + if (action === "add") return !!value; + // A reference to running work stays until it settles, so its card remains reachable. + return !value || !["queued", "running", "publishing"].includes(value.state); + } let request = await plan.persistence.storage.research.get(plan.id, id); let initial = request?.turns.find(turn => turn.kind === "initial"); let jobId = initial?.answerJobId ?? initial?.evidenceJobId; @@ -2149,6 +2156,28 @@ export function placeResearchReference(plan: Plan, id: string): Promise<"placed" }); } +/** Append an investigation's card to the document; a later edit can move it. */ +export function placeInvestigationReference(plan: Plan, id: string): Promise { + return exclusive(plan, async () => { + if (implementationActive(plan)) throw new ImplementationActiveError(); + let document = await room.restore( + plan.document.epoch, + Y.encodeStateAsUpdate(plan.document.doc), + room.project(plan.document), + [], + ); + document.seq = plan.document.seq; + try { + let mutation = room.insertInvestigation(document, id); + if (mutation) { + await publishStaged(plan, plan.server, plan.id, { ...plan, document }, mutation); + } + } finally { + document.doc.destroy(); + } + }); +} + export function placeExperimentReference( plan: Plan, experiment: string, diff --git a/apps/web/src/experiments/card.test.tsx b/apps/web/src/experiments/card.test.tsx index c38073738..ec8b70943 100644 --- a/apps/web/src/experiments/card.test.tsx +++ b/apps/web/src/experiments/card.test.tsx @@ -4,10 +4,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { initialState } from "@chopin/experiment"; import { performance } from "../../../../packages/experiment/src/fixtures"; import type { PublishedInvestigation } from "@chopin/experiment/records"; -import { InvestigationCard } from "./card"; +import { InvestigationReference, removableInvestigation } from "./card"; import { ExperimentStore } from "./store"; -test("a build lock preserves shared result selections while blocking document mutations", () => { +function setup(state: PublishedInvestigation["state"]) { let store = new ExperimentStore("document"); let item: PublishedInvestigation = { id: "investigation", @@ -15,29 +15,44 @@ test("a build lock preserves shared result selections while blocking document mu revision: 1, brief: "Compare startup approaches", requester: "ana", - state: "completed", + state, generation: 1, expiresAt: 0, createdAt: 0, updatedAt: 0, progress: "", - result: performance, - views: Object.fromEntries(performance.views.map(view => [view.key, initialState(view)])), + views: {}, decisions: [], }; store.values.set(item.id, item); - let markup = renderToStaticMarkup(createElement(InvestigationCard, { + store.items = [{ ...item, decisionCount: 0 }]; + return { store, item }; +} + +function render(store: ExperimentStore, userId = "ana", locked = false) { + return renderToStaticMarkup(createElement(InvestigationReference, { store, - summary: { ...item, decisionCount: 0 }, - userId: "ana", + id: "investigation", + userId, canEdit: true, - locked: true, + locked, + onRemove() {}, })); - expect(markup.match(/]*aria-label="Filter Workload"[^>]*>/)?.[0]) - .not.toContain("disabled"); - expect(markup).toMatch(/]*disabled=""[^>]*>Record decision<\/button>/); - expect(markup).toMatch(/]*disabled=""[^>]*>Insert view in document<\/button>/); - item.state = "running"; +} + +test("a requested investigation offers Run and can be removed", () => { + let { store } = setup("requested"); + let markup = render(store); + expect(markup).toContain('aria-label="Investigation"'); + expect(markup).toContain("Ready to run"); + expect(markup).toMatch(/]*disabled)[^>]*>Run<\/button>/); + expect(markup).toContain('aria-label="Remove investigation"'); + expect(removableInvestigation(store, "investigation")).toBe(true); + expect(render(store, "ana", true)).toMatch(/]*disabled=""[^>]*>Run<\/button>/); +}); + +test("only the person who ran it can stop running work, even under a build lock", () => { + let { store, item } = setup("running"); item.input = { id: item.id, documentId: item.documentId, @@ -47,22 +62,29 @@ test("a build lock preserves shared result selections while blocking document mu authorizer: "ana", source: { repositoryId: "R_score", repository: "octo-org/score", commit: "a".repeat(40) }, }; - markup = renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "ana", - canEdit: true, - locked: true, - })); - expect(markup).toMatch(/]*disabled)[^>]*>Cancel investigation<\/button>/); - let peer = renderToStaticMarkup(createElement(InvestigationCard, { - store, - summary: { ...item, decisionCount: 0 }, - userId: "leo", - canEdit: true, - locked: true, - })); - expect(peer).not.toContain("Cancel investigation"); + expect(render(store, "ana", true)).toMatch(/]*disabled)[^>]*>Stop<\/button>/); + let peer = render(store, "leo", true); + expect(peer).not.toContain(">Stop<"); + expect(peer).not.toContain("Remove investigation"); + expect(removableInvestigation(store, "investigation")).toBe(false); +}); + +test("settled work offers Retry", () => { + for (let state of ["failed", "cancelled", "interrupted"] as const) { + let { store } = setup(state); + expect(render(store)).toMatch(/]*disabled)[^>]*>Retry<\/button>/); + } +}); + +test("a build lock preserves shared result selections while blocking document mutations", () => { + let { store, item } = setup("completed"); + item.result = performance; + item.views = Object.fromEntries(performance.views.map(view => [view.key, initialState(view)])); + let markup = render(store, "ana", true); + expect(markup.match(/]*aria-label="Filter Workload"[^>]*>/)?.[0]) + .not.toContain("disabled"); + expect(markup).toMatch(/]*disabled=""[^>]*>Record decision<\/button>/); + expect(markup).toMatch(/]*disabled=""[^>]*>Insert view in document<\/button>/); }); function investigation(state: PublishedInvestigation["state"]): PublishedInvestigation { diff --git a/apps/web/src/experiments/card.tsx b/apps/web/src/experiments/card.tsx index cc7275b6e..534cfb3ac 100644 --- a/apps/web/src/experiments/card.tsx +++ b/apps/web/src/experiments/card.tsx @@ -1,47 +1,41 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { InvestigationCard, SidecarCard } from "@chopin/editor"; import { ExperimentView } from "@chopin/experiment/react"; import { StaticPlanEditor } from "@chopin/editor/static"; import type { ExperimentStore } from "./store"; import { EvidenceActions } from "./evidence"; -import type { InvestigationState, InvestigationSummary } from "@chopin/experiment/records"; -import { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold"; +import type { InvestigationState, PublishedInvestigation } from "@chopin/experiment/records"; -const WORDS: Record = { - requested: "Ready to run", - queued: "Waiting for your local agent", - running: "Running", - publishing: "Writing up", - completed: "Done", - failed: "Failed", - cancelled: "Cancelled", - interrupted: "Interrupted", -}; +const ACTIVE: InvestigationState[] = ["queued", "running", "publishing"]; +const SETTLED: InvestigationState[] = ["failed", "cancelled", "interrupted"]; -export function InvestigationCard({ store, summary, userId, canEdit, locked }: { +/** Whether a document may drop this investigation's card: only once its work is not in flight. */ +export function removableInvestigation(store: ExperimentStore, id: string): boolean { + let state = store.items.find(item => item.id === id)?.state ?? store.get(id)?.state; + return !!state && !ACTIVE.includes(state); +} + +/** The inline card for one investigation, placed in the document by its id. */ +export function InvestigationReference({ store, id, userId, canEdit, locked, onRemove }: { store: ExperimentStore; - summary: InvestigationSummary; + id: string; userId: string; canEdit: boolean; locked: boolean; + onRemove: () => void; }) { + useSyncExternalStore(store.subscribe, store.snapshot, store.snapshot); let [needsAgent, setNeedsAgent] = useState(false); let [busy, setBusy] = useState(false); let [error, setError] = useState(""); - let [loadFailed, setLoadFailed] = useState(false); - let [loading, setLoading] = useState(!store.get(summary.id)); - let item = store.get(summary.id); - // Work that still needs a person opens; a finished result folds to its brief. - let body = useUnfold(summary.state !== "completed", "card"); - let load = async () => { - setLoading(true); - await store.load(summary.id); - if (loadFailed && store.get(summary.id)) void store.refresh(); - setLoadFailed(!store.get(summary.id)); - setLoading(false); - }; + let summary = store.items.find(item => item.id === id); + let item = store.get(id); + let revision = summary?.revision; useEffect(() => { - if (!store.get(summary.id)) void load(); - }, [store, summary.id]); + if (!store.get(id) || revision !== undefined && store.get(id)!.revision < revision) { + void store.load(id); + } + }, [store, id, revision]); async function act(action: () => Promise) { setBusy(true); setError(""); @@ -56,148 +50,125 @@ export function InvestigationCard({ store, summary, userId, canEdit, locked }: { setBusy(false); } } + let state = summary?.state ?? item?.state; + let brief = summary?.brief ?? item?.brief; + if (!state || !brief) { + return ( + +

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

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

- {item - ? ( - - {summary.brief} - - ) - : {summary.brief}} -

-

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

- {error &&

{error}

} - {!item && loading &&

Loading investigation…

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

Start your local agent to run this:

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

Open the link it prints, then press Run again.

-
- )} -
- )} - {canEdit && ["queued", "running", "publishing"].includes(item.state) - && item.input?.authorizer === userId && ( - - )} - {canEdit && ["failed", "cancelled", "interrupted"].includes(item.state) && ( - - )} - {item.result && ( - <> - - - - {item.result.views.map(view => ( -
- - void act(async () => { - try { - await store.action(item.id, "state", { - view: view.key, - patch: { - mutationId: crypto.randomUUID(), - expected: { [field]: item.views[view.key].fields[field].revision }, - set: { [field]: values }, - }, - }); - } finally { - await store.load(item.id); - } - })} - /> - - -
- ))} -

{item.result.provenance.environment}

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

{value}

- ))} - {item.result.evidence.map(value => ( - -
{value.text}
-
- ))} - - )} + + ); +} + +function AgentHint() { + return ( +
+

Start your local agent to run this:

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

Open the link it prints, then press Run again.

+
+ ); +} + +function Result({ item, store, busy, canEdit, locked, act }: { + item: PublishedInvestigation; + store: ExperimentStore; + busy: boolean; + canEdit: boolean; + locked: boolean; + act: (action: () => Promise) => Promise; +}) { + let result = item.result!; + return ( +
+
+ Report + +
+ {result.views.map(view => ( +
+ + void act(async () => { + try { + await store.action(item.id, "state", { + view: view.key, + patch: { + mutationId: crypto.randomUUID(), + expected: { [field]: item.views[view.key].fields[field].revision }, + set: { [field]: values }, + }, + }); + } finally { + await store.load(item.id); + } + })} + /> + + - - )} -
+
+ ))} +

{result.provenance.environment}

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

{value}

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

{error || store.error}

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