@ziggs-ai/agent-sdk 0.38.17 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ import { laneHeader } from '@ziggs-ai/api-client';
2
+ import { runtimeLog } from '../shared/runtimeLog.js';
3
+ /** Headers git sends that the door needs to see; anything else stays behind. */
4
+ const GIT_REQUEST_HEADERS = ['content-type', 'content-encoding', 'accept', 'git-protocol'];
5
+ /** Headers the door answers with that git needs to see. */
6
+ const GIT_RESPONSE_HEADERS = ['content-type', 'cache-control', 'content-encoding', 'www-authenticate'];
7
+ const BOX_PATH = /^\/([^/]+)\/([^/]+?)(?:\.git)?\/(info\/refs|git-upload-pack|git-receive-pack)(\?.*)?$/;
8
+ /** The door path for one of the box's git requests, or null when the request names another repository. */
9
+ export function doorPathFor(boxPath, door) {
10
+ const m = BOX_PATH.exec(boxPath);
11
+ if (!m)
12
+ return null;
13
+ const [, owner, name, tail, query] = m;
14
+ if (owner.toLowerCase() !== door.repository.owner.toLowerCase() || name.toLowerCase() !== door.repository.name.toLowerCase()) {
15
+ return null;
16
+ }
17
+ return `/connections/${encodeURIComponent(door.connectionId)}/git/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${tail}${query ?? ''}`;
18
+ }
19
+ export function doorHeaders(door, gitHeaders) {
20
+ const out = {
21
+ authorization: `Bearer ${door.operatorKey}`,
22
+ 'x-agent-id': door.agentId,
23
+ 'x-grant-id': door.grantId,
24
+ 'x-ziggs-task-id': door.taskId,
25
+ };
26
+ for (const [key, value] of Object.entries(laneHeader(door.laneId)))
27
+ out[key.toLowerCase()] = value;
28
+ for (const [k, v] of Object.entries(gitHeaders)) {
29
+ if (GIT_REQUEST_HEADERS.includes(k.toLowerCase()))
30
+ out[k.toLowerCase()] = v;
31
+ }
32
+ return out;
33
+ }
34
+ export class CheckoutBoxError extends Error {
35
+ status;
36
+ constructor(message, status) {
37
+ super(message);
38
+ this.status = status;
39
+ this.name = 'CheckoutBoxError';
40
+ }
41
+ }
42
+ /**
43
+ * The host's end of the channel. One per box. Every call carries the box's
44
+ * secret; the box answers nothing without it.
45
+ */
46
+ export class CheckoutBoxClient {
47
+ endpoint;
48
+ fetchFn;
49
+ relayStopped = false;
50
+ relayLoop = null;
51
+ relayAbort = new AbortController();
52
+ forwarding = new Set();
53
+ mutation = Promise.resolve();
54
+ closing = false;
55
+ onMutation;
56
+ checkpointWarning;
57
+ warnCheckpoint(message) {
58
+ this.checkpointWarning = message;
59
+ runtimeLog.warn('checkout', message);
60
+ }
61
+ withCheckpointWarning(result) {
62
+ return this.checkpointWarning ? { ...result, checkpointWarning: this.checkpointWarning } : result;
63
+ }
64
+ mutate(operation) {
65
+ const pending = this.mutation.catch(() => { }).then(async () => {
66
+ if (this.closing)
67
+ throw new CheckoutBoxError('checkout is closing');
68
+ let result;
69
+ try {
70
+ result = await operation();
71
+ }
72
+ catch (error) {
73
+ // A lost HTTP response does not prove the command stopped. Quiesce
74
+ // before snapshotting; otherwise a retry could restore a torn archive.
75
+ await this.call('/cancel', {}, 5_000).catch(() => { });
76
+ throw error;
77
+ }
78
+ finally {
79
+ try {
80
+ await this.onMutation?.();
81
+ }
82
+ catch (error) {
83
+ this.warnCheckpoint(`Private checkpoint save failed: ${error.message}. Only previously saved or pushed work can be recovered.`);
84
+ }
85
+ }
86
+ return this.withCheckpointWarning(result);
87
+ });
88
+ this.mutation = pending;
89
+ return pending;
90
+ }
91
+ async quiesce() {
92
+ this.closing = true;
93
+ await this.call('/cancel', {}, 5_000).catch(() => { });
94
+ await this.mutation.catch(() => { });
95
+ }
96
+ async checkpoint() {
97
+ const res = await this.fetchFn(`${this.endpoint.url}/checkpoint`, {
98
+ method: 'POST', headers: this.auth(), signal: AbortSignal.timeout(130_000),
99
+ });
100
+ if (!res.ok)
101
+ throw new CheckoutBoxError(`checkpoint failed: ${await res.text()}`, res.status);
102
+ const reader = res.body?.getReader();
103
+ if (!reader)
104
+ throw new CheckoutBoxError('checkpoint has no body');
105
+ const chunks = [];
106
+ let size = 0;
107
+ try {
108
+ for (;;) {
109
+ const { value, done } = await reader.read();
110
+ if (done)
111
+ break;
112
+ size += value.length;
113
+ if (size > 64 * 1024 * 1024)
114
+ throw new CheckoutBoxError('checkpoint exceeds 64 MiB');
115
+ chunks.push(value);
116
+ }
117
+ }
118
+ finally {
119
+ await reader.cancel().catch(() => { });
120
+ }
121
+ return Buffer.concat(chunks);
122
+ }
123
+ async restore(data) {
124
+ const res = await this.fetchFn(`${this.endpoint.url}/restore`, {
125
+ method: 'POST', headers: { ...this.auth(), 'content-type': 'application/gzip' },
126
+ body: Buffer.from(data), signal: AbortSignal.timeout(130_000),
127
+ });
128
+ const text = await res.text();
129
+ if (!res.ok)
130
+ throw new CheckoutBoxError(`restore failed: ${text}`, res.status);
131
+ }
132
+ constructor(endpoint, fetchFn = fetch) {
133
+ this.endpoint = endpoint;
134
+ this.fetchFn = fetchFn;
135
+ }
136
+ auth() {
137
+ return { authorization: `Bearer ${this.endpoint.secret}` };
138
+ }
139
+ async call(path, body, timeoutMs = 60_000) {
140
+ const res = await this.fetchFn(`${this.endpoint.url}${path}`, {
141
+ method: 'POST',
142
+ headers: { ...this.auth(), 'content-type': 'application/json' },
143
+ body: JSON.stringify(body ?? {}),
144
+ signal: AbortSignal.timeout(timeoutMs),
145
+ });
146
+ const text = await res.text();
147
+ let parsed = null;
148
+ try {
149
+ parsed = text ? JSON.parse(text) : null;
150
+ }
151
+ catch {
152
+ parsed = { error: text };
153
+ }
154
+ if (!res.ok) {
155
+ const error = parsed?.error ?? text ?? `HTTP ${res.status}`;
156
+ throw new CheckoutBoxError(`box ${path} failed: ${error}`, res.status);
157
+ }
158
+ return parsed;
159
+ }
160
+ /** Poll the box until it answers, or give up. */
161
+ async waitReady(timeoutMs) {
162
+ const until = Date.now() + timeoutMs;
163
+ let lastError = 'no answer';
164
+ while (Date.now() < until) {
165
+ try {
166
+ const res = await this.fetchFn(`${this.endpoint.url}/health`, {
167
+ headers: this.auth(),
168
+ signal: AbortSignal.timeout(3_000),
169
+ });
170
+ if (res.ok) {
171
+ await res.text();
172
+ return;
173
+ }
174
+ lastError = `HTTP ${res.status}`;
175
+ }
176
+ catch (err) {
177
+ lastError = err.message;
178
+ }
179
+ await new Promise((r) => setTimeout(r, 1_000));
180
+ }
181
+ throw new CheckoutBoxError(`box did not come up within ${Math.round(timeoutMs / 1000)}s (${lastError})`);
182
+ }
183
+ /**
184
+ * Forward the box's git requests to the door, one at a time as git makes
185
+ * them, until {@link stopRelay}. The box queues what git asks; this end
186
+ * long-polls, performs each request against the door under the hire's
187
+ * grant, and posts the answer back for git to read.
188
+ */
189
+ startRelay(door) {
190
+ if (this.relayLoop)
191
+ return;
192
+ this.relayStopped = false;
193
+ this.relayAbort = new AbortController();
194
+ this.relayLoop = this.relay(door).catch((err) => {
195
+ runtimeLog.warn('checkout', `git relay stopped: ${err.message}`);
196
+ });
197
+ }
198
+ async stopRelay() {
199
+ this.relayStopped = true;
200
+ this.relayAbort.abort();
201
+ await Promise.allSettled(this.forwarding);
202
+ await this.relayLoop?.catch(() => { });
203
+ this.relayLoop = null;
204
+ }
205
+ async relay(door) {
206
+ while (!this.relayStopped) {
207
+ let next;
208
+ try {
209
+ next = await this.fetchFn(`${this.endpoint.url}/relay/next`, {
210
+ headers: this.auth(),
211
+ signal: AbortSignal.any([this.relayAbort.signal, AbortSignal.timeout(40_000)]),
212
+ });
213
+ }
214
+ catch {
215
+ if (this.relayStopped)
216
+ return;
217
+ await new Promise((r) => setTimeout(r, 500));
218
+ continue;
219
+ }
220
+ if (next.status === 204) {
221
+ await next.text().catch(() => { });
222
+ continue;
223
+ }
224
+ if (!next.ok) {
225
+ await next.text().catch(() => { });
226
+ if (next.status === 401)
227
+ throw new CheckoutBoxError('box refused the channel secret', 401);
228
+ await new Promise((r) => setTimeout(r, 500));
229
+ continue;
230
+ }
231
+ const forwarding = this.forward(next, door).catch((err) => {
232
+ if (!this.relayStopped)
233
+ runtimeLog.warn('checkout', `git relay request failed: ${err.message}`);
234
+ });
235
+ this.forwarding.add(forwarding);
236
+ void forwarding.finally(() => this.forwarding.delete(forwarding));
237
+ }
238
+ }
239
+ async forward(next, door) {
240
+ const id = next.headers.get('x-relay-id') ?? '';
241
+ const method = (next.headers.get('x-relay-method') ?? 'GET').toUpperCase();
242
+ const boxPath = next.headers.get('x-relay-path') ?? '/';
243
+ let gitHeaders = {};
244
+ try {
245
+ gitHeaders = JSON.parse(next.headers.get('x-relay-headers') ?? '{}');
246
+ }
247
+ catch {
248
+ gitHeaders = {};
249
+ }
250
+ const reply = async (status, headers, body) => {
251
+ const res = await this.fetchFn(`${this.endpoint.url}/relay/${encodeURIComponent(id)}`, {
252
+ method: 'POST',
253
+ headers: {
254
+ ...this.auth(),
255
+ 'x-relay-status': String(status),
256
+ 'x-relay-headers': JSON.stringify(headers),
257
+ 'content-type': 'application/octet-stream',
258
+ },
259
+ body,
260
+ // Streaming request bodies need half duplex in Node's fetch.
261
+ ...{ duplex: 'half' },
262
+ signal: AbortSignal.any([this.relayAbort.signal, AbortSignal.timeout(10 * 60_000)]),
263
+ });
264
+ await res.text().catch(() => { });
265
+ };
266
+ const doorPath = doorPathFor(boxPath, door);
267
+ if (!doorPath) {
268
+ await next.body?.cancel().catch(() => { });
269
+ await reply(403, { 'content-type': 'text/plain' }, `this box is checked out for ${door.repository.owner}/${door.repository.name} only`);
270
+ return;
271
+ }
272
+ let upstream;
273
+ try {
274
+ upstream = await this.fetchFn(`${door.backendUrl}${doorPath}`, {
275
+ method,
276
+ headers: doorHeaders(door, gitHeaders),
277
+ body: method === 'POST' ? next.body : undefined,
278
+ ...{ duplex: 'half' },
279
+ redirect: 'manual',
280
+ signal: AbortSignal.any([this.relayAbort.signal, AbortSignal.timeout(10 * 60_000)]),
281
+ });
282
+ }
283
+ catch (err) {
284
+ await reply(502, { 'content-type': 'text/plain' }, `git door unreachable: ${err.message}`);
285
+ return;
286
+ }
287
+ const headers = {};
288
+ for (const name of GIT_RESPONSE_HEADERS) {
289
+ const v = upstream.headers.get(name);
290
+ if (v)
291
+ headers[name] = v;
292
+ }
293
+ await reply(upstream.status, headers, upstream.body);
294
+ }
295
+ setup(spec) {
296
+ return this.call('/setup', spec, 15 * 60_000);
297
+ }
298
+ async read(path) {
299
+ return this.withCheckpointWarning(await this.call('/read', { path }));
300
+ }
301
+ write(path, content) {
302
+ return this.mutate(() => this.call('/write', { path, content }));
303
+ }
304
+ run(command, timeoutSeconds) {
305
+ const seconds = Math.max(1, Math.min(timeoutSeconds ?? 600, 1_800));
306
+ return this.mutate(() => this.call('/run', { command, timeoutSeconds: seconds }, (seconds + 30) * 1000));
307
+ }
308
+ push(message) {
309
+ return this.mutate(() => this.call('/push', { message }, 10 * 60_000));
310
+ }
311
+ /** Ask the box to exit. Best effort: a box that is already gone is the goal. */
312
+ async stop() {
313
+ try {
314
+ await this.call('/stop', {}, 5_000);
315
+ }
316
+ catch {
317
+ /* already gone, or never came up */
318
+ }
319
+ }
320
+ }
@@ -0,0 +1,10 @@
1
+ export type { CheckoutBox, CheckoutBoxEndpoint, CheckoutBoxSpec, CheckoutProvider, CheckoutCheckpointStore, CheckoutTarget, CheckoutRunResult, CheckoutPushResult, } from './types.js';
2
+ export { CheckoutBoxClient, CheckoutBoxError, doorPathFor, doorHeaders, type GitDoorTarget } from './boxClient.js';
3
+ export { checkoutTargetFromText, checkoutTargetFromTask, checkoutBranchForTask } from './intake.js';
4
+ export { CHECKOUT_TOOLS, checkoutReadTool, checkoutWriteTool, checkoutRunTool, checkoutPushTool, type CheckoutSession, } from './tools.js';
5
+ export { openCheckout, closeCheckout, type OpenCheckout, type OpenCheckoutDeps } from './open.js';
6
+ /**
7
+ * The box server, as a file a host can run with Node, or embed as a task's
8
+ * command. It depends on nothing but Node and git.
9
+ */
10
+ export declare function checkoutBoxServerPath(): string;
@@ -0,0 +1,12 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ export { CheckoutBoxClient, CheckoutBoxError, doorPathFor, doorHeaders } from './boxClient.js';
3
+ export { checkoutTargetFromText, checkoutTargetFromTask, checkoutBranchForTask } from './intake.js';
4
+ export { CHECKOUT_TOOLS, checkoutReadTool, checkoutWriteTool, checkoutRunTool, checkoutPushTool, } from './tools.js';
5
+ export { openCheckout, closeCheckout } from './open.js';
6
+ /**
7
+ * The box server, as a file a host can run with Node, or embed as a task's
8
+ * command. It depends on nothing but Node and git.
9
+ */
10
+ export function checkoutBoxServerPath() {
11
+ return fileURLToPath(new URL('./box/server.js', import.meta.url));
12
+ }
@@ -0,0 +1,15 @@
1
+ import type { CheckoutTarget } from './types.js';
2
+ /**
3
+ * What the task names. A task for a checkout agent names a repository as
4
+ * `owner/name` (a GitHub URL counts) and may name a base branch; when it does
5
+ * not, the repository's default branch is used. Nothing is guessed: a task
6
+ * naming no repository is refused with that said, not pointed at a likely one.
7
+ */
8
+ export declare function checkoutTargetFromText(text: string): CheckoutTarget | null;
9
+ /** The task's own words: title first, then the description. */
10
+ export declare function checkoutTargetFromTask(task: {
11
+ title?: string | null;
12
+ description?: string | null;
13
+ }): CheckoutTarget | null;
14
+ /** The branch a task's change lands on: one per task, never the default branch. */
15
+ export declare function checkoutBranchForTask(taskId: string): string;
@@ -0,0 +1,65 @@
1
+ /** `owner/name`, the way GitHub spells it. */
2
+ const REPOSITORY = /(?<![\w./-])([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9_.-]{1,100})(?![\w./-])/g;
3
+ /** Words that look like `a/b` in prose but are never a repository. */
4
+ const NOT_REPOSITORIES = new Set(['and/or', 'yes/no', 'on/off', 'read/write', 'i/o', 'he/she', 'his/her', 'src/']);
5
+ const BASE_BRANCH = /\b(?:base|target|from|off|onto|against)\s+(?:the\s+)?branch\s+[`"']?([A-Za-z0-9._\/-]+)[`"']?|\bbranch\s*(?::|=)\s*[`"']?([A-Za-z0-9._\/-]+)[`"']?|\bbase\s*(?::|=)\s*[`"']?([A-Za-z0-9._\/-]+)[`"']?/i;
6
+ /**
7
+ * What the task names. A task for a checkout agent names a repository as
8
+ * `owner/name` (a GitHub URL counts) and may name a base branch; when it does
9
+ * not, the repository's default branch is used. Nothing is guessed: a task
10
+ * naming no repository is refused with that said, not pointed at a likely one.
11
+ */
12
+ export function checkoutTargetFromText(text) {
13
+ if (!text)
14
+ return null;
15
+ const fromUrl = /github\.com[/:]([A-Za-z0-9-]+)\/([A-Za-z0-9_.-]+)(?:[/\s)>,]|$)/.exec(text);
16
+ let owner;
17
+ let name;
18
+ if (fromUrl) {
19
+ owner = fromUrl[1];
20
+ name = fromUrl[2];
21
+ }
22
+ else {
23
+ for (const match of text.matchAll(REPOSITORY)) {
24
+ const candidate = `${match[1]}/${match[2]}`;
25
+ if (NOT_REPOSITORIES.has(candidate.toLowerCase()))
26
+ continue;
27
+ // A path like `src/pages` reads as a repository to the regex; a repository
28
+ // owner is an account, which never contains a dot, and the name never a
29
+ // second slash. The first `owner/name` the task mentions wins.
30
+ if (/^[a-z]+$/.test(match[1]) && /^(src|app|lib|pages|components|docs|test|tests|scripts|public|assets|dist|build|node_modules|packages|api|config|styles|utils|hooks|types)$/.test(match[1]))
31
+ continue;
32
+ owner = match[1];
33
+ name = match[2];
34
+ break;
35
+ }
36
+ }
37
+ if (!owner || !name)
38
+ return null;
39
+ name = name.replace(/[.,;:]+$/, '').replace(/\.git$/, '');
40
+ if (!name || name === '.' || name === '..')
41
+ return null;
42
+ const base = BASE_BRANCH.exec(text);
43
+ const baseBranch = (base?.[1] ?? base?.[2] ?? base?.[3] ?? null)?.replace(/[.,;:]+$/, "") || null;
44
+ return {
45
+ repository: `${owner}/${name}`,
46
+ owner,
47
+ name,
48
+ baseBranch: baseBranch && baseBranch !== owner ? baseBranch : null,
49
+ };
50
+ }
51
+ /** The task's own words: title first, then the description. */
52
+ export function checkoutTargetFromTask(task) {
53
+ const title = String(task.title ?? '');
54
+ const description = String(task.description ?? '');
55
+ const target = checkoutTargetFromText(title) ?? checkoutTargetFromText(description);
56
+ if (!target)
57
+ return null;
58
+ const base = BASE_BRANCH.exec(`${title}\n${description}`);
59
+ target.baseBranch = (base?.[1] ?? base?.[2] ?? '').replace(/[.,;:]+$/, '') || null;
60
+ return target;
61
+ }
62
+ /** The branch a task's change lands on: one per task, never the default branch. */
63
+ export function checkoutBranchForTask(taskId) {
64
+ return `agent/${String(taskId).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`;
65
+ }
@@ -0,0 +1,49 @@
1
+ import type { ConnectionWithGrants } from '@ziggs-ai/api-client';
2
+ import { type GitDoorTarget } from './boxClient.js';
3
+ import type { CheckoutSession } from './tools.js';
4
+ import type { CheckoutBox, CheckoutProvider } from './types.js';
5
+ import { type AgentNeed } from '../tools/needs.js';
6
+ /** A box that is open for one wake, and when it started, for the cost line. */
7
+ export interface OpenCheckout {
8
+ session: CheckoutSession;
9
+ box: CheckoutBox;
10
+ startedAt: number;
11
+ saveCheckpoint: () => Promise<void>;
12
+ }
13
+ export interface OpenCheckoutDeps {
14
+ provider: CheckoutProvider | undefined;
15
+ needs: AgentNeed[];
16
+ /** The task this wake is for; no task, no checkout. */
17
+ taskId: string | undefined;
18
+ getTask(taskId: string): Promise<{
19
+ title?: string | null;
20
+ description?: string | null;
21
+ }>;
22
+ /** The agent's live connection grants, grouped by connection. */
23
+ listConnections(): Promise<ConnectionWithGrants[]>;
24
+ door: Pick<GitDoorTarget, 'backendUrl' | 'operatorKey' | 'agentId' | 'laneId'>;
25
+ /** Close the task with the reason, the way the needs gate does. */
26
+ failTask(taskId: string, reason: string): Promise<void>;
27
+ log: (message: string) => void;
28
+ fetchFn?: typeof fetch;
29
+ /** Hard cap for the box, seconds. Default one hour. */
30
+ ttlSeconds?: number;
31
+ /** How long to wait for the box to answer. Default five minutes. */
32
+ readyTimeoutMs?: number;
33
+ }
34
+ /**
35
+ * Open the checkout a wake needs, or refuse the task by name.
36
+ *
37
+ * Returns null when this wake needs no checkout (the agent declares none, or
38
+ * the wake carries no task), `'refused'` when the task was failed here with
39
+ * the reason, and the open box otherwise. The caller destroys the box when
40
+ * the wake ends ({@link closeCheckout}).
41
+ */
42
+ export declare function openCheckout(deps: OpenCheckoutDeps): Promise<OpenCheckout | null | 'refused'>;
43
+ /** Destroy the box. Returns how long it lived, for the cost line. */
44
+ export declare function closeCheckout(open: OpenCheckout): Promise<{
45
+ kind: string;
46
+ seconds: number;
47
+ vcpu?: number;
48
+ memoryMb?: number;
49
+ }>;
@@ -0,0 +1,190 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { CheckoutBoxClient, doorHeaders } from './boxClient.js';
3
+ import { checkoutBranchForTask, checkoutTargetFromTask } from './intake.js';
4
+ import { isCheckoutNeed, unmetNeedsMessage } from '../tools/needs.js';
5
+ /** Connection providers whose grant can back a checkout. */
6
+ function canBackCheckout(provider) {
7
+ const p = String(provider ?? '').split('—')[0].trim().toLowerCase();
8
+ return p === 'github' || p === 'mcp';
9
+ }
10
+ /**
11
+ * Open the checkout a wake needs, or refuse the task by name.
12
+ *
13
+ * Returns null when this wake needs no checkout (the agent declares none, or
14
+ * the wake carries no task), `'refused'` when the task was failed here with
15
+ * the reason, and the open box otherwise. The caller destroys the box when
16
+ * the wake ends ({@link closeCheckout}).
17
+ */
18
+ export async function openCheckout(deps) {
19
+ if (!deps.needs.some(isCheckoutNeed))
20
+ return null;
21
+ const taskId = deps.taskId;
22
+ if (!taskId)
23
+ return null;
24
+ const fetchFn = deps.fetchFn ?? fetch;
25
+ const refuse = async (reason) => {
26
+ deps.log(`refusing task ${taskId} — ${reason}`);
27
+ try {
28
+ await deps.failTask(taskId, reason);
29
+ }
30
+ catch (err) {
31
+ deps.log(`could not fail task ${taskId}: ${err.message}`);
32
+ }
33
+ return 'refused';
34
+ };
35
+ let task;
36
+ try {
37
+ task = await deps.getTask(taskId);
38
+ }
39
+ catch (err) {
40
+ // Reading failed before any effect: let the host retry, without failing the task.
41
+ throw new Error(`Checkout task ${taskId} could not be read: ${err.message}`, { cause: err });
42
+ }
43
+ const target = checkoutTargetFromTask(task);
44
+ if (!target) {
45
+ return refuse('Cannot start this task: it names no repository. Name it as owner/name (a GitHub URL also works), and the base branch if it is not the default branch.');
46
+ }
47
+ if (!deps.provider) {
48
+ return refuse(`Cannot start this task: it needs a checkout of ${target.repository} and this host provides none.`);
49
+ }
50
+ let groups;
51
+ try {
52
+ groups = await deps.listConnections();
53
+ }
54
+ catch (err) {
55
+ return refuse(`Cannot start this task: the connection grants could not be read (${err.message}).`);
56
+ }
57
+ const candidates = groups.filter((g) => (g.grants ?? []).length > 0 && canBackCheckout(g.provider));
58
+ if (!candidates.length)
59
+ return refuse(unmetNeedsMessage([{ checkout: 'git' }]));
60
+ // The grant that can actually see the repository: asked of the door, which
61
+ // answers for the connection it fronts. A grant on another MCP server, or a
62
+ // GitHub account that cannot see this repository, is named rather than tried.
63
+ let door = null;
64
+ const tried = [];
65
+ candidatesLoop: for (const group of candidates) {
66
+ for (const grant of group.grants ?? []) {
67
+ const grantId = String(grant.grantId ?? '');
68
+ if (!grantId)
69
+ continue;
70
+ const candidate = {
71
+ ...deps.door,
72
+ connectionId: group.connectionId,
73
+ grantId,
74
+ taskId,
75
+ repository: { owner: target.owner, name: target.name },
76
+ };
77
+ try {
78
+ const probe = await fetchFn(`${deps.door.backendUrl}/connections/${encodeURIComponent(group.connectionId)}/git/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.name)}/info/refs?service=git-upload-pack`, { headers: doorHeaders(candidate, {}), signal: AbortSignal.timeout(30_000) });
79
+ const text = await probe.text();
80
+ if (probe.ok) {
81
+ door = candidate;
82
+ break candidatesLoop;
83
+ }
84
+ tried.push(`${group.connectionId}: ${probe.status} ${text.slice(0, 160).replace(/\s+/g, ' ')}`);
85
+ }
86
+ catch (err) {
87
+ tried.push(`${group.connectionId}: ${err.message}`);
88
+ }
89
+ }
90
+ }
91
+ if (!door) {
92
+ return refuse(`Cannot start this task: no GitHub connection grant on this hire can read ${target.repository} (${tried.join('; ')}). ` +
93
+ 'Ask the connection owner to grant a GitHub connection that can see the repository, then re-assign the task.');
94
+ }
95
+ const branch = checkoutBranchForTask(taskId);
96
+ const secret = randomBytes(32).toString('hex');
97
+ const startedAt = Date.now();
98
+ let box;
99
+ try {
100
+ box = await deps.provider.start({
101
+ secret,
102
+ ttlSeconds: deps.ttlSeconds ?? 3600,
103
+ label: { agentId: deps.door.agentId, taskId, repository: target.repository },
104
+ });
105
+ }
106
+ catch (err) {
107
+ return refuse(`Cannot start this task: the host could not start a box for the checkout (${err.message}).`);
108
+ }
109
+ const client = new CheckoutBoxClient(box.endpoint, fetchFn);
110
+ try {
111
+ await client.waitReady(deps.readyTimeoutMs ?? 5 * 60_000);
112
+ client.startRelay(door);
113
+ const ready = await client.setup({ repository: target.repository, baseBranch: target.baseBranch, branch });
114
+ const key = createHash('sha256').update(JSON.stringify([
115
+ deps.door.agentId, deps.door.laneId ?? null, taskId,
116
+ target.repository.toLowerCase(), ready.baseBranch,
117
+ ])).digest('hex');
118
+ const store = deps.provider.checkpoints;
119
+ const saved = await store?.load(key);
120
+ if (saved)
121
+ await client.restore(saved);
122
+ let checkpointFailed = false;
123
+ const saveCheckpoint = async () => {
124
+ if (!store || checkpointFailed)
125
+ return;
126
+ try {
127
+ await store.save(key, await client.checkpoint());
128
+ }
129
+ catch (error) {
130
+ // Do not replace a tool result, retry a completed wake, or repeatedly
131
+ // archive an oversized repository. Keep the last recoverable snapshot.
132
+ checkpointFailed = true;
133
+ client.warnCheckpoint(`Private checkpoint save failed: ${error.message}. Checkpoints are disabled for this wake; push the task branch to preserve current work.`);
134
+ }
135
+ };
136
+ if (store)
137
+ client.onMutation = saveCheckpoint;
138
+ else
139
+ client.warnCheckpoint('Private checkpoints are disabled. Only work pushed to the task branch survives this wake.');
140
+ deps.log(`checkout open: ${target.repository} at ${ready.baseBranch} (${ready.head.slice(0, 10)}) on ${branch}` +
141
+ `${saved ? ', restored private task checkpoint' : ready.resumed ? ', continuing the branch an earlier wake pushed' : ''}, ${box.kind} box`);
142
+ return {
143
+ box,
144
+ startedAt,
145
+ saveCheckpoint,
146
+ session: {
147
+ client,
148
+ target,
149
+ baseBranch: ready.baseBranch,
150
+ branch,
151
+ taskId,
152
+ boxKind: box.kind,
153
+ },
154
+ };
155
+ }
156
+ catch (err) {
157
+ await client.stopRelay();
158
+ await box.stop();
159
+ // A storage/readiness/clone outage must not permanently fail the task.
160
+ // In particular, never continue from a fresh clone if loading a configured
161
+ // checkpoint failed: doing so could overwrite recoverable work.
162
+ throw new Error(`Could not open checkout of ${target.repository}: ${err.message}`, { cause: err });
163
+ }
164
+ }
165
+ /** Destroy the box. Returns how long it lived, for the cost line. */
166
+ export async function closeCheckout(open) {
167
+ try {
168
+ await open.session.client.quiesce();
169
+ await open.saveCheckpoint();
170
+ }
171
+ finally {
172
+ try {
173
+ await open.session.client.stop();
174
+ }
175
+ finally {
176
+ try {
177
+ await open.session.client.stopRelay();
178
+ }
179
+ finally {
180
+ await open.box.stop();
181
+ }
182
+ }
183
+ }
184
+ return {
185
+ kind: open.box.kind,
186
+ seconds: Math.max(1, Math.round((Date.now() - open.startedAt) / 1000)),
187
+ ...(open.box.resources?.vcpu !== undefined ? { vcpu: open.box.resources.vcpu } : {}),
188
+ ...(open.box.resources?.memoryMb !== undefined ? { memoryMb: open.box.resources.memoryMb } : {}),
189
+ };
190
+ }