diff --git a/apps/connector/src/workspace.test.ts b/apps/connector/src/workspace.test.ts index 1d96c65b8..2f8e440d2 100644 --- a/apps/connector/src/workspace.test.ts +++ b/apps/connector/src/workspace.test.ts @@ -90,6 +90,28 @@ async function scoreCheckout() { return { root, state, source }; } +test("a run whose commit can't be fetched fails naming the commit, branch and repository", async () => { + let { root, state, source } = await scoreCheckout(); + try { + let missing = { ...source, commit: "f".repeat(40), branch: "plans/one" }; + // GitHub is sent to a local path, which the connector's git never fetches from: no network. + let env = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.${join(state, "nowhere")}.insteadOf`, + GIT_CONFIG_VALUE_0: "https://github.com/", + }; + await expect(prepareWorkspace(root, state, { id: "f1", source: missing }, 1, undefined, env)) + .rejects.toThrow( + `Couldn't fetch commit ${ + "f".repeat(40) + } (plans/one) of octo-org/score to start from (fatal:`, + ); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(state, { recursive: true, force: true }); + } +}); + test("a run isn't started in a checkout whose git directory an agent gave a commondir: it is removed, and the run goes on once it is asked again", async () => { let { root, state, source } = await scoreCheckout(); try { diff --git a/apps/connector/src/workspace.ts b/apps/connector/src/workspace.ts index 5304c6abb..ddcf8d2a8 100644 --- a/apps/connector/src/workspace.ts +++ b/apps/connector/src/workspace.ts @@ -652,6 +652,17 @@ export async function prepareWorkspace( cwd: root, timeout: 120_000, env: { ...process.env, ...env, GIT_TERMINAL_PROMPT: "0" }, + }).catch(error => { + let lines = String((error as { stderr?: unknown }).stderr ?? "").trim().split("\n"); + let detail = redacted( + lines.find(line => line.startsWith("fatal:")) ?? (lines.at(-1) || (error as Error).message), + ); + throw new Error( + `Couldn't fetch commit ${input.source.commit}${ + input.source.branch ? ` (${input.source.branch})` : "" + } of ${input.source.repository} to start from (${detail}).`, + { cause: error }, + ); }); } let path = join(directory, "runs", attempt); diff --git a/apps/server/src/agent/tools.ts b/apps/server/src/agent/tools.ts index e4f7d171b..f4a3d8d1b 100644 --- a/apps/server/src/agent/tools.ts +++ b/apps/server/src/agent/tools.ts @@ -28,6 +28,7 @@ import { limits, ULID } from "@chopin/dialect"; import { READING_IN_PARTS } from "../document-reading"; import { readingModelOutput } from "./reading-output"; import { implementationGraphs, implementationReadiness } from "../tasks/plan-graphs"; +import { planSourceFor } from "../tasks/plan-source"; import { documentIdentity, implementationActive } from "../plan/service"; import { childTools, Refusal, shielded } from "./child-tools"; import { experimentTools } from "./experiment-tools"; @@ -700,6 +701,8 @@ export const documentTools = { description: "Read the current plan revision and implementation graph before drafting or " + "revising tasks. The returned plan_revision and graph_revision are required by " + "edit_implementation_graph; a newer plan or graph refuses the whole edit. " + + "plan_source, when present, is the pushed commit holding the plan's files that a " + + "build of this revision starts from. " + "When needs_revision is true, the graph is stale even if approved. Revise it " + "against plan_revision before calling it ready; replace an unchanged task if its content remains valid.", inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }), @@ -707,6 +710,7 @@ export const documentTools = { execute: (_raw, { context: { room: context } }) => answer("read_implementation_graph", () => { let version = context.plan.graph?.versions.at(-1); + let planSource = planSourceFor(context.plan.planSources, context.plan.revision); return { plan_revision: context.plan.revision, source: edit.source(context.plan), @@ -714,6 +718,7 @@ export const documentTools = { graph_plan_revision: version?.planRevision, needs_revision: !!version && version.planRevision !== context.plan.revision, graph: context.plan.graph, + ...(planSource ? { plan_source: planSource } : {}), }; }), }), diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index 12e510b95..e252beabe 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -45,6 +45,7 @@ import type { HostedCaller } from "../mcp/hosted"; import type { GitHubApp } from "../github/app"; import type { About, Connection } from "./connections"; import type { Source } from "@chopin/experiment"; +import type { PlanSource } from "@chopin/protocol/implementation"; import { PairingKeys } from "./pairing-keys"; import { Experiments, fail } from "./service"; import { datasetCsv } from "./export"; @@ -1836,6 +1837,19 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio throw error; } }, + hasCommit: async (owner, name, sha) => { + let github = auth.github; + if (!github.hasCommit) return false; + try { + return await auth.machines.use( + machine, + token => github.hasCommit!(token, owner, name, sha), + ); + } catch (error) { + if (error instanceof MachineError) return false; + throw error; + } + }, // A Planner serves its document and the document's children; a run, its document. allows: value => value.repositoryId === channel.repositoryId @@ -2639,11 +2653,17 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio return channel; } /** - * The checkout a connection's work on a document starts from: the one it said it has of the - * document's repository, else the head of the repository's default branch on GitHub, read with - * the machine's own authorization, which its connector clones or fetches. + * The checkout a connection's work on a document starts from: the plan source's commit when the + * Planner pushed the plan's files (`plan`), else the one the connection said it has of the + * document's repository, else the head of the repository's default branch on GitHub. GitHub is + * read with the machine's own authorization, as its connector clones or fetches; a plan source + * it can't find there refuses the work with `plan-source-unavailable`. */ - async function sourceFor(connection: Connection, documentId: string): Promise { + async function sourceFor( + connection: Connection, + documentId: string, + plan?: PlanSource, + ): Promise { let channel = await auth.storage.channels.get(documentId); if (!channel) fail("not-found"); let repository = { @@ -2652,8 +2672,8 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio name: channel.repositoryName, }; let checkout = checkoutOf(connection, repository.id); - if (checkout) return checkout; - if (!connection.anyRepository) fail("repository-forbidden"); + if (checkout && !plan) return checkout; + if (!checkout && !connection.anyRepository) fail("repository-forbidden"); let caller = await machineCaller( await machineRefusal(() => auth.machines.current(connection.machineId)), ); @@ -2662,6 +2682,25 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio ); if (!found || found.id !== repository.id) fail("repository-forbidden"); let github = auth.github; + if (plan) { + let present = github.hasCommit + && await caller.use(token => + github.hasCommit!(token, repository.owner, repository.name, plan.commit) + ); + if (!present) { + fail( + "plan-source-unavailable", + `The plan's commit ${plan.commit.slice(0, 7)} on ${plan.branch} isn't in ` + + `${found.fullName} on GitHub.`, + ); + } + return { + repositoryId: repository.id, + repository: found.fullName, + commit: plan.commit, + branch: plan.branch, + }; + } if (!github.branchHead) fail("no-workspace"); let commit = await caller.use(token => github.branchHead!(token, repository.owner, repository.name, found.defaultBranch) diff --git a/apps/server/src/github/client.ts b/apps/server/src/github/client.ts index 0ad41bf86..6b2144ddb 100644 --- a/apps/server/src/github/client.ts +++ b/apps/server/src/github/client.ts @@ -165,6 +165,8 @@ export interface GitHub { ): Promise; /** The commit a repository's branch points at; optional for GitHubs that never start work. */ branchHead?(token: string, owner: string, name: string, branch: string): Promise; + /** Whether a repository on GitHub has a commit; optional for GitHubs that never start work. */ + hasCommit?(token: string, owner: string, name: string, sha: string): Promise; /** The human collaborators who can read a repository; optional where collaborators are not listed. */ repositoryCollaborators?( token: string, @@ -709,6 +711,19 @@ export class GitHubClient implements GitHub, GitHubReferenceSource { return sha; } + async hasCommit(token: string, owner: string, name: string, sha: string): Promise { + let url = new URL( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits/${ + encodeURIComponent(sha) + }`, + this.#endpoints.api, + ); + // GitHub answers 422 for a SHA it has no commit for, and 404 for a repository it hides. + let response = await this.#request(url, token, undefined, [404, 422]); + if (!response.ok) return false; + return record(await body(response))?.sha === sha; + } + /** * The files of a branch: the blobs of its tree, as GitHub lists them, and whether it cut the * list short. A branch name is encoded as one path segment. @@ -943,7 +958,13 @@ export class GitHubClient implements GitHub, GitHubReferenceSource { return body(await this.#request(new URL(path, this.#endpoints.api), token)); } - async #request(url: URL, token: string, ifNoneMatch?: string): Promise { + async #request( + url: URL, + token: string, + ifNoneMatch?: string, + /** Statuses returned to the caller rather than thrown. */ + answered: number[] = [], + ): Promise { let response: Response; try { let headers = new Headers({ @@ -961,7 +982,9 @@ export class GitHubClient implements GitHub, GitHubReferenceSource { } catch { throw new GitHubError("GitHub API is unavailable"); } - if (response.status === 304 && ifNoneMatch) return response; + if (response.status === 304 && ifNoneMatch || answered.includes(response.status)) { + return response; + } if (!response.ok) { let rateLimited = await isRateLimited(response); let status = rateLimited diff --git a/apps/server/src/mcp.ts b/apps/server/src/mcp.ts index b2f451bfd..7da0063a0 100644 --- a/apps/server/src/mcp.ts +++ b/apps/server/src/mcp.ts @@ -22,6 +22,7 @@ import { IMAGE_TYPES } from "./images/format"; import { isLifecycleTool, LIFECYCLE_TOOLS, lifecycleCall } from "./mcp/lifecycle"; import { normalizedTitle } from "./channels/title"; import { documentText, NOTE_BYTES, READING_IN_PARTS, ReadRangeError } from "./document-reading"; +import { planSourceSchema } from "./tasks/plan-source"; import type { Brief, CreateDocumentInput } from "./mcp/create"; import type { UpdateDocument } from "./mcp/update"; @@ -69,6 +70,8 @@ export type Implementation = { history: ImplementationLifecycle["history"]; /** Decisions still open, for the agent to build on its best judgment; absent when none is. */ unapprovedDecisions?: NonNullable>["unapprovedDecisions"]; + /** Where the graph's plan revision's files were pushed, which a build of it starts from. */ + planSource?: { revision: number; branch: string; commit: string }; }; export type ImplementationInput = { @@ -182,6 +185,21 @@ export type InvokePlanner = { >; }; +export type RecordPlanSourceInput = { + id: string; + revision: number; + branch: string; + commit: string; +}; + +export type RecordPlanSource = { + record(caller: Caller, input: RecordPlanSourceInput): Promise< + | { kind: "recorded"; source: { revision: number; branch: string; commit: string } } + | { kind: "revision-conflict"; revision: number } + | { kind: "unavailable-commit" | "archived" | "forbidden" | "unavailable" } + >; +}; + export type UploadImage = { upload( caller: Caller, @@ -213,6 +231,7 @@ export type McpOptions = { restore?: RestoreDocument; invoke?: InvokePlanner; upload?: UploadImage; + planSources?: RecordPlanSource; implementations?: Implementations; sessions?: McpSessions; }; @@ -680,6 +699,71 @@ export const TOOLS: Tool[] = [ ], }, }, + { + name: "record_plan_source", + description: "Record where a document revision's files are on GitHub: a branch you pushed " + + "to the document's repository and the commit on it that holds them. A build of that " + + "revision, or a later one with no newer record, starts from this commit instead of the " + + "builder's checkout. Push the commit first: Chopin checks GitHub has it. Recording " + + "another commit for the same revision replaces it.", + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + maxLength: MAX_DOCUMENT_LOCATOR_LENGTH, + pattern: "\\S", + }, + revision: { + type: "integer", + minimum: 0, + description: "The document revision the files belong to; at most its current one.", + }, + branch: { + type: "string", + minLength: 1, + maxLength: 255, + description: "A branch name, without refs/heads/.", + }, + commit: { type: "string", pattern: "^[0-9a-f]{40}$" }, + }, + required: ["id", "revision", "branch", "commit"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + oneOf: [ + { + type: "object", + properties: { + recorded: { type: "boolean", const: true }, + revision: { type: "integer", minimum: 0 }, + branch: { type: "string" }, + commit: { type: "string" }, + }, + required: ["recorded", "revision", "branch", "commit"], + additionalProperties: false, + }, + { + type: "object", + properties: { + code: { type: "string", const: "revision-conflict" }, + revision: { type: "integer", minimum: 0 }, + }, + required: ["code", "revision"], + additionalProperties: false, + }, + outcome([ + "invalid-plan-source", + "plan-source-unavailable", + "document-archived", + "document-unavailable", + "repository-forbidden", + ]), + ], + }, + }, { name: "read_implementation", description: @@ -927,6 +1011,7 @@ function serviceInstructions(tools: Tool[]): string | undefined { || tool.name === "restore_document" || tool.name === "invoke_planner" || tool.name === "upload_image" + || tool.name === "record_plan_source" ); let implementation = tools .filter(tool => @@ -999,6 +1084,7 @@ export function handler( && (tool.name !== "restore_document" || restoring) && (tool.name !== "invoke_planner" || options.invoke) && (tool.name !== "upload_image" || options.upload) + && (tool.name !== "record_plan_source" || options.planSources) && (!["read_implementation", "start_implementation"].includes(tool.name) || options.implementations) && (!isLifecycleTool(tool.name) || options.implementations?.reportLifecycle) @@ -1268,6 +1354,40 @@ export function handler( : "document-unavailable", }, true)); } + if (tool.name === "record_plan_source" && options.planSources) { + let { id, revision, branch, commit, ...extra } = tool.arguments; + if ( + Object.keys(extra).length || !isLocator(id) || !Number.isSafeInteger(revision) + || (revision as number) < 0 || typeof branch !== "string" + || typeof commit !== "string" + ) { + return notification + ? undefined + : error( + call.id, + -32602, + "record_plan_source requires an id, revision, branch and commit", + ); + } + let source = planSourceSchema.safeParse({ revision, branch, commit }); + if (!source.success) return respond(text({ code: "invalid-plan-source" }, true)); + let outcome = await options.planSources.record(caller, { id, ...source.data }); + if (outcome.kind === "recorded") { + return respond(text({ recorded: true, ...outcome.source })); + } + if (outcome.kind === "revision-conflict") { + return respond(text({ code: outcome.kind, revision: outcome.revision }, true)); + } + return respond(text({ + code: outcome.kind === "unavailable-commit" + ? "plan-source-unavailable" + : outcome.kind === "archived" + ? "document-archived" + : outcome.kind === "forbidden" + ? "repository-forbidden" + : "document-unavailable", + }, true)); + } if (tool.name === "read_implementation") { if (Object.keys(tool.arguments).length !== 1 || !isLocator(tool.arguments.id)) { return notification diff --git a/apps/server/src/mcp/documents.test.ts b/apps/server/src/mcp/documents.test.ts index 209c482f0..9d536401a 100644 --- a/apps/server/src/mcp/documents.test.ts +++ b/apps/server/src/mcp/documents.test.ts @@ -68,6 +68,7 @@ describe("the MCP document protocol", () => { restore: { restore: unavailable }, invoke: { invoke: async () => ({ kind: "refused", code: "planner-unavailable" }) }, upload: { upload: unavailable }, + planSources: { record: unavailable }, implementations: { readImplementation: async () => undefined, startImplementation: unavailable, diff --git a/apps/server/src/mcp/hosted.test.ts b/apps/server/src/mcp/hosted.test.ts index 1b3319a95..70955423c 100644 --- a/apps/server/src/mcp/hosted.test.ts +++ b/apps/server/src/mcp/hosted.test.ts @@ -70,6 +70,8 @@ function createdDocument(id: string) { class GitHubBoundary implements GitHub { readonly userTokens: string[] = []; readonly repositoryCalls: Array<{ token: string; owner: string; name: string }> = []; + /** The commits GitHub has, in every repository. */ + readonly commits = new Set(); accessible = true; repositoryValue: Repository = { id: "R_score", @@ -126,6 +128,10 @@ class GitHubBoundary implements GitHub { throw new Error("installation-gated access must not be used by MCP"); } + async hasCommit(_token: string, _owner: string, _name: string, sha: string): Promise { + return this.commits.has(sha); + } + invalidate(): void {} private value(owner: string, name: string): Repository { @@ -2114,4 +2120,45 @@ describe("the hosted MCP adapter", () => { await context.storage.mcpSessions.deleteIdle(new Date(context.now.getTime() + 1)); expect(await code(await call(restarted, start, session))).toBe("session-required"); }); + + it("records where a revision's files were pushed, once checked on GitHub, and keeps it", async () => { + let context = setup(); + context.github.repositoryValue.permissions.push = true; + let opened = await plan(context); + await Service.close(opened.plan); + let revision = 999; + let mcp = handler(hosted(context.auth, { lease: () => opened.lease })); + let record = (args: Record) => + call(mcp, { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "record_plan_source", + arguments: { id: opened.channel.id, revision, ...args }, + }, + }); + let pushed = { branch: "plans/release", commit: "c".repeat(40) }; + context.github.commits.add(pushed.commit); + + expect(await code(await record({ ...pushed, commit: "abc" }))).toBe("invalid-plan-source"); + expect(await code(await record({ ...pushed, branch: "a..b" }))).toBe("invalid-plan-source"); + expect(await code(await record({ ...pushed, commit: "d".repeat(40) }))) + .toBe("plan-source-unavailable"); + // A revision the document hasn't reached is refused with the one it has. + let conflict = (await (await record(pushed)).json()).result.structuredContent; + expect(conflict).toEqual({ code: "revision-conflict", revision: expect.any(Number) }); + revision = conflict.revision; + let recorded = await (await record(pushed)).json(); + expect(recorded.result.isError).toBeUndefined(); + expect(recorded.result.structuredContent).toEqual({ recorded: true, revision, ...pushed }); + // Recording it again answers the same. + expect((await (await record(pushed)).json()).result.structuredContent) + .toEqual(recorded.result.structuredContent); + let stored = await context.storage.collaboration.load(opened.channel.id, context.now); + expect((await Service.readStored(stored!)).planSources).toEqual([{ revision, ...pushed }]); + + context.github.repositoryValue.permissions.push = false; + expect(await code(await record(pushed))).toBe("repository-forbidden"); + }); }); diff --git a/apps/server/src/mcp/hosted.ts b/apps/server/src/mcp/hosted.ts index 0a8bf9672..b18a6e047 100644 --- a/apps/server/src/mcp/hosted.ts +++ b/apps/server/src/mcp/hosted.ts @@ -21,6 +21,7 @@ import { claimImplementation, reportImplementationLifecycle } from "../tasks/pla import { StorageError } from "../storage/errors"; import { implementationLifecycle } from "../tasks/lifecycle"; import { unapprovedContext } from "../tasks/open-decisions"; +import { planSourceFor, withPlanSource } from "../tasks/plan-source"; import { imagePath } from "../images/format"; import type { Server } from "bun"; @@ -34,6 +35,7 @@ import type { ImplementationInput, InvokePlannerError, McpOptions, + RecordPlanSourceInput, RenameDocumentInput, } from "../mcp"; import type { UpdateClient, UpdateDocumentInput } from "./update"; @@ -42,6 +44,7 @@ import type { ChannelArchiveResult, ChannelRecord, Lease, StoredChannel } from " import type { ClaimResult, Run } from "../tasks/graphs"; import type { SocketData } from "../wire"; import type { VisualAdmission } from "../agent/visual-admission"; +import type { PlanSource } from "../tasks/plan-source"; export type HostedCaller = { oauthToken: string; @@ -53,6 +56,8 @@ export type HostedCaller = { */ launch?: { repository(owner: string, name: string): Promise; + /** Whether the repository on GitHub has the commit, read with the machine's own grant. */ + hasCommit(owner: string, name: string, sha: string): Promise; allows(channel: ChannelRecord): boolean; }; }; @@ -172,9 +177,14 @@ function exposed( activity: lifecycle?.activity, history: lifecycle?.history ?? [], ...unapprovedContext(state.records ?? []), + ...withSource(planSourceFor(state.planSources ?? [], version.planRevision)), }; } +function withSource(planSource: PlanSource | undefined) { + return planSource ? { planSource } : {}; +} + function claimResult(value: ClaimResult) { return value.kind === "started" ? { kind: "started" as const, run: value.run } @@ -876,6 +886,40 @@ export function hosted( }, ...(persistence ? { + planSources: { + async record(caller: HostedCaller, input: RecordPlanSourceInput) { + let access = await writableChannel(caller, input.id); + if (access.kind !== "allowed") return access; + let { channel } = access; + if (channel.archivedAt) return { kind: "archived" as const }; + let { revision, branch, commit } = input; + let owner = channel.repositoryOwner; + let name = channel.repositoryName; + let present = caller.launch + ? await caller.launch.hasCommit(owner, name, commit) + : await auth.github.hasCommit?.(caller.oauthToken, owner, name, commit); + if (!present) return { kind: "unavailable-commit" as const }; + return serializeDocument(channel.id, () => + withPlan(channel.id, async plan => { + if (revision > plan.revision) { + return { kind: "revision-conflict" as const, revision: plan.revision }; + } + let source = { revision, branch, commit }; + let next = withPlanSource(plan.planSources, source); + if (next.changed) { + let previous = plan.planSources; + plan.planSources = next.sources; + try { + await Plan.persistExclusive(plan); + } catch (err) { + plan.planSources = previous; + throw err; + } + } + return { kind: "recorded" as const, source }; + })); + }, + }, implementations: { async readImplementation(caller: HostedCaller, id: string) { let located = await locatedChannel(caller, id); @@ -893,6 +937,7 @@ export function hosted( ...(live.graph ? { graph: live.graph } : {}), ...(live.execution ? { execution: live.execution } : {}), lifecycle: live.lifecycle, + planSources: live.planSources, })); } let stored = await auth.storage.collaboration.load(channel.id, auth.clock()); diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 36ff3f721..b33c2e05c 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -8,6 +8,7 @@ import { withDocumentTitle, } from "@chopin/dialect"; import { locksEditing, releaseEndedRun, restoreBuilds, restoreLive } from "../tasks/builds"; +import { restorePlanSources } from "../tasks/plan-source"; import { announceImplementation, implementationStatus, @@ -81,6 +82,7 @@ import type { StorageAdapter } from "../storage/port"; import type { PendingCardAction } from "../questions/card-actions"; import type { ClaimInput, ClaimResult, Graph, Run } from "../tasks/graphs"; import type { LiveBuild } from "../tasks/builds"; +import type { PlanSource } from "../tasks/plan-source"; import type { Lifecycle, LifecycleInput, LifecycleResult } from "../tasks/lifecycle"; /** Updates are grouped for this long before being applied together. */ @@ -317,6 +319,8 @@ export type Plan = { lifecycle: Lifecycle; /** The finished build this living document was last delivered from. */ live?: LiveBuild; + /** Where recent revisions' files were pushed, oldest first; a build starts from one. */ + planSources: PlanSource[]; /** * A one-click Build plan waiting for its tasks, kept until the build starts or is cancelled so * the click outlives a decision the draft raised and a reload. In memory only (prototype). @@ -365,6 +369,7 @@ type Sidecar = { execution?: Run; lifecycle?: Lifecycle; live?: LiveBuild; + planSources?: PlanSource[]; mcpUpdates?: McpUpdateRecord[]; questions: Questions.Record[]; openQuestions: Questions.StoredOpen[]; @@ -398,6 +403,7 @@ function state(plan: Plan, chat: ChatView = plan.chat): Sidecar { ? { lifecycle: plan.lifecycle } : {}), ...(plan.live ? { live: plan.live } : {}), + ...(plan.planSources.length ? { planSources: plan.planSources } : {}), ...(plan.mcpUpdates.length > 0 ? { mcpUpdates: plan.mcpUpdates } : {}), questions: [...plan.records.values()], openQuestions: Questions.dump(plan.questions), @@ -602,6 +608,7 @@ function restoredState( if (Object.hasOwn(item, "execution")) expected.push("execution"); if (Object.hasOwn(item, "lifecycle")) expected.push("lifecycle"); if (Object.hasOwn(item, "live")) expected.push("live"); + if (Object.hasOwn(item, "planSources")) expected.push("planSources"); if (Object.hasOwn(item, "mcpUpdates")) expected.push("mcpUpdates"); if (Object.hasOwn(item, "conversationPlan")) expected.push("conversationPlan"); if (Object.hasOwn(item, "conversationPlanRetries")) expected.push("conversationPlanRetries"); @@ -660,6 +667,7 @@ function restoredState( let live = Object.hasOwn(item, "live") ? restoreLive(item.live, builds, lifecycle, graph, item.revision) : undefined; + let planSources = restorePlanSources(item.planSources, item.revision); let questions = objects(item.questions, "question record"); let records = questions.map(question => Questions.normalizeRecord(question)); let openQuestions = objects(item.openQuestions, "open questionnaire"); @@ -812,6 +820,7 @@ function restoredState( ...(execution ? { execution } : {}), ...(lifecycle ? { lifecycle } : {}), ...(live ? { live } : {}), + ...(planSources.length ? { planSources } : {}), ...(mcpUpdates.length > 0 ? { mcpUpdates } : {}), questions: records, openQuestions: openQuestions as unknown as Questions.StoredOpen[], @@ -1546,6 +1555,7 @@ export async function readStored( execution?: Run; lifecycle?: Lifecycle; records?: Questions.Record[]; + planSources?: PlanSource[]; }> { let restored = await restoreHosted(loaded.channel.id, loaded); try { @@ -1559,6 +1569,7 @@ export async function readStored( ...(restored.sidecar.builds ? { builds: restored.sidecar.builds } : {}), ...(restored.sidecar.execution ? { execution: restored.sidecar.execution } : {}), ...(restored.sidecar.lifecycle ? { lifecycle: restored.sidecar.lifecycle } : {}), + ...(restored.sidecar.planSources ? { planSources: restored.sidecar.planSources } : {}), }; } finally { restored.document.doc.destroy(); @@ -1614,6 +1625,7 @@ export async function open( execution: sidecar.execution, lifecycle: sidecar.lifecycle ?? { history: [] }, ...(sidecar.live ? { live: sidecar.live } : {}), + planSources: sidecar.planSources ?? [], claiming: false, queue: [], timer: undefined, diff --git a/apps/server/src/tasks/builds.test.ts b/apps/server/src/tasks/builds.test.ts index 01b3bcf2c..97674c4c1 100644 --- a/apps/server/src/tasks/builds.test.ts +++ b/apps/server/src/tasks/builds.test.ts @@ -52,6 +52,36 @@ test("approval queues the reviewed graph once and pickup survives reopening", as await Plan.close(reopened); }); +test("a build keeps the plan source it started from across a reopen, and one recorded since its press is refused", async () => { + let { plan, backend, server } = await prepared(); + let planSource = { revision: 0, branch: "plans/one", commit: "c".repeat(40) }; + let input = { + planRevision: 0, + graphVersion: 1, + graphRevision: 1, + user: "U_test", + connectionId: "laptop", + machineId: "M_laptop", + repositoryId: "R_test", + checkout: { + repository: checkout.repository, + branch: planSource.branch, + commit: planSource.commit, + }, + }; + plan.planSources = [planSource]; + // The press was checked before the Planner recorded it: that press is refused. + await expect(queueBuild(plan, { ...input, checkout })).rejects.toThrow("plan source has changed"); + let queued = await queueBuild(plan, { ...input, planSource }); + expect(queued.planSource).toEqual(planSource); + let id = plan.id; + await Plan.close(plan); + let reopened = await Plan.open(id, backend, server); + expect(reopened.planSources).toEqual([planSource]); + expect(reopened.builds[0]!.planSource).toEqual(planSource); + await Plan.close(reopened); +}); + test("a stale graph approval or an accepted comment cannot queue a build", async () => { let { plan } = await prepared(); let input = { diff --git a/apps/server/src/tasks/builds.ts b/apps/server/src/tasks/builds.ts index 0efd33c3a..615022ce2 100644 --- a/apps/server/src/tasks/builds.ts +++ b/apps/server/src/tasks/builds.ts @@ -13,6 +13,7 @@ import { transition, } from "./lifecycle"; import { announceImplementation } from "./notifications"; +import { planSourceFor, planSourceSchema } from "./plan-source"; import { drain, exclusive, persistExclusive, source } from "../plan/service"; import { broadcast } from "../wire"; import type { Plan } from "../plan/service"; @@ -25,6 +26,7 @@ import type { CheckoutContext, LiveRebuild, LiveSnapshot, + PlanSource, } from "@chopin/protocol/implementation"; export let checkoutSchema = z.object({ @@ -44,6 +46,7 @@ let buildSchema = z.object({ machineService: z.literal(true).optional(), repositoryId: z.string().min(1).max(128), checkout: checkoutSchema, + planSource: planSourceSchema.optional(), planRevision: z.number().int().nonnegative(), graphVersion: z.number().int().positive(), graphRevision: z.number().int().positive(), @@ -233,6 +236,8 @@ type BuildInput = { machineService?: true; repositoryId: string; checkout: CheckoutContext; + /** The plan source `checkout` starts from, as it was when the build was asked for. */ + planSource?: PlanSource; }; export async function queueBuild(plan: Plan, input: BuildInput): Promise { @@ -247,6 +252,13 @@ export async function queueBuild(plan: Plan, input: BuildInput): Promise c.repeat(40); + +test("a plan source's branch follows git's branch name rules", () => { + for (let name of ["main", "chopin/plan-1", "plans/a_b.c-2", "x"]) { + expect(validBranch(name)).toBe(true); + } + for ( + let name of [ + "", + "refs/heads/main", + "-main", + "a..b", + "a//b", + "/a", + "a/", + "a.", + ".a", + "a/.b", + "a.lock", + "a/b.lock/c", + "a@{1}", + "@", + "HEAD", + "a b", + "a~1", + "a^", + "a:b", + "a?", + "a*", + "a[b", + "a\\b", + "a\x01", + "x".repeat(256), + ] + ) { + expect(validBranch(name)).toBe(false); + } +}); + +test("a build uses the newest source recorded at or before its revision", () => { + let sources = withPlanSource([], { revision: 3, branch: "p", commit: commit("a") }).sources; + sources = withPlanSource(sources, { revision: 7, branch: "p", commit: commit("b") }).sources; + expect(planSourceFor(sources, 2)).toBeUndefined(); + expect(planSourceFor(sources, 3)?.commit).toBe(commit("a")); + expect(planSourceFor(sources, 6)?.commit).toBe(commit("a")); + expect(planSourceFor(sources, 9)?.commit).toBe(commit("b")); +}); + +test("recording the same source again changes nothing and a new commit replaces it", () => { + let first = { revision: 4, branch: "p", commit: commit("a") }; + let sources = withPlanSource([], first).sources; + expect(withPlanSource(sources, first)).toEqual({ sources, changed: false }); + let moved = withPlanSource(sources, { ...first, commit: commit("c") }); + expect(moved.changed).toBe(true); + expect(moved.sources).toEqual([{ ...first, commit: commit("c") }]); + // An earlier revision recorded late keeps the list in revision order. + let earlier = withPlanSource(moved.sources, { revision: 2, branch: "p", commit: commit("d") }); + expect(earlier.sources.map(item => item.revision)).toEqual([2, 4]); +}); + +test("a document keeps the newest twenty sources", () => { + let sources = withPlanSource([], { revision: 0, branch: "p", commit: commit("a") }).sources; + for (let revision = 1; revision < 25; revision++) { + sources = withPlanSource(sources, { revision, branch: "p", commit: commit("a") }).sources; + } + expect(sources.map(item => item.revision)).toEqual( + Array.from({ length: 20 }, (_, index) => index + 5), + ); +}); + +test("stored sources restore only in order, valid and no newer than the document", () => { + let stored = [ + { revision: 1, branch: "p", commit: commit("a") }, + { revision: 5, branch: "p", commit: commit("b") }, + ]; + expect(restorePlanSources(undefined, 0)).toEqual([]); + expect(restorePlanSources(stored, 5)).toEqual(stored); + expect(() => restorePlanSources(stored, 4)).toThrow(); + expect(() => restorePlanSources(stored.toReversed(), 5)).toThrow(); + expect(() => restorePlanSources([{ ...stored[0], commit: "abc" }], 5)).toThrow(); + expect(() => restorePlanSources([{ ...stored[0], branch: "a..b" }], 5)).toThrow(); +}); diff --git a/apps/server/src/tasks/plan-source.ts b/apps/server/src/tasks/plan-source.ts new file mode 100644 index 000000000..43ff3edbe --- /dev/null +++ b/apps/server/src/tasks/plan-source.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +/** + * Where a plan revision's files are on GitHub: a branch the Planner pushed and the commit on it + * that holds them. A build of that revision starts from this commit instead of the builder's own + * checkout, which never has files the Planner wrote in its own worktree. + */ +export type PlanSource = { revision: number; branch: string; commit: string }; + +/** How many revisions' sources a document keeps; a build copies the one it used. */ +export const PLAN_SOURCES = 20; + +/** + * A branch name git accepts (`git check-ref-format --branch`), without a `refs/` prefix: no + * component starting with a dot or ending in `.lock`, no `..`, `@{`, `//`, control or special + * characters, and no leading or trailing slash or trailing dot. + */ +export function validBranch(name: string): boolean { + return name.length > 0 && name.length <= 255 + && name !== "@" && name !== "HEAD" + && !name.startsWith("refs/") && !name.startsWith("-") + && ![...name].some(char => char <= " " || char === "\x7f" || "~^:?*[\\".includes(char)) + && !name.includes("..") && !name.includes("@{") && !name.includes("//") + && !name.startsWith("/") && !name.endsWith("/") && !name.endsWith(".") + && name.split("/").every(part => !part.startsWith(".") && !part.endsWith(".lock")); +} + +export let planSourceSchema = z.object({ + revision: z.number().int().nonnegative(), + branch: z.string().refine(validBranch, "invalid branch"), + commit: z.string().regex(/^[a-f0-9]{40}$/), +}).strict(); + +/** Restore the stored sources: oldest revision first, one per revision, none newer than `revision`. */ +export function restorePlanSources(value: unknown, revision: number): PlanSource[] { + if (value === undefined) return []; + let sources = z.array(planSourceSchema).min(1).max(PLAN_SOURCES).parse(value); + if ( + sources.some((item, index) => index > 0 && item.revision <= sources[index - 1]!.revision) + || sources.at(-1)!.revision > revision + ) throw new Error("invalid plan sources"); + return sources; +} + +/** + * Record `source` for its revision, replacing any earlier one for the same revision, and keep the + * newest `PLAN_SOURCES`. The same source again changes nothing. + */ +export function withPlanSource( + sources: readonly PlanSource[], + source: PlanSource, +): { sources: PlanSource[]; changed: boolean } { + let same = sources.find(item => item.revision === source.revision); + if (same && same.branch === source.branch && same.commit === source.commit) { + return { sources: [...sources], changed: false }; + } + let next = [...sources.filter(item => item.revision !== source.revision), { ...source }] + .sort((a, b) => a.revision - b.revision) + .slice(-PLAN_SOURCES); + return { sources: next, changed: true }; +} + +/** The source a build of plan revision `revision` starts from: the newest recorded at or before it. */ +export function planSourceFor( + sources: readonly PlanSource[], + revision: number, +): PlanSource | undefined { + let found = sources.findLast(item => item.revision <= revision); + return found && { ...found }; +} diff --git a/apps/server/src/tasks/routes.test.ts b/apps/server/src/tasks/routes.test.ts index e999b72cb..26d6a9101 100644 --- a/apps/server/src/tasks/routes.test.ts +++ b/apps/server/src/tasks/routes.test.ts @@ -123,6 +123,8 @@ async function setup( unreadable?: { locked: boolean }; /** How long after a restart a build's machine may come back and carry it on. */ resumeMs?: number; + /** Builds start from what the server reads on GitHub, where these commits are. */ + github?: { commits: Set }; } = {}, ) { let context = await openPlan("# Launcher plan\n"); @@ -154,6 +156,16 @@ async function setup( async repositoryAccess(token: string) { return github.repository(token, "owner", "repository"); }, + ...(options.github + ? { + async branchHead() { + return "b".repeat(40); + }, + async hasCommit(_token: string, _owner: string, _name: string, sha: string) { + return options.github!.commits.has(sha); + }, + } + : {}), } as unknown as GitHub; let config = { origin: "https://chopin.test", @@ -218,6 +230,7 @@ async function setup( takers: current.takers, mayTake: current.mayTake, machineChoices: current.machineChoices, + ...(options.github ? { sourceFor: current.sourceFor } : {}), busy: async connectionId => (await current.service.store.active()).some(item => item.connectionId === connectionId && ["running", "publishing"].includes(item.state) @@ -1720,8 +1733,8 @@ function edit(plan: Plan.Plan, text: string) { })); } -/** Deliver a live build, edit the document, then queue and start a rebuild of that edit. */ -async function rebuilding(autoRun = false) { +/** Deliver a live build of the document's first revision. */ +async function deliveredLive(autoRun = false) { let context = await setup(); context.plan.persistence.liveBuild = true; let stopped: string[] = []; @@ -1751,6 +1764,12 @@ async function rebuilding(autoRun = false) { id: built.id, state: "stopped", }); + return { context, connection, built, claim, stopped }; +} + +/** Deliver a live build, edit the document, then queue and start a rebuild of that edit. */ +async function rebuilding(autoRun = false) { + let { context, connection, built, claim, stopped } = await deliveredLive(autoRun); let builtSource = context.plan.live!.baseSource; expect((await edit(context.plan, "A rebuilt paragraph.")).ok).toBe(true); let target = Plan.source(context.plan); @@ -3584,3 +3603,107 @@ test("Build is refused while the person's machine choice cannot be read, then us await Plan.close(context.plan); } }); + +test("a build of a revision whose plan files were pushed starts from their commit, and its builder reads where", async () => { + let planned = { revision: 0, branch: "plans/launcher", commit: "c".repeat(40) }; + let context = await setup({ github: { commits: new Set([planned.commit]) } }); + try { + context.plan.planSources = [planned]; + let connection = await paired(context); + let built = await (await context.call(context.path, { + planRevision: 0, + graphVersion: 1, + graphRevision: 1, + })).json(); + expect(built.checkout).toEqual({ + repository: "owner/repository", + branch: planned.branch, + commit: planned.commit, + }); + expect(built.planSource).toEqual(planned); + expect((await (await context.call(context.path)).json()).build.planSource).toEqual(planned); + let claim = await tool(context, connection.token, "claim_implementation_build", { + id: built.id, + }); + expect(claim.source).toMatchObject({ commit: planned.commit, branch: planned.branch }); + await tool(context, connection.token, "report_implementation_build", { + id: built.id, + state: "running", + session: "acp-session", + }); + expect((await tool(context, claim.runToken, "read_implementation")).planSource).toEqual( + planned, + ); + } finally { + await Plan.close(context.plan); + } +}); + +test("a build with no plan source starts from the machine's checkout, as before", async () => { + let context = await setup({ github: { commits: new Set() } }); + try { + await paired(context); + let built = await (await context.call(context.path, { + planRevision: 0, + graphVersion: 1, + graphRevision: 1, + })).json(); + expect(built.checkout).toEqual(checkout); + expect(built.planSource).toBeUndefined(); + } finally { + await Plan.close(context.plan); + } +}); + +test("a plan source GitHub doesn't have refuses the build, saying so, and queues nothing", async () => { + let context = await setup({ github: { commits: new Set() } }); + try { + context.plan.planSources = [{ revision: 0, branch: "plans/launcher", commit: "d".repeat(40) }]; + await paired(context); + let response = await context.call(context.path, { + planRevision: 0, + graphVersion: 1, + graphRevision: 1, + }); + expect(response.status).toBe(409); + expect((await response.json()).error).toBe("plan-source-unavailable"); + expect(context.plan.builds).toEqual([]); + } finally { + await Plan.close(context.plan); + } +}); + +test("a rebuild keeps its base unless the plan's files were pushed for a revision since the last build", async () => { + let { context, connection } = await deliveredLive(); + try { + let first = context.plan.builds[0]!; + let base = context.plan.live!.baseRevision; + // A source recorded for the revision already built is not newer: today's base stands. + context.plan.planSources = [{ revision: base, branch: "plans/one", commit: "c".repeat(40) }]; + expect((await edit(context.plan, "A rebuilt paragraph.")).ok).toBe(true); + let kept = await queueRebuild(context.plan, context.experiments.connections); + if (kept.kind !== "queued") throw new Error(`rebuild was ${kept.kind}`); + expect(kept.build.checkout).toEqual(first.checkout); + expect(kept.build.planSource).toBeUndefined(); + await tool(context, connection.token, "claim_implementation_build", { id: kept.build.id }); + await tool(context, connection.token, "report_implementation_build", { + id: kept.build.id, + state: "stopped", + }); + // The Planner pushed the edited revision's files: the next rebuild starts from them. + let newer = { revision: context.plan.revision, branch: "plans/two", commit: "e".repeat(40) }; + context.plan.planSources = [...context.plan.planSources, newer]; + let moved = await queueRebuild(context.plan, context.experiments.connections, { + by: "U_test", + }); + if (moved.kind !== "queued") throw new Error(`rebuild was ${moved.kind}`); + expect(moved.build.checkout).toEqual({ + repository: first.checkout.repository, + branch: newer.branch, + commit: newer.commit, + }); + expect(moved.build.planSource).toEqual(newer); + } finally { + await Plan.close(context.plan); + } +}); diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index 72d5de483..53e929833 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -9,6 +9,7 @@ import { runner, } from "./builds"; import { implementationLifecycle } from "./lifecycle"; +import { planSourceFor } from "./plan-source"; import { planningReadiness, reportImplementationLifecycle } from "./plan-graphs"; import { exclusive } from "../plan/service"; import { GitHubError } from "../github/client"; @@ -27,6 +28,7 @@ import type { BuildMachine, BuildRequest, ImplementationSnapshot, + PlanSource, } from "@chopin/protocol/implementation"; type Options = { @@ -58,10 +60,11 @@ type Options = { /** Reads the stored machine choices, or throws `machine-choice-unavailable`. */ machineChoices?: () => Promise; /** - * The checkout a connection's build of a document starts from; without it, only a checkout the - * connection said it has. + * The checkout a connection's build of a document starts from: the plan source's commit when + * there is one, checked on GitHub (else `plan-source-unavailable`). Without it, only a checkout + * the connection said it has, at the plan source's commit when there is one. */ - sourceFor?: (connection: Connection, documentId: string) => Promise; + sourceFor?: (connection: Connection, documentId: string, plan?: PlanSource) => Promise; }; let buildSchema = z.object({ retryOf: z.string().uuid().optional(), @@ -382,11 +385,20 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o 409, ); } + // The plan's files the Planner pushed for this revision, which the build starts from. + let planSource = await options.withPlan( + id, + async plan => planSourceFor(plan.planSources, input.planRevision), + ); let source: Source | undefined; try { - source = options.sourceFor - ? await options.sourceFor(connection, id) - : checkoutOf(connection, channel.repositoryId); + if (options.sourceFor) source = await options.sourceFor(connection, id, planSource); + else { + source = checkoutOf(connection, channel.repositoryId); + if (source && planSource) { + source = { ...source, commit: planSource.commit, branch: planSource.branch }; + } + } } catch (error) { let code = (error as { code?: unknown } | undefined)?.code; throw new GitHubError(typeof code === "string" ? code : "no-workspace", 409); @@ -420,6 +432,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o ...(connection.about?.service ? { machineService: true as const } : {}), repositoryId: channel.repositoryId, checkout, + ...(planSource ? { planSource } : {}), }); let holder = options.connections.assigned(connection.id); // Another document's build holds the connection: this one starts after it. diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index 7e78b85f0..53325b82e 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -22,6 +22,7 @@ import { linkParts, liveTaskGroups, liveTaskState, + planSourceLine, pullRequestCommits, pullRequestNumber, retryAction, @@ -242,6 +243,17 @@ describe("attribution", () => { }); }); +describe("plan source", () => { + it("names the plan revision and the branch at its short commit a build started from", () => { + let planSource = { revision: 4, branch: "plans/launch", commit: "0123456789".repeat(4) }; + expect(planSourceLine({ ...build("running"), planSource })) + .toBe("Building from plan revision 4 at plans/launch@0123456"); + expect(planSourceLine({ ...build("stopped"), planSource })) + .toBe("Built from plan revision 4 at plans/launch@0123456"); + expect(planSourceLine(build("stopped"))).toBeUndefined(); + }); +}); + describe("refusals", () => { it("explains the refusals the server names and leaves the rest to the failure line", () => { expect(draftRefusalCopy("resolve accepted comments awaiting plan changes first")) @@ -357,6 +369,9 @@ describe("first build", () => { it("maps refused starts and rebuilds to plain copy", () => { expect(buildRefusalCopy("no-workspace")).toBe("Start your local agent, then retry"); expect(buildRefusalCopy("reviewed graph has changed")).toBe("The tasks changed. Try again"); + expect(buildRefusalCopy("plan-source-unavailable")) + .toBe("The plan’s commit isn’t on GitHub. Ask chopin to push it again"); + expect(buildRefusalCopy("plan source has changed")).toBe("The plan’s files changed. Try again"); expect(buildRefusalCopy("accepted comments awaiting plan changes")) .toBe("Update the document for accepted comments first"); expect(buildRefusalCopy("something internal")).toBe("The build couldn’t start. Try again"); diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index f97a82dbf..eab432f36 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -310,6 +310,21 @@ export function buildAgo( return at ? ago(at, now) : undefined; } +/** + * Where a build's code started when the Planner pushed the plan's files for it: the plan revision + * and the branch at the commit's short SHA. + */ +export function planSourceLine( + build: Pick | undefined, +): string | undefined { + if (!build?.planSource) return; + let source = build.planSource; + let active = ["queued", "starting", "running"].includes(build.state); + return `${ + active ? "Building" : "Built" + } from plan revision ${source.revision} at ${source.branch}@${source.commit.slice(0, 7)}`; +} + function latestRun(snapshot: Snapshot) { let graph = snapshot.graph; return snapshot.lifecycle.history.findLast(item => @@ -464,6 +479,10 @@ export function buildRefusalCopy(message: string): string { if (/offline or busy/.test(message)) return "Your local agent is offline or busy with other work"; if (/nothing to build/.test(message)) return "There are no changes to build"; if (/busy/i.test(message)) return "Your local agent is busy with another document"; + if (message === "plan-source-unavailable") { + return "The plan’s commit isn’t on GitHub. Ask chopin to push it again"; + } + if (/plan source has changed/.test(message)) return "The plan’s files changed. Try again"; if (/reviewed graph has changed|reviewed build has changed|stale/.test(message)) { return "The tasks changed. Try again"; } diff --git a/apps/web/src/build-source.css b/apps/web/src/build-source.css new file mode 100644 index 000000000..a07877bd7 --- /dev/null +++ b/apps/web/src/build-source.css @@ -0,0 +1,8 @@ +/* The plan's pushed commit the build started from, as quiet as the machine beside it. */ +.build-card-source { + min-width: 0; + overflow: hidden; + color: var(--color-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index 05cd74983..235c775b6 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -26,6 +26,7 @@ import { linkParts, liveTaskGroups, liveTaskState, + planSourceLine, plural, pullRequestCommits, pullRequestNumber, @@ -44,6 +45,7 @@ import { inlineText } from "./chat/inline-text"; import { InlineMarkdown, MessageMarkdown } from "./chat/markdown"; import { LocalAgentNote } from "./local-agent-note"; import { MachineGlyph } from "./machine-button"; +import "./build-source.css"; import type { ReactNode } from "react"; import type { BuildPlan } from "./build-plan-button"; @@ -330,7 +332,10 @@ export function BuildView( // The build the card's footer names: the one running, else the last that ran. let shown: - | Pick & { createdAt?: string; finishedAt?: string } + | Pick & { + createdAt?: string; + finishedAt?: string; + } | undefined = look.kind === "plan" || look.kind === "tasks" ? undefined : activeBuild ?? (phase.kind === "live" && snapshot?.live @@ -348,6 +353,7 @@ export function BuildView( ); let machine = shown && snapshot?.buildMachines?.[shown.id]; let when = buildAgo(shown, now); + let from = planSourceLine(shown); if (phase.kind === "stopped" && canEdit && !returning) { action = ( @@ -486,7 +492,7 @@ export function BuildView( {card.segments.map((state, index) => )} )} - {(login || machine || action) && ( + {(login || machine || from || action) && (
{login && shown && ( @@ -503,6 +509,11 @@ export function BuildView( )} {machine && } + {from && ( + + {from} + + )} {action}
diff --git a/docs/agent-connection.md b/docs/agent-connection.md index 7b3ab7c3a..dac1cc407 100644 --- a/docs/agent-connection.md +++ b/docs/agent-connection.md @@ -1650,3 +1650,47 @@ the answer leaves its document out. When a document is renamed, the poll's next answer names it at its new address; the connector shows that address in `chopin status` and its output, and starts the backend there next time, without restarting a backend that is running. + +### Plan sources + +A backend that writes files for a document revision (a specification, a proof, a +schema) in `CHOPIN_WORKTREE` makes them reachable by a build by committing them on a +branch, pushing it to the document's repository, and recording where at `/mcp`, +with the MCP token in `CHOPIN_MCP_TOKEN_FILE`: + +```json +{ + "name": "record_plan_source", + "arguments": { + "id": "", + "revision": 12, + "branch": "plans/", + "commit": "<40 hex characters>" + } +} +``` + +`revision` is the document revision the files belong to, such as the one +`update_document` returned; `branch` is a branch name git accepts, without +`refs/heads/`; `commit` is the full SHA, already pushed. Chopin asks GitHub for the +commit with the machine's own grant and keeps one source per revision, the newest +20 revisions, beside the document; recording another commit for a revision replaces +it, and the same one again changes nothing. It answers +`{ "recorded": true, "revision", "branch", "commit" }`, or an error whose `code` is +`invalid-plan-source` (a bad branch or SHA), `plan-source-unavailable` (GitHub has +no such commit in the repository), `revision-conflict` with the document's current +`revision` (one it has not reached), `document-archived`, `document-unavailable` or +`repository-forbidden`. A server without the tool answers JSON-RPC error `-32601`. + +A build of the tasks drafted against revision R starts from the source recorded for +the newest revision at or before R, instead of the builder's checkout or the default +branch: its worktree is detached at that commit. Build checks the commit again with +the building machine's grant as it is pressed, and refuses with +`plan-source-unavailable` when GitHub no longer has it; a connector that cannot fetch +it fails the build naming the commit. The build records the source, the Build tab +says _Built from plan revision 12 at plans/…@1a2b3c4_, and its run's +`read_implementation` returns it as `planSource`. A rebuild of a built document keeps +the base it had unless a source was recorded for a revision after the one last built, +which the rebuild then starts from. The Planner's `read_implementation_graph` returns +the source for the current revision as `plan_source`. A document with no recorded +source builds as before. diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 638c17b68..0e72fb686 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -1402,6 +1402,12 @@ supported MCP handoff can read only graphs on MCP-created documents, and no current production interface lets a person approve the draft. See [Experimental implementation lifecycle](implementation-lifecycle.md). +A remote Planner's backend that writes files for a revision in its own worktree +pushes them and records where with `record_plan_source` at `/mcp`; a build of that +revision starts from the recorded commit rather than the builder's checkout, and +`read_implementation_graph` returns it as `plan_source`. See +[Plan sources](agent-connection.md#plan-sources). + ## Main implementation points - Harness selection, adapter factory map, and startup checks: diff --git a/e2e/github.ts b/e2e/github.ts index 7cd3051c3..f40558e13 100644 --- a/e2e/github.ts +++ b/e2e/github.ts @@ -327,6 +327,13 @@ let fake = async (input: Parameters[0], init?: Parameters output, localEdit: () => readFile(join(root, "sample.txt"), "utf8"), close: async () => { @@ -517,6 +519,110 @@ for (let mode of ["block", "complete", "startup-failure", "live"] as const) { }); } +test("a build of a revision whose plan files the Planner pushed starts from their commit", async ({ seed, join: enter, room, baseURL }) => { + test.setTimeout(60_000); + await seed("# Plan files\n\nThe Planner wrote the plan's files on its own branch.\n", { + graph: preparedGraph(), + }); + let handle = person(); + let local = await connector(baseURL!, [ + "bun", + join(ROOT, "apps/connector/src/testing/fake-implementer.ts"), + "--complete", + "--http", + ]); + try { + // The Planner's commit of the plan's files, on a branch beside the person's own work, which + // their checkout never has checked out. + let text = (...args: string[]) => String(local.git(...args)).trim(); + let root = text("rev-parse", "--show-toplevel"); + let stdin = (input: string, ...args: string[]) => + execFileSync("git", ["-C", root, ...args], { input }).toString().trim(); + let blob = stdin("# The plan's files\n", "hash-object", "-w", "--stdin"); + let tree = stdin( + `100644 blob ${text("rev-parse", "HEAD:sample.txt")}\tsample.txt\n` + + `100644 blob ${blob}\tplan.md\n`, + "mktree", + ); + let commit = text("commit-tree", tree, "-p", "HEAD", "-m", "Plan files"); + text("branch", "plans/e2e", commit); + let mcp = async (name: string, args: Record) => { + let response = await fetch(`${baseURL}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ghu_e2e_${handle}_1`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }), + }); + expect(response.status).toBe(200); + return (await response.json()).result; + }; + let { revision } = (await mcp("read_document", { id: room })).structuredContent; + // GitHub has no zero commit, so it isn't recorded. + expect( + (await mcp("record_plan_source", { + id: room, + revision, + branch: "plans/e2e", + commit: "0".repeat(40), + })).structuredContent, + ).toEqual({ code: "plan-source-unavailable" }); + expect( + (await mcp("record_plan_source", { id: room, revision, branch: "plans/e2e", commit })) + .structuredContent, + ).toEqual({ recorded: true, revision, branch: "plans/e2e", commit }); + + let page = await enter(handle); + await page.goto(await local.pairing); + await page.getByRole("button", { name: "Add machine", exact: true }).click(); + await expect(page.getByRole("status").filter({ hasText: "Connected" })).toHaveText( + "Connected", + ); + await page.goto(testChannelPath(room)); + await page.getByRole("button", { name: "Build plan", exact: true }).click(); + await expect.poll(async () => { + let snapshot = await (await page.request.get(`/api/channels/${room}/implementation`)).json(); + return snapshot.build?.state; + }, { timeout: 30_000 }).toBe("stopped"); + let snapshot = await (await page.request.get(`/api/channels/${room}/implementation`)).json(); + expect(snapshot.build.checkout).toEqual({ + repository: "octo-org/score", + branch: "plans/e2e", + commit, + }); + expect(snapshot.build.planSource).toEqual({ revision, branch: "plans/e2e", commit }); + // The build's own worktree is that commit, with the plan's files in it. + let worktree = join(local.state, "runs", snapshot.build.id); + expect(execFileSync("git", ["-C", worktree, "rev-parse", "HEAD"]).toString().trim()) + .toBe(commit); + expect(await readFile(join(worktree, "plan.md"), "utf8")).toBe("# The plan's files\n"); + await page.getByRole("group", { name: "Document view" }) + .getByRole("button", { name: /^Build/ }).click(); + let line = buildView(page).getByText( + `Built from plan revision ${revision} at plans/e2e@${commit.slice(0, 7)}`, + { exact: true }, + ); + await expect(line).toBeVisible(); + await expect(line).toHaveAttribute("title", commit); + let shots = process.env.CHOPIN_PLAN_SOURCE_SHOTS; + // Chopin has one theme, so one look at desktop and phone widths. + for (let width of shots ? [1280, 390] : []) { + await page.setViewportSize({ width, height: 844 }); + await buildView(page).locator(".build-card").screenshot({ + path: join(shots!, `build-card-${width}.png`), + }); + } + } finally { + await local.close(); + } +}); + test("a running build heads Chat's run strip with its task, and Chat says when it opens each pull request and when it finishes", async ({ seed, join: enter, room, baseURL }) => { test.setTimeout(90_000); await seed("# Chat sees the build\n\nValidate the local agent connection.\n", { diff --git a/packages/protocol/implementation.d.ts b/packages/protocol/implementation.d.ts index 0cd5cbb7f..06bf3c947 100644 --- a/packages/protocol/implementation.d.ts +++ b/packages/protocol/implementation.d.ts @@ -4,6 +4,8 @@ import type { Plan } from "./plan"; type KIND = Frame & { kind: K }; export type CheckoutContext = { repository: string; branch?: string; commit: string }; +/** Where a plan revision's files were pushed: a branch and the commit on it that holds them. */ +export type PlanSource = { revision: number; branch: string; commit: string }; export type BuildRequest = { id: string; retryOf?: string; @@ -21,6 +23,11 @@ export type BuildRequest = { machineService?: true; repositoryId: string; checkout: CheckoutContext; + /** + * The commit holding the plan's files that the Planner pushed for plan revision `revision`, + * which `checkout` starts from; absent when the build started from the builder's checkout. + */ + planSource?: PlanSource; planRevision: number; graphVersion: number; graphRevision: number;