@agentproto/worktree 0.2.0 → 0.3.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.
- package/LICENSE +202 -21
- package/README.md +86 -4
- package/dist/bin/worktree-agent.mjs +4 -4
- package/dist/{chunk-P7FOHOQ3.mjs → chunk-CBEWP5OT.mjs} +13 -5
- package/dist/chunk-CBEWP5OT.mjs.map +1 -0
- package/dist/chunk-CM3XGWTY.mjs +155 -0
- package/dist/chunk-CM3XGWTY.mjs.map +1 -0
- package/dist/chunk-E3EM24BS.mjs +3 -0
- package/dist/{chunk-PSFICTF7.mjs.map → chunk-E3EM24BS.mjs.map} +1 -1
- package/dist/chunk-SEVCUXPY.mjs +1176 -0
- package/dist/chunk-SEVCUXPY.mjs.map +1 -0
- package/dist/index.d.ts +1076 -6
- package/dist/index.mjs +595 -4
- package/dist/index.mjs.map +1 -1
- package/dist/provider/index.mjs +2 -2
- package/dist/tools/index.d.ts +114 -2
- package/dist/tools/index.mjs +2 -2
- package/package.json +6 -6
- package/dist/chunk-BBAH3VLQ.mjs +0 -170
- package/dist/chunk-BBAH3VLQ.mjs.map +0 -1
- package/dist/chunk-FRVPZTQN.mjs +0 -67
- package/dist/chunk-FRVPZTQN.mjs.map +0 -1
- package/dist/chunk-P7FOHOQ3.mjs.map +0 -1
- package/dist/chunk-PSFICTF7.mjs +0 -3
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
import { provisionWorktreeTool, cleanupWorktreeTool, runGateTool, runScriptTool, startServiceTool, stopServiceTool, listServicesTool } from './chunk-CM3XGWTY.mjs';
|
|
2
|
+
import { implementTool, defineDriver } from '@agentproto/driver';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { lstat, mkdir, symlink, copyFile, writeFile, readdir, stat, readFile } from 'fs/promises';
|
|
5
|
+
import { resolve, join, dirname, posix, basename, sep } from 'path';
|
|
6
|
+
import { ToolError } from '@agentproto/tool';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { createServer } from 'net';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @agentproto/worktree v0.1.0
|
|
14
|
+
* git-worktree provision / gate / cleanup TOOL contracts + builtin PROVIDER.
|
|
15
|
+
*/
|
|
16
|
+
function mergedEnv(extra) {
|
|
17
|
+
return extra ? { ...process.env, ...extra } : process.env;
|
|
18
|
+
}
|
|
19
|
+
function execArgv(command, args, cwd, opts = {}) {
|
|
20
|
+
return new Promise((resolvePromise, reject) => {
|
|
21
|
+
const child = spawn(command, args, { cwd, shell: false, env: mergedEnv(opts.env) });
|
|
22
|
+
let stdout = "";
|
|
23
|
+
let stderr = "";
|
|
24
|
+
child.stdout?.on("data", (d) => stdout += d.toString("utf8"));
|
|
25
|
+
child.stderr?.on("data", (d) => stderr += d.toString("utf8"));
|
|
26
|
+
child.on("error", reject);
|
|
27
|
+
child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stdout, stderr }));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function execShell(cmd, cwd, opts = {}) {
|
|
31
|
+
return new Promise((resolvePromise, reject) => {
|
|
32
|
+
const child = spawn(cmd, { cwd, shell: true, env: mergedEnv(opts.env) });
|
|
33
|
+
let stdout = "";
|
|
34
|
+
let stderr = "";
|
|
35
|
+
child.stdout?.on("data", (d) => stdout += d.toString("utf8"));
|
|
36
|
+
child.stderr?.on("data", (d) => stderr += d.toString("utf8"));
|
|
37
|
+
child.on("error", reject);
|
|
38
|
+
child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stdout, stderr }));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function execGit(repoRoot, args) {
|
|
42
|
+
const result = await execArgv("git", ["-C", repoRoot, ...args], repoRoot);
|
|
43
|
+
if (result.exitCode !== 0) {
|
|
44
|
+
throw new Error(`git ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function escapeSegment(seg) {
|
|
49
|
+
let out = "";
|
|
50
|
+
for (const ch of seg) {
|
|
51
|
+
if (ch === "*") out += "[^/]*";
|
|
52
|
+
else if (ch === "?") out += "[^/]";
|
|
53
|
+
else out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function globToRegExp(pattern) {
|
|
58
|
+
const segments = pattern.split("/");
|
|
59
|
+
let out = "^";
|
|
60
|
+
segments.forEach((seg, i) => {
|
|
61
|
+
const isLast = i === segments.length - 1;
|
|
62
|
+
if (seg === "**") {
|
|
63
|
+
out += isLast ? ".*" : "(?:[^/]+/)*";
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
out += escapeSegment(seg);
|
|
67
|
+
if (!isLast) out += "/";
|
|
68
|
+
});
|
|
69
|
+
return new RegExp(out + "$");
|
|
70
|
+
}
|
|
71
|
+
function globBaseDir(pattern) {
|
|
72
|
+
const segments = pattern.split("/");
|
|
73
|
+
const literal = [];
|
|
74
|
+
for (const seg of segments) {
|
|
75
|
+
if (seg.includes("*") || seg.includes("?")) break;
|
|
76
|
+
literal.push(seg);
|
|
77
|
+
}
|
|
78
|
+
if (literal.length === segments.length) literal.pop();
|
|
79
|
+
return literal.join("/");
|
|
80
|
+
}
|
|
81
|
+
async function walk(root, dirRel) {
|
|
82
|
+
const abs = dirRel ? join(root, dirRel) : root;
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
92
|
+
const rel = dirRel ? posix.join(dirRel, entry.name) : entry.name;
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
const children = await walk(root, rel);
|
|
95
|
+
for (const child of children) out.push(child);
|
|
96
|
+
} else if (entry.isFile()) {
|
|
97
|
+
out.push(rel);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
async function expandGlob(root, pattern) {
|
|
103
|
+
const regex = globToRegExp(pattern);
|
|
104
|
+
const base = globBaseDir(pattern);
|
|
105
|
+
const candidates = await walk(root, base);
|
|
106
|
+
return candidates.filter((rel) => regex.test(rel));
|
|
107
|
+
}
|
|
108
|
+
var CONFIG_FILENAME = "agentproto.json";
|
|
109
|
+
var hookSchema = z.union([z.string(), z.array(z.string())]);
|
|
110
|
+
var scriptSchema = z.object({
|
|
111
|
+
command: z.string().min(1, "script command must be non-empty"),
|
|
112
|
+
type: z.literal("service").optional(),
|
|
113
|
+
port: z.number().int().min(1).max(65535).optional()
|
|
114
|
+
});
|
|
115
|
+
var configSchema = z.object({
|
|
116
|
+
worktree: z.object({
|
|
117
|
+
setup: hookSchema.optional(),
|
|
118
|
+
teardown: hookSchema.optional()
|
|
119
|
+
}).optional(),
|
|
120
|
+
scripts: z.record(z.string(), scriptSchema).optional()
|
|
121
|
+
});
|
|
122
|
+
var ConfigError = class extends Error {
|
|
123
|
+
constructor(message) {
|
|
124
|
+
super(message);
|
|
125
|
+
this.name = "ConfigError";
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
function parseConfig(raw) {
|
|
129
|
+
let json;
|
|
130
|
+
try {
|
|
131
|
+
json = JSON.parse(raw);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new ConfigError(
|
|
134
|
+
`${CONFIG_FILENAME} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const result = configSchema.safeParse(json);
|
|
138
|
+
if (!result.success) {
|
|
139
|
+
const issues = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
|
|
140
|
+
throw new ConfigError(`${CONFIG_FILENAME} failed validation: ${issues}`);
|
|
141
|
+
}
|
|
142
|
+
return result.data;
|
|
143
|
+
}
|
|
144
|
+
function normalizeHook(hook) {
|
|
145
|
+
if (hook === void 0) return [];
|
|
146
|
+
return typeof hook === "string" ? [hook] : [...hook];
|
|
147
|
+
}
|
|
148
|
+
function listScripts(config) {
|
|
149
|
+
return Object.entries(config.scripts ?? {}).map(([name, cfg]) => ({ name, ...cfg }));
|
|
150
|
+
}
|
|
151
|
+
function listServices(config) {
|
|
152
|
+
return listScripts(config).filter((s) => s.type === "service");
|
|
153
|
+
}
|
|
154
|
+
function getScript(config, name) {
|
|
155
|
+
const cfg = config.scripts?.[name];
|
|
156
|
+
return cfg ? { name, ...cfg } : void 0;
|
|
157
|
+
}
|
|
158
|
+
async function loadConfigFromBase(repoRoot, base = "origin/main") {
|
|
159
|
+
const spec = `${base}:${CONFIG_FILENAME}`;
|
|
160
|
+
const result = await execArgv("git", ["-C", repoRoot, "show", spec], repoRoot);
|
|
161
|
+
if (result.exitCode !== 0) {
|
|
162
|
+
const stderr = result.stderr.toLowerCase();
|
|
163
|
+
const pathAbsent = stderr.includes("does not exist") || stderr.includes("exists on disk, but not in");
|
|
164
|
+
const refAbsent = stderr.includes("unknown revision") || stderr.includes("bad revision") || stderr.includes("invalid object name") || stderr.includes("ambiguous argument");
|
|
165
|
+
if (pathAbsent || refAbsent) return null;
|
|
166
|
+
throw new ConfigError(
|
|
167
|
+
`could not read ${CONFIG_FILENAME} from '${spec}': ${result.stderr.trim() || `git exited ${result.exitCode}`}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return parseConfig(result.stdout);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/env.ts
|
|
174
|
+
var ENV_VARS = {
|
|
175
|
+
/** Absolute path to the original repo checkout the worktree was cut from. */
|
|
176
|
+
sourceCheckoutPath: "AGENTPROTO_SOURCE_CHECKOUT_PATH",
|
|
177
|
+
/** Absolute path to the worktree directory. */
|
|
178
|
+
worktreePath: "AGENTPROTO_WORKTREE_PATH",
|
|
179
|
+
/** The worktree's branch name. */
|
|
180
|
+
branchName: "AGENTPROTO_BRANCH_NAME",
|
|
181
|
+
/** A service's own allocated port. */
|
|
182
|
+
port: "AGENTPROTO_PORT",
|
|
183
|
+
/** A service's own reverse-proxy URL. */
|
|
184
|
+
url: "AGENTPROTO_URL"
|
|
185
|
+
};
|
|
186
|
+
function hookEnv(ctx) {
|
|
187
|
+
return {
|
|
188
|
+
[ENV_VARS.sourceCheckoutPath]: ctx.sourceCheckoutPath,
|
|
189
|
+
[ENV_VARS.worktreePath]: ctx.worktreePath,
|
|
190
|
+
[ENV_VARS.branchName]: ctx.branchName
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function serviceEnvToken(name) {
|
|
194
|
+
return name.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
195
|
+
}
|
|
196
|
+
function peerEnvKey(name, suffix) {
|
|
197
|
+
return `AGENTPROTO_SERVICE_${serviceEnvToken(name)}_${suffix}`;
|
|
198
|
+
}
|
|
199
|
+
function peerEnv(peers) {
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const peer of peers) {
|
|
202
|
+
out[peerEnvKey(peer.name, "PORT")] = String(peer.port);
|
|
203
|
+
out[peerEnvKey(peer.name, "URL")] = peer.url;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function serviceEnv(args) {
|
|
208
|
+
return {
|
|
209
|
+
...hookEnv(args.ctx),
|
|
210
|
+
[ENV_VARS.port]: String(args.self.port),
|
|
211
|
+
[ENV_VARS.url]: args.self.url,
|
|
212
|
+
...peerEnv(args.peers)
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/lifecycle.ts
|
|
217
|
+
var HookError = class extends Error {
|
|
218
|
+
command;
|
|
219
|
+
result;
|
|
220
|
+
constructor(phase, run) {
|
|
221
|
+
super(
|
|
222
|
+
`worktree ${phase} hook failed (exit ${run.result.exitCode}): ${run.command}
|
|
223
|
+
` + (run.result.stderr || run.result.stdout).trim()
|
|
224
|
+
);
|
|
225
|
+
this.name = "HookError";
|
|
226
|
+
this.command = run.command;
|
|
227
|
+
this.result = run.result;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
async function runSetup(config, ctx) {
|
|
231
|
+
const commands = normalizeHook(config.worktree?.setup);
|
|
232
|
+
const runs = [];
|
|
233
|
+
const env = hookEnv(ctx);
|
|
234
|
+
for (const command of commands) {
|
|
235
|
+
const result = await execShell(command, ctx.worktreePath, { env });
|
|
236
|
+
const run = { command, result };
|
|
237
|
+
runs.push(run);
|
|
238
|
+
if (result.exitCode !== 0) throw new HookError("setup", run);
|
|
239
|
+
}
|
|
240
|
+
return runs;
|
|
241
|
+
}
|
|
242
|
+
async function runTeardown(config, ctx) {
|
|
243
|
+
const commands = normalizeHook(config.worktree?.teardown);
|
|
244
|
+
const runs = [];
|
|
245
|
+
const env = hookEnv(ctx);
|
|
246
|
+
for (const command of commands) {
|
|
247
|
+
const result = await execShell(command, ctx.worktreePath, { env });
|
|
248
|
+
runs.push({ command, result });
|
|
249
|
+
}
|
|
250
|
+
return runs;
|
|
251
|
+
}
|
|
252
|
+
var SESSIONS_FILE_PATH = () => resolve(homedir(), ".agentproto", "sessions.json");
|
|
253
|
+
var sessionRefSchema = z.object({
|
|
254
|
+
id: z.string(),
|
|
255
|
+
label: z.string().optional(),
|
|
256
|
+
startedAt: z.string(),
|
|
257
|
+
endedAt: z.string().optional(),
|
|
258
|
+
status: z.string(),
|
|
259
|
+
cwd: z.string().optional()
|
|
260
|
+
});
|
|
261
|
+
var sessionsFileSchema = z.object({ sessions: z.array(z.unknown()) });
|
|
262
|
+
async function readSessionsRegistry(path = SESSIONS_FILE_PATH()) {
|
|
263
|
+
let raw;
|
|
264
|
+
try {
|
|
265
|
+
raw = await readFile(path, "utf8");
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (typeof err === "object" && err !== null && Reflect.get(err, "code") === "ENOENT") return [];
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
let parsed;
|
|
271
|
+
try {
|
|
272
|
+
parsed = JSON.parse(raw);
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const file = sessionsFileSchema.safeParse(parsed);
|
|
277
|
+
if (!file.success) return null;
|
|
278
|
+
const refs = [];
|
|
279
|
+
for (const entry of file.data.sessions) {
|
|
280
|
+
const ref = sessionRefSchema.safeParse(entry);
|
|
281
|
+
if (ref.success) refs.push(ref.data);
|
|
282
|
+
}
|
|
283
|
+
return refs;
|
|
284
|
+
}
|
|
285
|
+
function sessionInWorktree(session, worktreePath) {
|
|
286
|
+
if (session.cwd === void 0) return false;
|
|
287
|
+
return session.cwd === worktreePath || session.cwd.startsWith(worktreePath + sep);
|
|
288
|
+
}
|
|
289
|
+
var worktreeMarkerSchema = z.object({
|
|
290
|
+
worktreeId: z.string(),
|
|
291
|
+
createdAt: z.string(),
|
|
292
|
+
createdBySessionId: z.string().optional()
|
|
293
|
+
});
|
|
294
|
+
async function readWorktreeMarker(worktreePath) {
|
|
295
|
+
const gitDirRes = await execArgv(
|
|
296
|
+
"git",
|
|
297
|
+
["-C", worktreePath, "rev-parse", "--path-format=absolute", "--git-dir"],
|
|
298
|
+
worktreePath
|
|
299
|
+
);
|
|
300
|
+
if (gitDirRes.exitCode !== 0) return null;
|
|
301
|
+
const gitDir = gitDirRes.stdout.trim();
|
|
302
|
+
if (!gitDir) return null;
|
|
303
|
+
let raw;
|
|
304
|
+
try {
|
|
305
|
+
raw = await readFile(resolve(gitDir, "agentproto-worktree.json"), "utf8");
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
let parsed;
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(raw);
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const result = worktreeMarkerSchema.safeParse(parsed);
|
|
316
|
+
return result.success ? result.data : null;
|
|
317
|
+
}
|
|
318
|
+
async function writeWorktreeMarker(worktreePath, marker) {
|
|
319
|
+
const gitDirRes = await execArgv(
|
|
320
|
+
"git",
|
|
321
|
+
["-C", worktreePath, "rev-parse", "--path-format=absolute", "--git-dir"],
|
|
322
|
+
worktreePath
|
|
323
|
+
);
|
|
324
|
+
if (gitDirRes.exitCode !== 0) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`git rev-parse --git-dir failed in ${worktreePath} (exit ${gitDirRes.exitCode}): ${gitDirRes.stderr.trim()}`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const gitDir = gitDirRes.stdout.trim();
|
|
330
|
+
await writeFile(resolve(gitDir, "agentproto-worktree.json"), JSON.stringify(marker, null, 2) + "\n", "utf8");
|
|
331
|
+
}
|
|
332
|
+
async function computeProvenance(worktreePath, options = {}) {
|
|
333
|
+
const [registry, marker] = await Promise.all([
|
|
334
|
+
readSessionsRegistry(options.sessionsPath ?? SESSIONS_FILE_PATH()),
|
|
335
|
+
readWorktreeMarker(worktreePath)
|
|
336
|
+
]);
|
|
337
|
+
const sessions = registry ?? [];
|
|
338
|
+
const matched = sessions.filter((s) => sessionInWorktree(s, worktreePath)).filter((s) => marker ? s.startedAt >= marker.createdAt : true).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
339
|
+
return {
|
|
340
|
+
confidence: marker ? "exact" : "best-effort",
|
|
341
|
+
sessions: matched
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/provider/bodies/provision-worktree.body.ts
|
|
346
|
+
var provisionWorktreeBuiltin = implementTool(
|
|
347
|
+
provisionWorktreeTool,
|
|
348
|
+
async ({ input }) => {
|
|
349
|
+
const base = input.base ?? "origin/main";
|
|
350
|
+
const branch = input.branch ?? `wt/${input.slug}`;
|
|
351
|
+
const cwd = input.dir ? resolve(input.dir) : resolve(input.repoRoot, "..", "_worktrees", input.slug);
|
|
352
|
+
await execGit(input.repoRoot, ["worktree", "add", "-b", branch, cwd, base]);
|
|
353
|
+
await writeWorktreeMarker(cwd, {
|
|
354
|
+
worktreeId: `wt_${randomUUID().slice(0, 8)}`,
|
|
355
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
356
|
+
});
|
|
357
|
+
for (const rel of input.linkPaths ?? []) {
|
|
358
|
+
const target = resolve(input.repoRoot, rel);
|
|
359
|
+
const dest = join(cwd, rel);
|
|
360
|
+
const existing = await lstat(dest).catch(() => null);
|
|
361
|
+
if (existing) continue;
|
|
362
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
363
|
+
await symlink(target, dest, "dir");
|
|
364
|
+
}
|
|
365
|
+
if (input.depsCmd) {
|
|
366
|
+
const result = await execShell(input.depsCmd, cwd);
|
|
367
|
+
if (result.exitCode !== 0) {
|
|
368
|
+
throw new ToolError({
|
|
369
|
+
code: "execution_failed",
|
|
370
|
+
message: `depsCmd '${input.depsCmd}' failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const pattern of input.copyGlobs ?? []) {
|
|
375
|
+
const matches = await expandGlob(input.repoRoot, pattern);
|
|
376
|
+
for (const rel of matches) {
|
|
377
|
+
const dest = join(cwd, rel);
|
|
378
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
379
|
+
await copyFile(join(input.repoRoot, rel), dest);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (input.runSetup !== false) {
|
|
383
|
+
const config = await loadConfigFromBase(input.repoRoot, base);
|
|
384
|
+
if (config) {
|
|
385
|
+
try {
|
|
386
|
+
await runSetup(config, {
|
|
387
|
+
sourceCheckoutPath: input.repoRoot,
|
|
388
|
+
worktreePath: cwd,
|
|
389
|
+
branchName: branch
|
|
390
|
+
});
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (err instanceof HookError) {
|
|
393
|
+
throw new ToolError({ code: "execution_failed", message: err.message });
|
|
394
|
+
}
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { cwd, branch };
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
function isPortFree(port) {
|
|
403
|
+
return new Promise((resolve4) => {
|
|
404
|
+
const server = createServer();
|
|
405
|
+
server.once("error", () => resolve4(false));
|
|
406
|
+
server.once("listening", () => {
|
|
407
|
+
server.close(() => resolve4(true));
|
|
408
|
+
});
|
|
409
|
+
server.listen(port, "127.0.0.1");
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
function ephemeralPort() {
|
|
413
|
+
return new Promise((resolve4, reject) => {
|
|
414
|
+
const server = createServer();
|
|
415
|
+
server.once("error", reject);
|
|
416
|
+
server.listen(0, "127.0.0.1", () => {
|
|
417
|
+
const address = server.address();
|
|
418
|
+
if (address && typeof address === "object") {
|
|
419
|
+
const port = address.port;
|
|
420
|
+
server.close(() => resolve4(port));
|
|
421
|
+
} else {
|
|
422
|
+
server.close(() => reject(new Error("could not determine an ephemeral port")));
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
async function allocatePort(preferred, reserved = /* @__PURE__ */ new Set()) {
|
|
428
|
+
if (preferred !== void 0 && !reserved.has(preferred) && await isPortFree(preferred)) {
|
|
429
|
+
return preferred;
|
|
430
|
+
}
|
|
431
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
432
|
+
const port = await ephemeralPort();
|
|
433
|
+
if (!reserved.has(port)) return port;
|
|
434
|
+
}
|
|
435
|
+
throw new Error("could not allocate a free port after 20 attempts");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/services/slug.ts
|
|
439
|
+
function slugify(input) {
|
|
440
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
441
|
+
}
|
|
442
|
+
function serviceHostname(parts) {
|
|
443
|
+
const labels = parts.isDefaultBranch ? [slugify(parts.script), slugify(parts.repo)] : [slugify(parts.script), slugify(parts.branch), slugify(parts.repo)];
|
|
444
|
+
return `${labels.join("--")}.localhost`;
|
|
445
|
+
}
|
|
446
|
+
function serviceUrl(parts, proxyPort) {
|
|
447
|
+
return `http://${serviceHostname(parts)}:${proxyPort}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/services/supervisor.ts
|
|
451
|
+
var ServiceSupervisor = class _ServiceSupervisor {
|
|
452
|
+
opts;
|
|
453
|
+
tracked = /* @__PURE__ */ new Map();
|
|
454
|
+
constructor(opts, tracked) {
|
|
455
|
+
this.opts = opts;
|
|
456
|
+
this.tracked = tracked;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Build a supervisor, allocating a port for every declared service (declared
|
|
460
|
+
* port if free, else ephemeral) and resolving each one's hostname + URL.
|
|
461
|
+
* Nothing is spawned yet.
|
|
462
|
+
*/
|
|
463
|
+
static async create(opts) {
|
|
464
|
+
const tracked = /* @__PURE__ */ new Map();
|
|
465
|
+
const reserved = /* @__PURE__ */ new Set();
|
|
466
|
+
for (const svc of opts.services) {
|
|
467
|
+
const port = await allocatePort(svc.port, reserved);
|
|
468
|
+
reserved.add(port);
|
|
469
|
+
const hostname = serviceHostname({
|
|
470
|
+
script: svc.name,
|
|
471
|
+
branch: opts.ctx.branchName,
|
|
472
|
+
repo: opts.repo,
|
|
473
|
+
isDefaultBranch: opts.isDefaultBranch
|
|
474
|
+
});
|
|
475
|
+
const runtime = {
|
|
476
|
+
name: svc.name,
|
|
477
|
+
command: svc.command,
|
|
478
|
+
hostname,
|
|
479
|
+
port,
|
|
480
|
+
url: `http://${hostname}:${opts.proxyPort}`
|
|
481
|
+
};
|
|
482
|
+
tracked.set(svc.name, {
|
|
483
|
+
runtime,
|
|
484
|
+
child: null,
|
|
485
|
+
status: "exited",
|
|
486
|
+
exitCode: null,
|
|
487
|
+
startedAt: null
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return new _ServiceSupervisor(opts, tracked);
|
|
491
|
+
}
|
|
492
|
+
/** Every declared service's resolved runtime (ports/urls), regardless of state. */
|
|
493
|
+
runtimes() {
|
|
494
|
+
return [...this.tracked.values()].map((t) => t.runtime);
|
|
495
|
+
}
|
|
496
|
+
/** Peers of `name` — every other declared service, as `PeerService` records. */
|
|
497
|
+
peersOf(name) {
|
|
498
|
+
return this.runtimes().filter((r) => r.name !== name).map((r) => ({ name: r.name, port: r.port, url: r.url }));
|
|
499
|
+
}
|
|
500
|
+
toStatus(t) {
|
|
501
|
+
return {
|
|
502
|
+
name: t.runtime.name,
|
|
503
|
+
hostname: t.runtime.hostname,
|
|
504
|
+
port: t.runtime.port,
|
|
505
|
+
url: t.runtime.url,
|
|
506
|
+
pid: t.child?.pid ?? null,
|
|
507
|
+
status: t.status,
|
|
508
|
+
exitCode: t.exitCode,
|
|
509
|
+
startedAt: t.startedAt
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Start a declared service. Idempotent: returns the current status if it's
|
|
514
|
+
* already running. Injects `AGENTPROTO_PORT` / `AGENTPROTO_URL` for itself
|
|
515
|
+
* plus `AGENTPROTO_SERVICE_<PEER>_PORT|URL` for every sibling.
|
|
516
|
+
*/
|
|
517
|
+
start(name) {
|
|
518
|
+
const t = this.tracked.get(name);
|
|
519
|
+
if (!t) throw new Error(`no service "${name}" declared in agentproto.json`);
|
|
520
|
+
if (t.status === "running" && t.child) return this.toStatus(t);
|
|
521
|
+
const env = {
|
|
522
|
+
...hookEnv(this.opts.ctx),
|
|
523
|
+
[ENV_VARS.port]: String(t.runtime.port),
|
|
524
|
+
[ENV_VARS.url]: t.runtime.url,
|
|
525
|
+
...peerEnv(this.peersOf(name))
|
|
526
|
+
};
|
|
527
|
+
const child = spawn(t.runtime.command, {
|
|
528
|
+
cwd: this.opts.ctx.worktreePath,
|
|
529
|
+
shell: true,
|
|
530
|
+
env: { ...process.env, ...env },
|
|
531
|
+
stdio: "inherit"
|
|
532
|
+
});
|
|
533
|
+
t.child = child;
|
|
534
|
+
t.status = "running";
|
|
535
|
+
t.exitCode = null;
|
|
536
|
+
t.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
537
|
+
child.on("exit", (code) => {
|
|
538
|
+
t.status = "exited";
|
|
539
|
+
t.exitCode = code;
|
|
540
|
+
});
|
|
541
|
+
child.on("error", () => {
|
|
542
|
+
t.status = "exited";
|
|
543
|
+
if (t.exitCode === null) t.exitCode = -1;
|
|
544
|
+
});
|
|
545
|
+
this.opts.proxyTable?.set(t.runtime.hostname, t.runtime.port);
|
|
546
|
+
return this.toStatus(t);
|
|
547
|
+
}
|
|
548
|
+
/** Stop a running service (SIGTERM). Returns whether one was running. */
|
|
549
|
+
async stop(name) {
|
|
550
|
+
const t = this.tracked.get(name);
|
|
551
|
+
if (!t) throw new Error(`no service "${name}" declared in agentproto.json`);
|
|
552
|
+
this.opts.proxyTable?.delete(t.runtime.hostname);
|
|
553
|
+
const child = t.child;
|
|
554
|
+
if (!child || t.status !== "running") return false;
|
|
555
|
+
return new Promise((resolve4) => {
|
|
556
|
+
const done = () => {
|
|
557
|
+
t.status = "exited";
|
|
558
|
+
resolve4(true);
|
|
559
|
+
};
|
|
560
|
+
child.once("exit", done);
|
|
561
|
+
child.kill("SIGTERM");
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/** Stop every running service. */
|
|
565
|
+
async stopAll() {
|
|
566
|
+
await Promise.all([...this.tracked.keys()].map((name) => this.stop(name)));
|
|
567
|
+
}
|
|
568
|
+
/** Current status of a single service, or `undefined` if not declared. */
|
|
569
|
+
get(name) {
|
|
570
|
+
const t = this.tracked.get(name);
|
|
571
|
+
return t ? this.toStatus(t) : void 0;
|
|
572
|
+
}
|
|
573
|
+
/** Current status of every declared service. */
|
|
574
|
+
list() {
|
|
575
|
+
return [...this.tracked.values()].map((t) => this.toStatus(t));
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// src/services/proxy-table.ts
|
|
580
|
+
var ProxyTable = class {
|
|
581
|
+
routes = /* @__PURE__ */ new Map();
|
|
582
|
+
/** Register (or replace) the target port for a hostname. */
|
|
583
|
+
set(hostname, port) {
|
|
584
|
+
this.routes.set(hostname.toLowerCase(), port);
|
|
585
|
+
}
|
|
586
|
+
/** Remove a hostname's route. Returns whether it was present. */
|
|
587
|
+
delete(hostname) {
|
|
588
|
+
return this.routes.delete(hostname.toLowerCase());
|
|
589
|
+
}
|
|
590
|
+
/** The target port registered for an exact hostname, if any. */
|
|
591
|
+
get(hostname) {
|
|
592
|
+
return this.routes.get(hostname.toLowerCase());
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Resolve a raw `Host` header (which may carry a `:port` suffix, and for
|
|
596
|
+
* IPv6 a bracketed host) to a target port.
|
|
597
|
+
*/
|
|
598
|
+
lookup(hostHeader) {
|
|
599
|
+
if (!hostHeader) return void 0;
|
|
600
|
+
const host = stripPort(hostHeader).toLowerCase();
|
|
601
|
+
return this.routes.get(host);
|
|
602
|
+
}
|
|
603
|
+
/** All `[hostname, port]` routes currently registered. */
|
|
604
|
+
entries() {
|
|
605
|
+
return [...this.routes.entries()];
|
|
606
|
+
}
|
|
607
|
+
/** Number of registered routes. */
|
|
608
|
+
get size() {
|
|
609
|
+
return this.routes.size;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
function stripPort(hostHeader) {
|
|
613
|
+
const trimmed = hostHeader.trim();
|
|
614
|
+
if (trimmed.startsWith("[")) {
|
|
615
|
+
const close = trimmed.indexOf("]");
|
|
616
|
+
return close === -1 ? trimmed : trimmed.slice(0, close + 1);
|
|
617
|
+
}
|
|
618
|
+
const colon = trimmed.indexOf(":");
|
|
619
|
+
return colon === -1 ? trimmed : trimmed.slice(0, colon);
|
|
620
|
+
}
|
|
621
|
+
function repoLabel(repoRoot) {
|
|
622
|
+
return basename(repoRoot.replace(/[/\\]+$/, ""));
|
|
623
|
+
}
|
|
624
|
+
async function detectDefaultBranch(repoRoot) {
|
|
625
|
+
const head = await execArgv(
|
|
626
|
+
"git",
|
|
627
|
+
["-C", repoRoot, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
628
|
+
repoRoot
|
|
629
|
+
);
|
|
630
|
+
if (head.exitCode === 0) {
|
|
631
|
+
const ref = head.stdout.trim();
|
|
632
|
+
const slash = ref.lastIndexOf("/");
|
|
633
|
+
if (slash !== -1) return ref.slice(slash + 1);
|
|
634
|
+
if (ref) return ref;
|
|
635
|
+
}
|
|
636
|
+
for (const candidate of ["main", "master"]) {
|
|
637
|
+
const exists = await execArgv(
|
|
638
|
+
"git",
|
|
639
|
+
["-C", repoRoot, "rev-parse", "--verify", "--quiet", `refs/heads/${candidate}`],
|
|
640
|
+
repoRoot
|
|
641
|
+
);
|
|
642
|
+
if (exists.exitCode === 0) return candidate;
|
|
643
|
+
}
|
|
644
|
+
return "main";
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/services/runtime.ts
|
|
648
|
+
var supervisors = /* @__PURE__ */ new Map();
|
|
649
|
+
var sharedProxyTable = new ProxyTable();
|
|
650
|
+
var DEFAULT_PROXY_PORT = 18780;
|
|
651
|
+
async function resolveSupervisor(input) {
|
|
652
|
+
const config = await loadConfigFromBase(input.repoRoot, input.base) ?? {};
|
|
653
|
+
const existing = supervisors.get(input.worktreePath);
|
|
654
|
+
if (existing) return { supervisor: existing, config };
|
|
655
|
+
const ctx = {
|
|
656
|
+
sourceCheckoutPath: input.repoRoot,
|
|
657
|
+
worktreePath: input.worktreePath,
|
|
658
|
+
branchName: input.branch
|
|
659
|
+
};
|
|
660
|
+
const defaultBranch = await detectDefaultBranch(input.repoRoot);
|
|
661
|
+
const supervisor = await ServiceSupervisor.create({
|
|
662
|
+
ctx,
|
|
663
|
+
services: listServices(config),
|
|
664
|
+
repo: repoLabel(input.repoRoot),
|
|
665
|
+
isDefaultBranch: input.branch === defaultBranch,
|
|
666
|
+
proxyPort: input.proxyPort ?? DEFAULT_PROXY_PORT,
|
|
667
|
+
proxyTable: sharedProxyTable
|
|
668
|
+
});
|
|
669
|
+
supervisors.set(input.worktreePath, supervisor);
|
|
670
|
+
return { supervisor, config };
|
|
671
|
+
}
|
|
672
|
+
function getSupervisor(worktreePath) {
|
|
673
|
+
return supervisors.get(worktreePath);
|
|
674
|
+
}
|
|
675
|
+
async function disposeSupervisor(worktreePath) {
|
|
676
|
+
const supervisor = supervisors.get(worktreePath);
|
|
677
|
+
if (!supervisor) return;
|
|
678
|
+
await supervisor.stopAll();
|
|
679
|
+
supervisors.delete(worktreePath);
|
|
680
|
+
}
|
|
681
|
+
async function computeTreeState(worktreePath) {
|
|
682
|
+
const res = await execArgv("git", ["-C", worktreePath, "status", "--porcelain=v2"], worktreePath);
|
|
683
|
+
if (res.exitCode !== 0) {
|
|
684
|
+
throw new Error(
|
|
685
|
+
`git status --porcelain=v2 failed in ${worktreePath} (exit ${res.exitCode}): ${res.stderr.trim() || res.stdout.trim()}`
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
let modified = 0;
|
|
689
|
+
let staged = 0;
|
|
690
|
+
let untracked = 0;
|
|
691
|
+
for (const line of res.stdout.split("\n")) {
|
|
692
|
+
if (!line) continue;
|
|
693
|
+
if (line.startsWith("? ")) {
|
|
694
|
+
untracked++;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u ")) {
|
|
698
|
+
const xy = line.split(" ", 2)[1] ?? "..";
|
|
699
|
+
if (xy[0] !== ".") staged++;
|
|
700
|
+
if (xy[1] !== ".") modified++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (modified === 0 && staged === 0 && untracked === 0) return { state: "clean" };
|
|
704
|
+
return { state: "dirty", modified, staged, untracked, newestMtimeMs: await newestDirtyMtimeMs(worktreePath) };
|
|
705
|
+
}
|
|
706
|
+
async function newestDirtyMtimeMs(worktreePath) {
|
|
707
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
708
|
+
execArgv("git", ["-C", worktreePath, "diff", "--name-only", "-z"], worktreePath),
|
|
709
|
+
execArgv("git", ["-C", worktreePath, "diff", "--cached", "--name-only", "-z"], worktreePath),
|
|
710
|
+
execArgv("git", ["-C", worktreePath, "ls-files", "--others", "--exclude-standard", "-z"], worktreePath)
|
|
711
|
+
]);
|
|
712
|
+
const paths = /* @__PURE__ */ new Set();
|
|
713
|
+
for (const res of [unstaged, staged, untracked]) {
|
|
714
|
+
if (res.exitCode !== 0) continue;
|
|
715
|
+
for (const p of res.stdout.split("\0")) if (p) paths.add(p);
|
|
716
|
+
}
|
|
717
|
+
let newest = null;
|
|
718
|
+
for (const p of paths) {
|
|
719
|
+
try {
|
|
720
|
+
const st = await stat(resolve(worktreePath, p));
|
|
721
|
+
if (newest === null || st.mtimeMs > newest) newest = st.mtimeMs;
|
|
722
|
+
} catch {
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return newest;
|
|
726
|
+
}
|
|
727
|
+
var integrationBase = { checkedAt: z.string() };
|
|
728
|
+
var integrationStateSchema = z.union([
|
|
729
|
+
z.object({ ...integrationBase, state: z.literal("fresh") }),
|
|
730
|
+
z.object({
|
|
731
|
+
...integrationBase,
|
|
732
|
+
state: z.literal("merged"),
|
|
733
|
+
via: z.literal("squash"),
|
|
734
|
+
pr: z.number(),
|
|
735
|
+
offline: z.boolean()
|
|
736
|
+
}),
|
|
737
|
+
z.object({
|
|
738
|
+
...integrationBase,
|
|
739
|
+
state: z.literal("partial"),
|
|
740
|
+
pr: z.number(),
|
|
741
|
+
aheadBy: z.number(),
|
|
742
|
+
offline: z.boolean()
|
|
743
|
+
}),
|
|
744
|
+
z.object({ ...integrationBase, state: z.literal("open"), pr: z.number(), offline: z.boolean() }),
|
|
745
|
+
z.object({ ...integrationBase, state: z.literal("pushed-no-pr") }),
|
|
746
|
+
z.object({ ...integrationBase, state: z.literal("unpushed"), aheadBy: z.number() }),
|
|
747
|
+
z.object({ ...integrationBase, state: z.literal("local-only") }),
|
|
748
|
+
z.object({ ...integrationBase, state: z.literal("gone-unexplained") }),
|
|
749
|
+
z.object({ ...integrationBase, state: z.literal("diverged"), offline: z.boolean() }),
|
|
750
|
+
z.object({ ...integrationBase, state: z.literal("detached") }),
|
|
751
|
+
z.object({ ...integrationBase, state: z.literal("unknown"), reason: z.literal("offline") })
|
|
752
|
+
]);
|
|
753
|
+
var verdictMemoRecordSchema = z.object({
|
|
754
|
+
repo: z.string(),
|
|
755
|
+
branch: z.string(),
|
|
756
|
+
tipSha: z.string(),
|
|
757
|
+
verdict: integrationStateSchema,
|
|
758
|
+
checkedAt: z.string()
|
|
759
|
+
});
|
|
760
|
+
var verdictMemoFileSchema = z.object({ entries: z.array(verdictMemoRecordSchema) });
|
|
761
|
+
var VERDICT_MEMO_PATH = () => resolve(homedir(), ".agentproto", "worktree-verdicts.json");
|
|
762
|
+
function memoKey(repo, branch, tipSha) {
|
|
763
|
+
return `${repo}\0${branch}\0${tipSha}`;
|
|
764
|
+
}
|
|
765
|
+
var FileVerdictMemoStore = class {
|
|
766
|
+
constructor(path = VERDICT_MEMO_PATH()) {
|
|
767
|
+
this.path = path;
|
|
768
|
+
}
|
|
769
|
+
path;
|
|
770
|
+
cache = null;
|
|
771
|
+
async load() {
|
|
772
|
+
if (this.cache) return this.cache;
|
|
773
|
+
const map = /* @__PURE__ */ new Map();
|
|
774
|
+
this.cache = map;
|
|
775
|
+
let raw;
|
|
776
|
+
try {
|
|
777
|
+
raw = await readFile(this.path, "utf8");
|
|
778
|
+
} catch {
|
|
779
|
+
return map;
|
|
780
|
+
}
|
|
781
|
+
let parsed;
|
|
782
|
+
try {
|
|
783
|
+
parsed = JSON.parse(raw);
|
|
784
|
+
} catch {
|
|
785
|
+
return map;
|
|
786
|
+
}
|
|
787
|
+
const result = verdictMemoFileSchema.safeParse(parsed);
|
|
788
|
+
if (!result.success) return map;
|
|
789
|
+
for (const entry of result.data.entries) map.set(memoKey(entry.repo, entry.branch, entry.tipSha), entry);
|
|
790
|
+
return map;
|
|
791
|
+
}
|
|
792
|
+
async get(repo, branch, tipSha) {
|
|
793
|
+
const map = await this.load();
|
|
794
|
+
return map.get(memoKey(repo, branch, tipSha)) ?? null;
|
|
795
|
+
}
|
|
796
|
+
async set(record) {
|
|
797
|
+
const map = await this.load();
|
|
798
|
+
map.set(memoKey(record.repo, record.branch, record.tipSha), record);
|
|
799
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
800
|
+
await writeFile(this.path, JSON.stringify({ entries: [...map.values()] }, null, 2) + "\n", "utf8");
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
var InMemoryVerdictMemoStore = class {
|
|
804
|
+
map = /* @__PURE__ */ new Map();
|
|
805
|
+
async get(repo, branch, tipSha) {
|
|
806
|
+
return this.map.get(memoKey(repo, branch, tipSha)) ?? null;
|
|
807
|
+
}
|
|
808
|
+
async set(record) {
|
|
809
|
+
this.map.set(memoKey(record.repo, record.branch, record.tipSha), record);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
async function isAncestor(repoRoot, ancestor, descendant) {
|
|
813
|
+
const res = await execArgv(
|
|
814
|
+
"git",
|
|
815
|
+
["-C", repoRoot, "merge-base", "--is-ancestor", ancestor, descendant],
|
|
816
|
+
repoRoot
|
|
817
|
+
);
|
|
818
|
+
if (res.exitCode === 0) return true;
|
|
819
|
+
if (res.exitCode === 1) return false;
|
|
820
|
+
throw new Error(
|
|
821
|
+
`git merge-base --is-ancestor ${ancestor} ${descendant} failed (exit ${res.exitCode}): ${res.stderr.trim()}`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
async function revListCount(repoRoot, range) {
|
|
825
|
+
const res = await execGit(repoRoot, ["rev-list", "--count", range]);
|
|
826
|
+
return Number.parseInt(res.stdout.trim(), 10);
|
|
827
|
+
}
|
|
828
|
+
async function readUpstreamTrack(repoRoot, branch) {
|
|
829
|
+
const res = await execArgv(
|
|
830
|
+
"git",
|
|
831
|
+
["-C", repoRoot, "for-each-ref", "--format=%(upstream)%09%(upstream:track)", `refs/heads/${branch}`],
|
|
832
|
+
repoRoot
|
|
833
|
+
);
|
|
834
|
+
if (res.exitCode !== 0) return { upstream: null, gone: false, aheadBy: 0 };
|
|
835
|
+
const [rawUpstream, rawTrack] = res.stdout.split(" ");
|
|
836
|
+
const upstream = (rawUpstream ?? "").trim();
|
|
837
|
+
if (!upstream) return { upstream: null, gone: false, aheadBy: 0 };
|
|
838
|
+
const track = (rawTrack ?? "").trim();
|
|
839
|
+
const aheadMatch = track.match(/ahead (\d+)/);
|
|
840
|
+
return {
|
|
841
|
+
upstream,
|
|
842
|
+
gone: track.includes("[gone]"),
|
|
843
|
+
aheadBy: aheadMatch ? Number.parseInt(aheadMatch[1] ?? "0", 10) : 0
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
async function listGitWorktrees(repoRoot) {
|
|
847
|
+
const res = await execGit(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
848
|
+
const entries = [];
|
|
849
|
+
let current = null;
|
|
850
|
+
for (const line of res.stdout.split("\n")) {
|
|
851
|
+
if (line.startsWith("worktree ")) {
|
|
852
|
+
if (current) entries.push(current);
|
|
853
|
+
current = { path: line.slice("worktree ".length), branch: null, head: "" };
|
|
854
|
+
} else if (current && line.startsWith("HEAD ")) {
|
|
855
|
+
current.head = line.slice("HEAD ".length);
|
|
856
|
+
} else if (current && line.startsWith("branch ")) {
|
|
857
|
+
current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "");
|
|
858
|
+
} else if (current && line === "detached") {
|
|
859
|
+
current.branch = null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (current) entries.push(current);
|
|
863
|
+
return entries;
|
|
864
|
+
}
|
|
865
|
+
function newestMergedFirst(prs) {
|
|
866
|
+
return prs.filter((pr) => pr.merged).slice().sort((a, b) => (b.mergedAt ?? "").localeCompare(a.mergedAt ?? ""));
|
|
867
|
+
}
|
|
868
|
+
function dedupeByNumber(prs) {
|
|
869
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
870
|
+
for (const pr of prs) byNumber.set(pr.number, pr);
|
|
871
|
+
return [...byNumber.values()];
|
|
872
|
+
}
|
|
873
|
+
async function reconcileIntegration(input) {
|
|
874
|
+
const now = input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
875
|
+
const defaultBranchRef = input.defaultBranchRef ?? "origin/main";
|
|
876
|
+
if (input.branch === null) {
|
|
877
|
+
return { state: "detached", checkedAt: now() };
|
|
878
|
+
}
|
|
879
|
+
const branch = input.branch;
|
|
880
|
+
const ancestryOk = await isAncestor(input.repoRoot, input.tipSha, defaultBranchRef).catch(() => false);
|
|
881
|
+
if (ancestryOk) {
|
|
882
|
+
return { state: "fresh", checkedAt: now() };
|
|
883
|
+
}
|
|
884
|
+
let candidatePrs;
|
|
885
|
+
try {
|
|
886
|
+
const [byBranch, byCommit] = await Promise.all([
|
|
887
|
+
input.forge.pullRequestsForBranch(branch),
|
|
888
|
+
input.forge.pullRequestsForCommit(input.tipSha)
|
|
889
|
+
]);
|
|
890
|
+
candidatePrs = dedupeByNumber([...byBranch, ...byCommit]);
|
|
891
|
+
} catch {
|
|
892
|
+
return reconcileOffline(input, now());
|
|
893
|
+
}
|
|
894
|
+
let verdict;
|
|
895
|
+
try {
|
|
896
|
+
verdict = await classifyAgainstPulls(input.repoRoot, input.tipSha, candidatePrs, now, input.forge);
|
|
897
|
+
} catch {
|
|
898
|
+
return reconcileOffline(input, now());
|
|
899
|
+
}
|
|
900
|
+
if (verdict) {
|
|
901
|
+
await input.memo.set({ repo: input.repoName, branch, tipSha: input.tipSha, verdict, checkedAt: verdict.checkedAt }).catch(() => {
|
|
902
|
+
});
|
|
903
|
+
return verdict;
|
|
904
|
+
}
|
|
905
|
+
const upstream = await readUpstreamTrack(input.repoRoot, branch);
|
|
906
|
+
if (upstream.upstream === null) return { state: "local-only", checkedAt: now() };
|
|
907
|
+
if (upstream.gone) return { state: "gone-unexplained", checkedAt: now() };
|
|
908
|
+
if (upstream.aheadBy > 0) return { state: "unpushed", aheadBy: upstream.aheadBy, checkedAt: now() };
|
|
909
|
+
return { state: "pushed-no-pr", checkedAt: now() };
|
|
910
|
+
}
|
|
911
|
+
async function classifyAgainstPulls(repoRoot, tipSha, candidatePrs, now, forge) {
|
|
912
|
+
for (const pr of newestMergedFirst(candidatePrs)) {
|
|
913
|
+
const H = pr.headRefOid;
|
|
914
|
+
await forge.ensurePullHeadFetched(pr.number, H);
|
|
915
|
+
if (await isAncestor(repoRoot, tipSha, H)) {
|
|
916
|
+
return { state: "merged", via: "squash", pr: pr.number, checkedAt: now(), offline: false };
|
|
917
|
+
}
|
|
918
|
+
if (await isAncestor(repoRoot, H, tipSha)) {
|
|
919
|
+
const aheadBy = await revListCount(repoRoot, `${H}..${tipSha}`);
|
|
920
|
+
return { state: "partial", pr: pr.number, aheadBy, checkedAt: now(), offline: false };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const [firstOpenPr] = candidatePrs.filter((pr) => pr.state === "open").sort((a, b) => a.number - b.number);
|
|
924
|
+
if (firstOpenPr) {
|
|
925
|
+
return { state: "open", pr: firstOpenPr.number, checkedAt: now(), offline: false };
|
|
926
|
+
}
|
|
927
|
+
if (newestMergedFirst(candidatePrs).length > 0) {
|
|
928
|
+
return { state: "diverged", checkedAt: now(), offline: false };
|
|
929
|
+
}
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
async function reconcileOffline(input, checkedAt) {
|
|
933
|
+
if (input.branch === null) return { state: "detached", checkedAt };
|
|
934
|
+
const cached = await input.memo.get(input.repoName, input.branch, input.tipSha);
|
|
935
|
+
if (cached) {
|
|
936
|
+
return offlineCopy(cached.verdict, checkedAt);
|
|
937
|
+
}
|
|
938
|
+
return { state: "unknown", reason: "offline", checkedAt };
|
|
939
|
+
}
|
|
940
|
+
function offlineCopy(verdict, checkedAt) {
|
|
941
|
+
if (verdict.state === "merged" && verdict.via === "squash") {
|
|
942
|
+
return { ...verdict, checkedAt, offline: true };
|
|
943
|
+
}
|
|
944
|
+
if (verdict.state === "partial" || verdict.state === "open" || verdict.state === "diverged") {
|
|
945
|
+
return { ...verdict, checkedAt, offline: true };
|
|
946
|
+
}
|
|
947
|
+
return verdict;
|
|
948
|
+
}
|
|
949
|
+
var LIVE_SESSION_STATUSES = /* @__PURE__ */ new Set(["starting", "running"]);
|
|
950
|
+
async function computeLiveness(worktreePath, options = {}) {
|
|
951
|
+
const registry = await readSessionsRegistry(options.sessionsPath ?? SESSIONS_FILE_PATH());
|
|
952
|
+
if (registry === null) return { state: "daemon-unreachable", sessions: [], services: [] };
|
|
953
|
+
const live = registry.filter((s) => sessionInWorktree(s, worktreePath)).filter((s) => LIVE_SESSION_STATUSES.has(s.status));
|
|
954
|
+
if (live.length > 0) return { state: "sessions", sessions: live, services: [] };
|
|
955
|
+
return { state: "idle", sessions: [], services: [] };
|
|
956
|
+
}
|
|
957
|
+
var RECENT_WRITE_HOLD_WINDOW_MS = 15 * 60 * 1e3;
|
|
958
|
+
function classify(tree, integration, liveness, nowMs = Date.now()) {
|
|
959
|
+
const merged = integration.state === "merged";
|
|
960
|
+
const fresh = integration.state === "fresh";
|
|
961
|
+
const clean = tree.state === "clean";
|
|
962
|
+
const idleOrUnreachable = liveness.state === "idle" || liveness.state === "daemon-unreachable";
|
|
963
|
+
if ((merged || fresh) && clean && idleOrUnreachable) return { reclaimable: true, class: "reclaim" };
|
|
964
|
+
if (merged && !clean) {
|
|
965
|
+
const writtenRecently = tree.state === "dirty" && typeof tree.newestMtimeMs === "number" && nowMs - tree.newestMtimeMs < RECENT_WRITE_HOLD_WINDOW_MS;
|
|
966
|
+
if (writtenRecently) return { reclaimable: false, class: "hold" };
|
|
967
|
+
return { reclaimable: false, class: "salvage" };
|
|
968
|
+
}
|
|
969
|
+
return { reclaimable: false, class: "hold" };
|
|
970
|
+
}
|
|
971
|
+
async function computeWorktreeStatus(input) {
|
|
972
|
+
const [tree, integration, liveness, provenance] = await Promise.all([
|
|
973
|
+
computeTreeState(input.worktree.path),
|
|
974
|
+
reconcileIntegration({
|
|
975
|
+
repoRoot: input.repoRoot,
|
|
976
|
+
repoName: input.repoName,
|
|
977
|
+
branch: input.worktree.branch,
|
|
978
|
+
tipSha: input.worktree.head,
|
|
979
|
+
forge: input.forge,
|
|
980
|
+
memo: input.memo,
|
|
981
|
+
defaultBranchRef: input.defaultBranchRef,
|
|
982
|
+
now: input.now
|
|
983
|
+
}),
|
|
984
|
+
computeLiveness(input.worktree.path, { sessionsPath: input.sessionsPath }),
|
|
985
|
+
computeProvenance(input.worktree.path, { sessionsPath: input.sessionsPath })
|
|
986
|
+
]);
|
|
987
|
+
const { reclaimable, class: cls } = classify(tree, integration, liveness, input.nowMs);
|
|
988
|
+
return {
|
|
989
|
+
path: input.worktree.path,
|
|
990
|
+
branch: input.worktree.branch,
|
|
991
|
+
head: input.worktree.head,
|
|
992
|
+
tree,
|
|
993
|
+
integration,
|
|
994
|
+
liveness,
|
|
995
|
+
provenance,
|
|
996
|
+
gate: { state: "none" },
|
|
997
|
+
reclaimable,
|
|
998
|
+
class: cls
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
async function listWorktreeStatuses(input) {
|
|
1002
|
+
const worktrees = await listGitWorktrees(input.repoRoot);
|
|
1003
|
+
const results = [];
|
|
1004
|
+
for (const worktree of worktrees) {
|
|
1005
|
+
results.push(
|
|
1006
|
+
await computeWorktreeStatus({
|
|
1007
|
+
repoRoot: input.repoRoot,
|
|
1008
|
+
repoName: input.repoName,
|
|
1009
|
+
worktree,
|
|
1010
|
+
forge: input.forge,
|
|
1011
|
+
memo: input.memo,
|
|
1012
|
+
defaultBranchRef: input.defaultBranchRef,
|
|
1013
|
+
sessionsPath: input.sessionsPath,
|
|
1014
|
+
now: input.now
|
|
1015
|
+
})
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
return results;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/provider/bodies/cleanup-worktree.body.ts
|
|
1022
|
+
var WorktreeNotRemovableError = class extends Error {
|
|
1023
|
+
cwd;
|
|
1024
|
+
blocked;
|
|
1025
|
+
/**
|
|
1026
|
+
* `blocked` is empty when git itself refused without us categorizing the
|
|
1027
|
+
* tree first (the no-flags default path, PLAN.md §5.2 layer 3) — `detail`
|
|
1028
|
+
* then carries git's own stderr instead of a named class.
|
|
1029
|
+
*/
|
|
1030
|
+
constructor(cwd, blocked, detail) {
|
|
1031
|
+
const flags = blocked.map((b) => b === "untracked" ? "discardUntracked" : "discardModified");
|
|
1032
|
+
const message = blocked.length > 0 ? `worktree at ${cwd} has ${blocked.join(" and ")} changes; refusing to remove. Pass ${flags.join(" and ")} to authorize discarding ${blocked.length > 1 ? "them" : "it"}, or use 'worktree archive' to salvage first.` : `worktree at ${cwd} is not clean; git refused to remove it${detail ? `: ${detail}` : ""}. Pass discardUntracked/discardModified to authorize discarding, or use 'worktree archive' to salvage first.`;
|
|
1033
|
+
super(message);
|
|
1034
|
+
this.name = "WorktreeNotRemovableError";
|
|
1035
|
+
this.cwd = cwd;
|
|
1036
|
+
this.blocked = blocked;
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
var cleanupWorktreeBuiltin = implementTool(
|
|
1040
|
+
cleanupWorktreeTool,
|
|
1041
|
+
async ({ input }) => {
|
|
1042
|
+
await disposeSupervisor(input.cwd);
|
|
1043
|
+
if (input.runTeardown !== false) {
|
|
1044
|
+
const config = await loadConfigFromBase(input.repoRoot, input.base).catch(() => null);
|
|
1045
|
+
if (config) {
|
|
1046
|
+
const runs = await runTeardown(config, {
|
|
1047
|
+
sourceCheckoutPath: input.repoRoot,
|
|
1048
|
+
worktreePath: input.cwd,
|
|
1049
|
+
branchName: input.branch ?? ""
|
|
1050
|
+
}).catch((err) => {
|
|
1051
|
+
process.stderr.write(
|
|
1052
|
+
`worktree.cleanup: teardown hook error (ignored): ${err instanceof Error ? err.message : String(err)}
|
|
1053
|
+
`
|
|
1054
|
+
);
|
|
1055
|
+
return [];
|
|
1056
|
+
});
|
|
1057
|
+
for (const run of runs) {
|
|
1058
|
+
if (run.result.exitCode !== 0) {
|
|
1059
|
+
process.stderr.write(
|
|
1060
|
+
`worktree.cleanup: teardown '${run.command}' exited ${run.result.exitCode} (ignored): ${(run.result.stderr || run.result.stdout).trim()}
|
|
1061
|
+
`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
const discardUntracked = input.discardUntracked === true;
|
|
1068
|
+
const discardModified = input.discardModified === true;
|
|
1069
|
+
if (discardUntracked || discardModified) {
|
|
1070
|
+
const tree = await computeTreeState(input.cwd);
|
|
1071
|
+
if (tree.state === "dirty") {
|
|
1072
|
+
const blocked = [];
|
|
1073
|
+
if (tree.untracked > 0 && !discardUntracked) blocked.push("untracked");
|
|
1074
|
+
if ((tree.modified > 0 || tree.staged > 0) && !discardModified) blocked.push("modified");
|
|
1075
|
+
if (blocked.length > 0) throw new WorktreeNotRemovableError(input.cwd, blocked);
|
|
1076
|
+
await execGit(input.repoRoot, ["worktree", "remove", "--force", input.cwd]);
|
|
1077
|
+
} else {
|
|
1078
|
+
await execGit(input.repoRoot, ["worktree", "remove", input.cwd]);
|
|
1079
|
+
}
|
|
1080
|
+
} else {
|
|
1081
|
+
try {
|
|
1082
|
+
await execGit(input.repoRoot, ["worktree", "remove", input.cwd]);
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
throw new WorktreeNotRemovableError(input.cwd, [], err instanceof Error ? err.message : String(err));
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (input.deleteBranch && input.branch) {
|
|
1088
|
+
await execGit(input.repoRoot, ["branch", "-D", input.branch]);
|
|
1089
|
+
}
|
|
1090
|
+
return { removed: true };
|
|
1091
|
+
}
|
|
1092
|
+
);
|
|
1093
|
+
var runGateBuiltin = implementTool(runGateTool, async ({ input }) => {
|
|
1094
|
+
const result = await execShell(input.cmd, input.cwd);
|
|
1095
|
+
return { passed: result.exitCode === 0, ...result };
|
|
1096
|
+
});
|
|
1097
|
+
var runScriptBuiltin = implementTool(runScriptTool, async ({ input }) => {
|
|
1098
|
+
const config = await loadConfigFromBase(input.repoRoot, input.base);
|
|
1099
|
+
const script = config ? getScript(config, input.script) : void 0;
|
|
1100
|
+
if (!script) {
|
|
1101
|
+
throw new ToolError({
|
|
1102
|
+
code: "not_found",
|
|
1103
|
+
message: `no script "${input.script}" in ${input.base ?? "origin/main"}:agentproto.json`
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const env = hookEnv({
|
|
1107
|
+
sourceCheckoutPath: input.repoRoot,
|
|
1108
|
+
worktreePath: input.worktreePath,
|
|
1109
|
+
branchName: input.branch
|
|
1110
|
+
});
|
|
1111
|
+
const result = await execShell(script.command, input.worktreePath, { env });
|
|
1112
|
+
return { passed: result.exitCode === 0, ...result };
|
|
1113
|
+
});
|
|
1114
|
+
var startServiceBuiltin = implementTool(startServiceTool, async ({ input }) => {
|
|
1115
|
+
const { supervisor } = await resolveSupervisor({
|
|
1116
|
+
repoRoot: input.repoRoot,
|
|
1117
|
+
worktreePath: input.worktreePath,
|
|
1118
|
+
branch: input.branch,
|
|
1119
|
+
base: input.base,
|
|
1120
|
+
proxyPort: input.proxyPort
|
|
1121
|
+
});
|
|
1122
|
+
if (!supervisor.get(input.script)) {
|
|
1123
|
+
throw new ToolError({
|
|
1124
|
+
code: "not_found",
|
|
1125
|
+
message: `no service "${input.script}" declared (type: "service") in ${input.base ?? "origin/main"}:agentproto.json`
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return supervisor.start(input.script);
|
|
1129
|
+
});
|
|
1130
|
+
var stopServiceBuiltin = implementTool(stopServiceTool, async ({ input }) => {
|
|
1131
|
+
const supervisor = getSupervisor(input.worktreePath);
|
|
1132
|
+
if (!supervisor) return { stopped: false };
|
|
1133
|
+
const stopped = await supervisor.stop(input.script);
|
|
1134
|
+
return { stopped };
|
|
1135
|
+
});
|
|
1136
|
+
var listServicesBuiltin = implementTool(listServicesTool, async ({ input }) => {
|
|
1137
|
+
const { supervisor } = await resolveSupervisor({
|
|
1138
|
+
repoRoot: input.repoRoot,
|
|
1139
|
+
worktreePath: input.worktreePath,
|
|
1140
|
+
branch: input.branch,
|
|
1141
|
+
base: input.base,
|
|
1142
|
+
proxyPort: input.proxyPort
|
|
1143
|
+
});
|
|
1144
|
+
return { services: supervisor.list() };
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// src/provider/worktree-provider.ts
|
|
1148
|
+
var worktreeProvider = defineDriver({
|
|
1149
|
+
id: "worktree-builtin",
|
|
1150
|
+
name: "Git Worktree (built-in)",
|
|
1151
|
+
description: "In-process provision / gate / cleanup of git worktrees via direct git and shell invocations. No sandboxing \u2014 the host owns the filesystem and process boundary.",
|
|
1152
|
+
version: "0.1.0",
|
|
1153
|
+
kind: "builtin",
|
|
1154
|
+
implements: [
|
|
1155
|
+
{ tool: "worktree.provision", version: "0.2.0" },
|
|
1156
|
+
{ tool: "worktree.cleanup", version: "0.2.0" },
|
|
1157
|
+
{ tool: "worktree.run-gate", version: "0.1.0" },
|
|
1158
|
+
{ tool: "worktree.run-script", version: "0.1.0" },
|
|
1159
|
+
{ tool: "worktree.start-service", version: "0.1.0" },
|
|
1160
|
+
{ tool: "worktree.stop-service", version: "0.1.0" },
|
|
1161
|
+
{ tool: "worktree.list-services", version: "0.1.0" }
|
|
1162
|
+
],
|
|
1163
|
+
implementations: [
|
|
1164
|
+
provisionWorktreeBuiltin,
|
|
1165
|
+
cleanupWorktreeBuiltin,
|
|
1166
|
+
runGateBuiltin,
|
|
1167
|
+
runScriptBuiltin,
|
|
1168
|
+
startServiceBuiltin,
|
|
1169
|
+
stopServiceBuiltin,
|
|
1170
|
+
listServicesBuiltin
|
|
1171
|
+
]
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
export { CONFIG_FILENAME, ConfigError, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, HookError, InMemoryVerdictMemoStore, ProxyTable, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WorktreeNotRemovableError, allocatePort, classify, computeLiveness, computeProvenance, computeTreeState, computeWorktreeStatus, detectDefaultBranch, disposeSupervisor, ephemeralPort, execArgv, execGit, execShell, expandGlob, getScript, getSupervisor, globToRegExp, hookEnv, isPortFree, listGitWorktrees, listScripts, listServices, listWorktreeStatuses, loadConfigFromBase, normalizeHook, parseConfig, peerEnv, peerEnvKey, readSessionsRegistry, readWorktreeMarker, reconcileIntegration, repoLabel, resolveSupervisor, runSetup, runTeardown, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, worktreeProvider, writeWorktreeMarker };
|
|
1175
|
+
//# sourceMappingURL=chunk-SEVCUXPY.mjs.map
|
|
1176
|
+
//# sourceMappingURL=chunk-SEVCUXPY.mjs.map
|