Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/connector/src/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions apps/connector/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -700,20 +701,24 @@ 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 }),
metadata: { skipPermission: true },
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),
graph_revision: version?.revision ?? 0,
graph_plan_revision: version?.planRevision,
needs_revision: !!version && version.planRevision !== context.plan.revision,
graph: context.plan.graph,
...(planSource ? { plan_source: planSource } : {}),
};
}),
}),
Expand Down
51 changes: 45 additions & 6 deletions apps/server/src/experiments/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Source> {
async function sourceFor(
connection: Connection,
documentId: string,
plan?: PlanSource,
): Promise<Source> {
let channel = await auth.storage.channels.get(documentId);
if (!channel) fail("not-found");
let repository = {
Expand All @@ -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)),
);
Expand All @@ -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)
Expand Down
27 changes: 25 additions & 2 deletions apps/server/src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export interface GitHub {
): Promise<InstalledRepository | undefined>;
/** 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<string>;
/** 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<boolean>;
/** The human collaborators who can read a repository; optional where collaborators are not listed. */
repositoryCollaborators?(
token: string,
Expand Down Expand Up @@ -709,6 +711,19 @@ export class GitHubClient implements GitHub, GitHubReferenceSource {
return sha;
}

async hasCommit(token: string, owner: string, name: string, sha: string): Promise<boolean> {
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.
Expand Down Expand Up @@ -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<Response> {
async #request(
url: URL,
token: string,
ifNoneMatch?: string,
/** Statuses returned to the caller rather than thrown. */
answered: number[] = [],
): Promise<Response> {
let response: Response;
try {
let headers = new Headers({
Expand All @@ -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
Expand Down
120 changes: 120 additions & 0 deletions apps/server/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ReturnType<typeof unapprovedContext>>["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 = {
Expand Down Expand Up @@ -182,6 +185,21 @@ export type InvokePlanner<Caller> = {
>;
};

export type RecordPlanSourceInput = {
id: string;
revision: number;
branch: string;
commit: string;
};

export type RecordPlanSource<Caller> = {
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<Caller> = {
upload(
caller: Caller,
Expand Down Expand Up @@ -213,6 +231,7 @@ export type McpOptions<Caller> = {
restore?: RestoreDocument<Caller>;
invoke?: InvokePlanner<Caller>;
upload?: UploadImage<Caller>;
planSources?: RecordPlanSource<Caller>;
implementations?: Implementations<Caller>;
sessions?: McpSessions<Caller>;
};
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -999,6 +1084,7 @@ export function handler<Caller>(
&& (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)
Expand Down Expand Up @@ -1268,6 +1354,40 @@ export function handler<Caller>(
: "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
Expand Down
Loading
Loading