@flumecode/runner 0.9.0 → 0.11.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/dist/cli.js
CHANGED
|
@@ -136,6 +136,38 @@ async function reportHeartbeat(config, claudeCode) {
|
|
|
136
136
|
noteServerVersion(res);
|
|
137
137
|
if (!res.ok) throw new Error(`heartbeat failed: ${res.status} ${await safeText(res)}`);
|
|
138
138
|
}
|
|
139
|
+
async function uploadJobLog(config, jobId, content) {
|
|
140
|
+
const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/logs`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
authorization: `Bearer ${config.token}`,
|
|
144
|
+
"content-type": "application/json",
|
|
145
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
146
|
+
},
|
|
147
|
+
body: JSON.stringify({ content })
|
|
148
|
+
});
|
|
149
|
+
noteServerVersion(res);
|
|
150
|
+
if (!res.ok) throw new Error(`log upload failed: ${res.status} ${await safeText(res)}`);
|
|
151
|
+
}
|
|
152
|
+
async function fetchRelatedSessions(config, params) {
|
|
153
|
+
if (!params.prNumbers.length) return [];
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch(`${config.serverUrl}/api/runner/sessions/related`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: {
|
|
158
|
+
authorization: `Bearer ${config.token}`,
|
|
159
|
+
"content-type": "application/json",
|
|
160
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify(params)
|
|
163
|
+
});
|
|
164
|
+
noteServerVersion(res);
|
|
165
|
+
if (!res.ok) return [];
|
|
166
|
+
return await res.json();
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
139
171
|
async function safeText(res) {
|
|
140
172
|
try {
|
|
141
173
|
return await res.text();
|
|
@@ -144,6 +176,467 @@ async function safeText(res) {
|
|
|
144
176
|
}
|
|
145
177
|
}
|
|
146
178
|
|
|
179
|
+
// src/plugins/socket.ts
|
|
180
|
+
import { exec as execCb } from "node:child_process";
|
|
181
|
+
import { promisify as promisify2 } from "node:util";
|
|
182
|
+
|
|
183
|
+
// src/workspace.ts
|
|
184
|
+
import { execFile } from "node:child_process";
|
|
185
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
186
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
187
|
+
import { tmpdir } from "node:os";
|
|
188
|
+
import { join as join2 } from "node:path";
|
|
189
|
+
import { promisify } from "node:util";
|
|
190
|
+
|
|
191
|
+
// src/types.ts
|
|
192
|
+
function jobTitle(ctx) {
|
|
193
|
+
return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/logger.ts
|
|
197
|
+
var lines = [];
|
|
198
|
+
var secrets = [];
|
|
199
|
+
var MAX_BYTES = 10 * 1024 * 1024;
|
|
200
|
+
function startJobLog(opts) {
|
|
201
|
+
lines = [];
|
|
202
|
+
secrets = opts.secrets.filter(Boolean);
|
|
203
|
+
logEvent("meta", `job ${opts.jobId} (${opts.kind}) started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
204
|
+
}
|
|
205
|
+
function redact(s) {
|
|
206
|
+
for (const sec of secrets) {
|
|
207
|
+
s = s.split(sec).join("***REDACTED***");
|
|
208
|
+
}
|
|
209
|
+
return s;
|
|
210
|
+
}
|
|
211
|
+
function logEvent(section, text) {
|
|
212
|
+
lines.push(`[${(/* @__PURE__ */ new Date()).toISOString()}] [${section}] ${redact(text)}`);
|
|
213
|
+
}
|
|
214
|
+
function getJobLog() {
|
|
215
|
+
const full = lines.join("\n");
|
|
216
|
+
if (full.length <= MAX_BYTES) return full;
|
|
217
|
+
const half = Math.floor(MAX_BYTES / 2);
|
|
218
|
+
return full.slice(0, half) + `
|
|
219
|
+
|
|
220
|
+
\u2026[truncated ${full.length - MAX_BYTES} bytes]\u2026
|
|
221
|
+
|
|
222
|
+
` + full.slice(-half);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/workspace.ts
|
|
226
|
+
var exec = promisify(execFile);
|
|
227
|
+
var WORKSPACE_PREFIX = "flume-runner-";
|
|
228
|
+
var MAX_BUFFER = 1 << 24;
|
|
229
|
+
async function git(args) {
|
|
230
|
+
logEvent("git", `git ${args.join(" ")}`);
|
|
231
|
+
try {
|
|
232
|
+
const result = await exec("git", args, { maxBuffer: MAX_BUFFER });
|
|
233
|
+
if (result.stdout.trim()) logEvent("git:out", result.stdout.trim());
|
|
234
|
+
if (result.stderr.trim()) logEvent("git:err", result.stderr.trim());
|
|
235
|
+
return result;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
logEvent("git:err", String(err.stderr ?? err));
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function ensureGitIdentity(dir, identity) {
|
|
242
|
+
await git(["-C", dir, "config", "user.email", identity.email]);
|
|
243
|
+
await git(["-C", dir, "config", "user.name", identity.name]);
|
|
244
|
+
}
|
|
245
|
+
function cloneUrl(ctx) {
|
|
246
|
+
const { owner, name, cloneToken } = ctx.repo;
|
|
247
|
+
return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
|
|
248
|
+
}
|
|
249
|
+
function detectPackageManager(dir) {
|
|
250
|
+
if (!existsSync2(join2(dir, "package.json"))) return null;
|
|
251
|
+
if (existsSync2(join2(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
252
|
+
if (existsSync2(join2(dir, "yarn.lock"))) return "yarn";
|
|
253
|
+
if (existsSync2(join2(dir, "package-lock.json"))) return "npm";
|
|
254
|
+
if (existsSync2(join2(dir, "bun.lockb"))) return "bun";
|
|
255
|
+
return "npm";
|
|
256
|
+
}
|
|
257
|
+
async function installDependencies(dir) {
|
|
258
|
+
const manager = detectPackageManager(dir);
|
|
259
|
+
if (manager === null) return { status: "skipped" };
|
|
260
|
+
const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
|
|
261
|
+
logEvent("install", `${manager} install`);
|
|
262
|
+
try {
|
|
263
|
+
const result = await exec(manager, ["install"], {
|
|
264
|
+
cwd: dir,
|
|
265
|
+
maxBuffer: MAX_BUFFER,
|
|
266
|
+
env,
|
|
267
|
+
timeout: 5 * 6e4
|
|
268
|
+
});
|
|
269
|
+
if (result.stdout.trim()) logEvent("install:out", result.stdout.trim());
|
|
270
|
+
if (result.stderr.trim()) logEvent("install:err", result.stderr.trim());
|
|
271
|
+
return { status: "installed", manager };
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const e = err;
|
|
274
|
+
const detail = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
|
|
275
|
+
logEvent("install:err", detail || (err instanceof Error ? err.message : String(err)));
|
|
276
|
+
return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function makeWorkspace() {
|
|
280
|
+
return mkdtemp(join2(tmpdir(), WORKSPACE_PREFIX));
|
|
281
|
+
}
|
|
282
|
+
var MAX_WORKSPACES = 8;
|
|
283
|
+
var workspaceRegistry = /* @__PURE__ */ new Map();
|
|
284
|
+
async function acquireWorkspace(key) {
|
|
285
|
+
const existing = workspaceRegistry.get(key);
|
|
286
|
+
if (existing !== void 0 && existsSync2(existing)) {
|
|
287
|
+
workspaceRegistry.delete(key);
|
|
288
|
+
workspaceRegistry.set(key, existing);
|
|
289
|
+
return { dir: existing, reused: true };
|
|
290
|
+
}
|
|
291
|
+
const dir = await makeWorkspace();
|
|
292
|
+
workspaceRegistry.set(key, dir);
|
|
293
|
+
if (workspaceRegistry.size > MAX_WORKSPACES) {
|
|
294
|
+
const oldest = workspaceRegistry.keys().next().value;
|
|
295
|
+
const oldDir = workspaceRegistry.get(oldest);
|
|
296
|
+
workspaceRegistry.delete(oldest);
|
|
297
|
+
rm(oldDir, { recursive: true, force: true }).catch(() => {
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return { dir, reused: false };
|
|
301
|
+
}
|
|
302
|
+
async function discardWorkspace(key) {
|
|
303
|
+
const dir = workspaceRegistry.get(key);
|
|
304
|
+
workspaceRegistry.delete(key);
|
|
305
|
+
if (dir !== void 0) {
|
|
306
|
+
await cleanup(dir).catch(() => {
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function resetWorkspace(dir) {
|
|
311
|
+
await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
|
|
312
|
+
});
|
|
313
|
+
await git(["-C", dir, "clean", "-fd"]).catch(() => {
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
async function prepareAtSha(ctx, dir, reused) {
|
|
317
|
+
const identity = { name: ctx.agentName, email: ctx.agentEmail };
|
|
318
|
+
if (!reused) {
|
|
319
|
+
await cloneAtSha(ctx, dir);
|
|
320
|
+
await ensureGitIdentity(dir, identity);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
324
|
+
await ensureGitIdentity(dir, identity);
|
|
325
|
+
}
|
|
326
|
+
async function prepareResumingBranch(ctx, dir, reused) {
|
|
327
|
+
const identity = { name: ctx.agentName, email: ctx.agentEmail };
|
|
328
|
+
if (!reused) {
|
|
329
|
+
const result = await cloneResumingBranch(ctx, dir);
|
|
330
|
+
await ensureGitIdentity(dir, identity);
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
334
|
+
await ensureGitIdentity(dir, identity);
|
|
335
|
+
return { resumed: true };
|
|
336
|
+
}
|
|
337
|
+
async function sweepWorkspaces() {
|
|
338
|
+
const base = tmpdir();
|
|
339
|
+
let entries;
|
|
340
|
+
try {
|
|
341
|
+
entries = await readdir(base);
|
|
342
|
+
} catch {
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
let removed = 0;
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
348
|
+
try {
|
|
349
|
+
await rm(join2(base, entry), { recursive: true, force: true });
|
|
350
|
+
removed++;
|
|
351
|
+
} catch {
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return removed;
|
|
355
|
+
}
|
|
356
|
+
async function cloneAtSha(ctx, dir) {
|
|
357
|
+
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
358
|
+
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
359
|
+
}
|
|
360
|
+
async function cloneResumingBranch(ctx, dir) {
|
|
361
|
+
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
362
|
+
try {
|
|
363
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
|
|
364
|
+
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
|
|
365
|
+
return { resumed: true };
|
|
366
|
+
} catch {
|
|
367
|
+
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
368
|
+
return { resumed: false };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function hasChanges(dir) {
|
|
372
|
+
await git(["-C", dir, "add", "-A"]);
|
|
373
|
+
const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
|
|
374
|
+
return stdout2.trim().length > 0;
|
|
375
|
+
}
|
|
376
|
+
var PreCommitError = class extends Error {
|
|
377
|
+
constructor(log) {
|
|
378
|
+
super("pre-commit checks failed");
|
|
379
|
+
this.log = log;
|
|
380
|
+
this.name = "PreCommitError";
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
function commitFailureLog(err) {
|
|
384
|
+
const e = err;
|
|
385
|
+
const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
|
|
386
|
+
return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
|
|
387
|
+
}
|
|
388
|
+
function isUnsupportedGitSubcommand(err) {
|
|
389
|
+
const e = err;
|
|
390
|
+
const text = `${typeof e.stderr === "string" ? e.stderr : ""}
|
|
391
|
+
${e.message ?? ""}`;
|
|
392
|
+
return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
|
|
393
|
+
}
|
|
394
|
+
async function runRepoChecks(dir) {
|
|
395
|
+
try {
|
|
396
|
+
await git(["-C", dir, "hook", "run", "pre-commit"]);
|
|
397
|
+
logEvent("checks", "pre-commit hook passed");
|
|
398
|
+
return { ok: true, log: "", skipped: false };
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (isUnsupportedGitSubcommand(err)) {
|
|
401
|
+
logEvent("checks", "pre-commit hook skipped (git too old)");
|
|
402
|
+
return { ok: true, log: "", skipped: true };
|
|
403
|
+
}
|
|
404
|
+
const log = commitFailureLog(err);
|
|
405
|
+
logEvent("checks:err", log);
|
|
406
|
+
return { ok: false, log, skipped: false };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function commitChanges(ctx, dir) {
|
|
410
|
+
if (!await hasChanges(dir)) return false;
|
|
411
|
+
try {
|
|
412
|
+
await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
throw new PreCommitError(commitFailureLog(err));
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
async function pushBranch(ctx, dir) {
|
|
419
|
+
await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
|
|
420
|
+
}
|
|
421
|
+
var RebaseConflictError = class extends Error {
|
|
422
|
+
constructor(mergeBranch, files) {
|
|
423
|
+
const list = files.length ? `: ${files.join(", ")}` : "";
|
|
424
|
+
super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
|
|
425
|
+
this.mergeBranch = mergeBranch;
|
|
426
|
+
this.files = files;
|
|
427
|
+
this.name = "RebaseConflictError";
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
async function rebaseOntoMergeBranch(ctx, dir) {
|
|
431
|
+
const { mergeBranch } = ctx.repo;
|
|
432
|
+
if (!mergeBranch) return;
|
|
433
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
434
|
+
try {
|
|
435
|
+
await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
|
|
438
|
+
() => ({ stdout: "" })
|
|
439
|
+
);
|
|
440
|
+
const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
441
|
+
await git(["-C", dir, "rebase", "--abort"]).catch(() => {
|
|
442
|
+
});
|
|
443
|
+
if (files.length === 0) throw err;
|
|
444
|
+
throw new RebaseConflictError(mergeBranch, files);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async function mergeInMergeBranch(ctx, dir) {
|
|
448
|
+
const { mergeBranch } = ctx.repo;
|
|
449
|
+
if (!mergeBranch) return { conflicted: false };
|
|
450
|
+
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
451
|
+
try {
|
|
452
|
+
await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
|
|
453
|
+
return { conflicted: false };
|
|
454
|
+
} catch {
|
|
455
|
+
return { conflicted: true };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function listUnmergedPaths(dir) {
|
|
459
|
+
const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
|
|
460
|
+
stdout: ""
|
|
461
|
+
}));
|
|
462
|
+
return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
463
|
+
}
|
|
464
|
+
async function listConflictMarkerPaths(dir, paths) {
|
|
465
|
+
if (paths.length === 0) return [];
|
|
466
|
+
const { stdout: stdout2 } = await git([
|
|
467
|
+
"-C",
|
|
468
|
+
dir,
|
|
469
|
+
"grep",
|
|
470
|
+
"--no-color",
|
|
471
|
+
"-lE",
|
|
472
|
+
"^(<<<<<<<|>>>>>>>|\\|\\|\\|\\|\\|\\|\\|)",
|
|
473
|
+
"--",
|
|
474
|
+
...paths
|
|
475
|
+
]).catch(() => ({ stdout: "" }));
|
|
476
|
+
return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
477
|
+
}
|
|
478
|
+
async function openPullRequest(ctx) {
|
|
479
|
+
const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
|
|
480
|
+
if (!mergeBranch) return null;
|
|
481
|
+
const apiBase = `https://api.github.com/repos/${owner}/${name}`;
|
|
482
|
+
const headers = {
|
|
483
|
+
authorization: `Bearer ${cloneToken}`,
|
|
484
|
+
accept: "application/vnd.github+json",
|
|
485
|
+
"x-github-api-version": "2022-11-28",
|
|
486
|
+
"content-type": "application/json"
|
|
487
|
+
};
|
|
488
|
+
const title = jobTitle(ctx);
|
|
489
|
+
const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
|
|
490
|
+
const res = await fetch(`${apiBase}/pulls`, {
|
|
491
|
+
method: "POST",
|
|
492
|
+
headers,
|
|
493
|
+
body: JSON.stringify({
|
|
494
|
+
title: `FlumeCode: ${title}`,
|
|
495
|
+
head: checkoutBranch,
|
|
496
|
+
base: mergeBranch,
|
|
497
|
+
body
|
|
498
|
+
})
|
|
499
|
+
});
|
|
500
|
+
if (res.status === 201) {
|
|
501
|
+
const data = await res.json();
|
|
502
|
+
return { number: data.number, url: data.html_url };
|
|
503
|
+
}
|
|
504
|
+
if (res.status === 422) {
|
|
505
|
+
const list = await fetch(
|
|
506
|
+
`${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
|
|
507
|
+
{ headers }
|
|
508
|
+
);
|
|
509
|
+
if (list.ok) {
|
|
510
|
+
const open = await list.json();
|
|
511
|
+
if (open[0]) return { number: open[0].number, url: open[0].html_url };
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
|
|
516
|
+
}
|
|
517
|
+
async function cleanup(dir) {
|
|
518
|
+
await rm(dir, { recursive: true, force: true });
|
|
519
|
+
}
|
|
520
|
+
function parsePrFromSubject(subject) {
|
|
521
|
+
const m = subject.match(/\(#(\d+)\)\s*$/);
|
|
522
|
+
return m ? Number(m[1]) : null;
|
|
523
|
+
}
|
|
524
|
+
async function incomingPrNumbers(ctx, dir, paths) {
|
|
525
|
+
if (!paths.length) return [];
|
|
526
|
+
try {
|
|
527
|
+
const mergeHeadResult = await git(["-C", dir, "rev-parse", "MERGE_HEAD"]);
|
|
528
|
+
const mergeHead = mergeHeadResult.stdout.trim();
|
|
529
|
+
const baseResult = await git(["-C", dir, "merge-base", "HEAD", mergeHead]);
|
|
530
|
+
const base = baseResult.stdout.trim();
|
|
531
|
+
const logResult = await git([
|
|
532
|
+
"-C",
|
|
533
|
+
dir,
|
|
534
|
+
"log",
|
|
535
|
+
"--no-merges",
|
|
536
|
+
`--format=%H%x1f%s`,
|
|
537
|
+
`${base}..${mergeHead}`,
|
|
538
|
+
"--",
|
|
539
|
+
...paths
|
|
540
|
+
]);
|
|
541
|
+
const nums = /* @__PURE__ */ new Set();
|
|
542
|
+
const needLookup = [];
|
|
543
|
+
for (const line of logResult.stdout.split("\n").filter(Boolean)) {
|
|
544
|
+
const idx = line.indexOf("");
|
|
545
|
+
const sha = line.slice(0, idx);
|
|
546
|
+
const subject = line.slice(idx + 1);
|
|
547
|
+
const n = parsePrFromSubject(subject);
|
|
548
|
+
if (n !== null) nums.add(n);
|
|
549
|
+
else needLookup.push(sha);
|
|
550
|
+
}
|
|
551
|
+
for (const sha of needLookup) {
|
|
552
|
+
for (const n of await prNumbersForCommit(ctx, sha)) nums.add(n);
|
|
553
|
+
}
|
|
554
|
+
return [...nums];
|
|
555
|
+
} catch {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async function prNumbersForCommit(ctx, sha) {
|
|
560
|
+
const { owner, name, cloneToken } = ctx.repo;
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${name}/commits/${sha}/pulls`, {
|
|
563
|
+
headers: {
|
|
564
|
+
authorization: `Bearer ${cloneToken}`,
|
|
565
|
+
accept: "application/vnd.github+json",
|
|
566
|
+
"x-github-api-version": "2022-11-28"
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
if (!res.ok) return [];
|
|
570
|
+
return (await res.json()).map((p) => p.number);
|
|
571
|
+
} catch {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/plugins/manifest.ts
|
|
577
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
578
|
+
import { readdir as readdir2, readFile } from "node:fs/promises";
|
|
579
|
+
import { join as join3 } from "node:path";
|
|
580
|
+
async function loadPlugins(dir) {
|
|
581
|
+
const pluginsDir = join3(dir, ".flumecode", "plugins");
|
|
582
|
+
if (!existsSync3(pluginsDir)) return [];
|
|
583
|
+
let entries;
|
|
584
|
+
try {
|
|
585
|
+
entries = await readdir2(pluginsDir);
|
|
586
|
+
} catch {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
const manifests = [];
|
|
590
|
+
for (const entry of entries) {
|
|
591
|
+
const manifestPath = join3(pluginsDir, entry, "plugin.json");
|
|
592
|
+
try {
|
|
593
|
+
const raw = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
594
|
+
const manifest = parseManifest(raw);
|
|
595
|
+
if (manifest) manifests.push(manifest);
|
|
596
|
+
} catch {
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return manifests;
|
|
600
|
+
}
|
|
601
|
+
function parseManifest(raw) {
|
|
602
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
603
|
+
const r = raw;
|
|
604
|
+
if (typeof r.key !== "string" || !r.key) return null;
|
|
605
|
+
if (r.socket !== "pre-commit") return null;
|
|
606
|
+
if (typeof r.run !== "string" || !r.run) return null;
|
|
607
|
+
const manifest = { key: r.key, socket: r.socket, run: r.run };
|
|
608
|
+
if (typeof r.heartbeat === "object" && r.heartbeat !== null) {
|
|
609
|
+
const hb = r.heartbeat;
|
|
610
|
+
if (typeof hb.url === "string" && typeof hb.token === "string") {
|
|
611
|
+
manifest.heartbeat = { url: hb.url, token: hb.token };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return manifest;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/plugins/socket.ts
|
|
618
|
+
var exec2 = promisify2(execCb);
|
|
619
|
+
async function runSocket(socketName, dir) {
|
|
620
|
+
const plugins = (await loadPlugins(dir)).filter((p) => p.socket === socketName);
|
|
621
|
+
for (const plugin of plugins) {
|
|
622
|
+
const result = await runPluginCommand(plugin.run, dir);
|
|
623
|
+
if (result.exitCode !== 0) {
|
|
624
|
+
throw new PreCommitError(`[plugin:${plugin.key}] ${result.output}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function runPluginCommand(command2, cwd) {
|
|
629
|
+
try {
|
|
630
|
+
const result = await exec2(command2, { cwd, maxBuffer: 1 << 24 });
|
|
631
|
+
const output = [result.stdout, result.stderr].map((s) => s.trim()).filter(Boolean).join("\n");
|
|
632
|
+
return { exitCode: 0, output };
|
|
633
|
+
} catch (err) {
|
|
634
|
+
const e = err;
|
|
635
|
+
const output = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter(Boolean).join("\n");
|
|
636
|
+
return { exitCode: typeof e.code === "number" ? e.code : 1, output };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
147
640
|
// src/executor.ts
|
|
148
641
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
149
642
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -252,59 +745,59 @@ var planInputSchema = {
|
|
|
252
745
|
};
|
|
253
746
|
var planSchema = z2.object(planInputSchema);
|
|
254
747
|
function renderPlan(plan) {
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
748
|
+
const lines2 = [];
|
|
749
|
+
lines2.push(`# ${plan.title}`);
|
|
750
|
+
lines2.push("");
|
|
751
|
+
lines2.push(`**Scope** \u2014 \`${plan.scope}\``);
|
|
752
|
+
lines2.push("");
|
|
753
|
+
lines2.push(`**Goal** \u2014 ${plan.goal}`);
|
|
261
754
|
if (plan.assumptions.length > 0) {
|
|
262
|
-
|
|
263
|
-
|
|
755
|
+
lines2.push("");
|
|
756
|
+
lines2.push("**Assumptions**");
|
|
264
757
|
for (const assumption of plan.assumptions) {
|
|
265
|
-
|
|
758
|
+
lines2.push(`- ${assumption}`);
|
|
266
759
|
}
|
|
267
760
|
}
|
|
268
|
-
|
|
269
|
-
|
|
761
|
+
lines2.push("");
|
|
762
|
+
lines2.push("## Steps");
|
|
270
763
|
for (const [i, step] of plan.steps.entries()) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
764
|
+
lines2.push("");
|
|
765
|
+
lines2.push(`### ${i + 1}. ${step.title}`);
|
|
766
|
+
lines2.push("");
|
|
767
|
+
lines2.push(step.description);
|
|
275
768
|
if (step.pseudoCode && step.pseudoCode.length > 0) {
|
|
276
769
|
for (const entry of step.pseudoCode) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
770
|
+
lines2.push("");
|
|
771
|
+
lines2.push(`\`${entry.file}\``);
|
|
772
|
+
lines2.push("");
|
|
773
|
+
lines2.push("```");
|
|
774
|
+
lines2.push(entry.pseudoCode);
|
|
775
|
+
lines2.push("```");
|
|
283
776
|
}
|
|
284
777
|
}
|
|
285
778
|
}
|
|
286
|
-
|
|
287
|
-
|
|
779
|
+
lines2.push("");
|
|
780
|
+
lines2.push("## Acceptance criteria");
|
|
288
781
|
for (const criterion of plan.acceptanceCriteria) {
|
|
289
|
-
|
|
782
|
+
lines2.push(`- [ ] ${criterion}`);
|
|
290
783
|
}
|
|
291
784
|
if (plan.risks.length > 0) {
|
|
292
|
-
|
|
293
|
-
|
|
785
|
+
lines2.push("");
|
|
786
|
+
lines2.push("**Risks / open questions**");
|
|
294
787
|
for (const risk of plan.risks) {
|
|
295
|
-
|
|
788
|
+
lines2.push(`- ${risk}`);
|
|
296
789
|
}
|
|
297
790
|
}
|
|
298
791
|
if (plan.outOfScope.length > 0) {
|
|
299
|
-
|
|
300
|
-
|
|
792
|
+
lines2.push("");
|
|
793
|
+
lines2.push("**Out of scope**");
|
|
301
794
|
for (const item of plan.outOfScope) {
|
|
302
|
-
|
|
795
|
+
lines2.push(`- ${item}`);
|
|
303
796
|
}
|
|
304
797
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return
|
|
798
|
+
lines2.push("");
|
|
799
|
+
lines2.push(PLAN_MARKER);
|
|
800
|
+
return lines2.join("\n");
|
|
308
801
|
}
|
|
309
802
|
var submitPlanInputSchema = {
|
|
310
803
|
plans: z2.array(z2.object(planInputSchema)).min(1).refine(
|
|
@@ -379,27 +872,27 @@ var reportInputSchema = {
|
|
|
379
872
|
};
|
|
380
873
|
var reportSchema = z3.object(reportInputSchema);
|
|
381
874
|
function renderReport(report) {
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
875
|
+
const lines2 = [];
|
|
876
|
+
lines2.push(report.summary.trim());
|
|
877
|
+
lines2.push("");
|
|
878
|
+
lines2.push(report.prose.trim());
|
|
879
|
+
lines2.push("");
|
|
880
|
+
lines2.push("## Acceptance criteria");
|
|
388
881
|
for (const ac of report.acceptanceCriteria) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
882
|
+
lines2.push("");
|
|
883
|
+
lines2.push(`### ${STATUS_ICON[ac.status]} ${ac.criterion}`);
|
|
884
|
+
lines2.push("");
|
|
885
|
+
lines2.push(ac.rationale.trim());
|
|
393
886
|
for (const ev of ac.evidence) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
887
|
+
lines2.push("");
|
|
888
|
+
lines2.push(ev.note ? `\`${ev.file}\` \u2014 ${ev.note}` : `\`${ev.file}\``);
|
|
889
|
+
lines2.push("");
|
|
890
|
+
lines2.push("```diff");
|
|
891
|
+
lines2.push(ev.hunk.replace(/\n+$/, ""));
|
|
892
|
+
lines2.push("```");
|
|
400
893
|
}
|
|
401
894
|
}
|
|
402
|
-
return
|
|
895
|
+
return lines2.join("\n");
|
|
403
896
|
}
|
|
404
897
|
function createReportTooling() {
|
|
405
898
|
let submittedReport = null;
|
|
@@ -428,6 +921,32 @@ function createReportTooling() {
|
|
|
428
921
|
|
|
429
922
|
// src/executor.ts
|
|
430
923
|
var FLUME_PLUGIN_DIR = fileURLToPath2(new URL("../skills-plugin", import.meta.url));
|
|
924
|
+
function emptyUsage() {
|
|
925
|
+
return {
|
|
926
|
+
inputTokens: 0,
|
|
927
|
+
outputTokens: 0,
|
|
928
|
+
cacheCreationTokens: 0,
|
|
929
|
+
cacheReadTokens: 0,
|
|
930
|
+
costUsd: 0
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
var usageAcc = emptyUsage();
|
|
934
|
+
function resetUsage() {
|
|
935
|
+
usageAcc = emptyUsage();
|
|
936
|
+
}
|
|
937
|
+
function getUsage() {
|
|
938
|
+
const totalTokens = usageAcc.inputTokens + usageAcc.outputTokens + usageAcc.cacheCreationTokens + usageAcc.cacheReadTokens;
|
|
939
|
+
return { ...usageAcc, totalTokens };
|
|
940
|
+
}
|
|
941
|
+
function stringifyResult(content) {
|
|
942
|
+
if (typeof content === "string") return content;
|
|
943
|
+
if (Array.isArray(content)) {
|
|
944
|
+
return content.map(
|
|
945
|
+
(c) => typeof c === "object" && c !== null && "text" in c ? String(c.text) : JSON.stringify(c)
|
|
946
|
+
).join("\n");
|
|
947
|
+
}
|
|
948
|
+
return JSON.stringify(content);
|
|
949
|
+
}
|
|
431
950
|
async function runClaudeCode(opts) {
|
|
432
951
|
let finalText = "";
|
|
433
952
|
const { mcpServer, collected } = createWidgetTooling();
|
|
@@ -463,11 +982,34 @@ async function runClaudeCode(opts) {
|
|
|
463
982
|
for (const block of content) {
|
|
464
983
|
if (block && block.type === "text" && typeof block.text === "string") {
|
|
465
984
|
process.stdout.write(block.text);
|
|
985
|
+
logEvent("agent", block.text);
|
|
986
|
+
} else if (block && block.type === "tool_use") {
|
|
987
|
+
logEvent("tool_use", `${block.name} ${JSON.stringify(block.input)}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
} else if (message.type === "user") {
|
|
992
|
+
const content = message.message?.content;
|
|
993
|
+
if (Array.isArray(content)) {
|
|
994
|
+
for (const block of content) {
|
|
995
|
+
if (block && block.type === "tool_result") {
|
|
996
|
+
logEvent("tool_result", stringifyResult(block.content));
|
|
466
997
|
}
|
|
467
998
|
}
|
|
468
999
|
}
|
|
469
1000
|
} else if (message.type === "result") {
|
|
470
1001
|
finalText = message.result ?? "";
|
|
1002
|
+
logEvent("result", finalText);
|
|
1003
|
+
const resultMsg = message;
|
|
1004
|
+
if (resultMsg.usage) {
|
|
1005
|
+
usageAcc.inputTokens += resultMsg.usage.input_tokens ?? 0;
|
|
1006
|
+
usageAcc.outputTokens += resultMsg.usage.output_tokens ?? 0;
|
|
1007
|
+
usageAcc.cacheCreationTokens += resultMsg.usage.cache_creation_input_tokens ?? 0;
|
|
1008
|
+
usageAcc.cacheReadTokens += resultMsg.usage.cache_read_input_tokens ?? 0;
|
|
1009
|
+
}
|
|
1010
|
+
usageAcc.costUsd += resultMsg.total_cost_usd ?? 0;
|
|
1011
|
+
} else if (message.type === "system") {
|
|
1012
|
+
logEvent("system", JSON.stringify(message));
|
|
471
1013
|
}
|
|
472
1014
|
}
|
|
473
1015
|
process.stdout.write("\n");
|
|
@@ -527,11 +1069,11 @@ function errorMessage(err) {
|
|
|
527
1069
|
|
|
528
1070
|
// src/rules.ts
|
|
529
1071
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
530
|
-
import { join as
|
|
1072
|
+
import { join as join4 } from "node:path";
|
|
531
1073
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
532
1074
|
var RULES_DIR = fileURLToPath3(new URL("../skills-plugin/rules", import.meta.url));
|
|
533
1075
|
function loadRule(name) {
|
|
534
|
-
const raw = readFileSync3(
|
|
1076
|
+
const raw = readFileSync3(join4(RULES_DIR, `${name}.md`), "utf8");
|
|
535
1077
|
return stripFrontMatter(raw).trim();
|
|
536
1078
|
}
|
|
537
1079
|
function stripFrontMatter(raw) {
|
|
@@ -547,18 +1089,18 @@ function turnHeading(turn, agentName) {
|
|
|
547
1089
|
if (turn.kind === "report") return `${agentName} (implementation report)`;
|
|
548
1090
|
return agentName;
|
|
549
1091
|
}
|
|
550
|
-
function appendThread(
|
|
1092
|
+
function appendThread(lines2, ctx) {
|
|
551
1093
|
if (!ctx.thread || ctx.thread.length === 0) return;
|
|
552
|
-
|
|
1094
|
+
lines2.push("", "# Conversation so far");
|
|
553
1095
|
for (const turn of ctx.thread) {
|
|
554
|
-
|
|
1096
|
+
lines2.push("", `## ${turnHeading(turn, ctx.agentName)}`, turn.content);
|
|
555
1097
|
}
|
|
556
1098
|
}
|
|
557
1099
|
function buildPrompt(ctx) {
|
|
558
1100
|
const task = ctx.permissionMode === "plan" ? `Use the \`flumecode:request-to-plan\` skill to handle this request. You are read-only and cannot modify files \u2014 clarify any ambiguity with the user first, then produce a concrete, actionable plan (the specific changes you would make and why). Cite the relevant files. Do NOT call ExitPlanMode or write the plan to a file. When the plan is ready, call the \`submit_plan\` tool with the structured plan fields; the runner renders it into the canonical plan markdown and posts it as your comment.` : `Use the \`flumecode:implement-plan\` skill to handle this request. You are the ORCHESTRATOR: do not implement, review, or write the report yourself \u2014 follow the skill to delegate each phase to subagents via the Task tool, picking the right model for each. Do not commit or push \u2014 the runner handles that.`;
|
|
559
1101
|
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this request. If there is no wiki, work from the code directly.`;
|
|
560
1102
|
const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
|
|
561
|
-
const
|
|
1103
|
+
const lines2 = [
|
|
562
1104
|
`You are "${ctx.agentName}", an autonomous coding agent working inside a FlumeCode request.`,
|
|
563
1105
|
`The repository ${ctx.repo.fullName} is checked out in your current working directory on branch "${ctx.repo.checkoutBranch}" at commit ${ctx.repo.checkoutSha.slice(0, 7)}.`,
|
|
564
1106
|
task,
|
|
@@ -566,29 +1108,29 @@ function buildPrompt(ctx) {
|
|
|
566
1108
|
widgets
|
|
567
1109
|
];
|
|
568
1110
|
if (ctx.permissionMode !== "plan") {
|
|
569
|
-
|
|
1111
|
+
lines2.push(
|
|
570
1112
|
"",
|
|
571
1113
|
"These coding guidelines apply to all code produced in this run:",
|
|
572
1114
|
"",
|
|
573
1115
|
loadRule("coding-guideline")
|
|
574
1116
|
);
|
|
575
1117
|
}
|
|
576
|
-
|
|
1118
|
+
lines2.push("", `# Request: ${ctx.request?.title ?? ""}`);
|
|
577
1119
|
if (ctx.request?.body) {
|
|
578
|
-
|
|
1120
|
+
lines2.push("", ctx.request.body);
|
|
579
1121
|
}
|
|
580
|
-
appendThread(
|
|
581
|
-
|
|
1122
|
+
appendThread(lines2, ctx);
|
|
1123
|
+
lines2.push(
|
|
582
1124
|
"",
|
|
583
1125
|
ctx.permissionMode === "plan" ? "Your final reply is posted verbatim as your comment in the thread \u2014 if you called `submit_plan`, the rendered plan is posted automatically; for clarifying questions, your reply text is posted as-is." : "Your final reply is posted verbatim as your comment in the thread \u2014 make it the implementation report your report subagent produced, with nothing added. The runner appends the pull-request link."
|
|
584
1126
|
);
|
|
585
|
-
return
|
|
1127
|
+
return lines2.join("\n");
|
|
586
1128
|
}
|
|
587
1129
|
function buildRevisePrompt(ctx) {
|
|
588
1130
|
const task = `Use the \`flumecode:revise-implementation\` skill to handle this turn. The plan below was already implemented (its implementation report appears in the conversation below, tagged as such); the user is now asking to fine-tune that implementation. Decide how to respond to their latest message: if it's unclear, ask a clarifying question (as a widget); if it's a bad idea or not feasible, push back with your reasoning; if it warrants rethinking the plan, call \`submit_plan\` with a revised plan; otherwise implement the requested change. When you implement, you are the ORCHESTRATOR: delegate the work to subagents via the Task tool as the skill directs, and do not commit or push \u2014 the runner handles that, updating the existing pull request.`;
|
|
589
1131
|
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this change. If there is no wiki, work from the code directly.`;
|
|
590
1132
|
const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
|
|
591
|
-
const
|
|
1133
|
+
const lines2 = [
|
|
592
1134
|
`You are "${ctx.agentName}", an autonomous coding agent fine-tuning an implemented FlumeCode plan in an ongoing thread with the user.`,
|
|
593
1135
|
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from, so any change you push updates that PR.`,
|
|
594
1136
|
task,
|
|
@@ -602,20 +1144,20 @@ function buildRevisePrompt(ctx) {
|
|
|
602
1144
|
`# Plan: ${ctx.request?.title ?? ""}`
|
|
603
1145
|
];
|
|
604
1146
|
if (ctx.request?.body) {
|
|
605
|
-
|
|
1147
|
+
lines2.push("", ctx.request.body);
|
|
606
1148
|
}
|
|
607
|
-
appendThread(
|
|
608
|
-
|
|
1149
|
+
appendThread(lines2, ctx);
|
|
1150
|
+
lines2.push(
|
|
609
1151
|
"",
|
|
610
1152
|
"The last message above is the user's request for this turn. Your final reply is posted verbatim as your comment in the plan thread: if you implemented a change, make it a short report of what you changed (the runner appends the pull-request link); if you asked a question, called `submit_plan`, or pushed back, your reply text is posted as-is."
|
|
611
1153
|
);
|
|
612
|
-
return
|
|
1154
|
+
return lines2.join("\n");
|
|
613
1155
|
}
|
|
614
|
-
function buildResolvePrompt(ctx) {
|
|
1156
|
+
function buildResolvePrompt(ctx, related = []) {
|
|
615
1157
|
const mergeBranch = ctx.repo.mergeBranch ?? "the merge branch";
|
|
616
1158
|
const task = `Use the \`flumecode:resolve-merge-conflict\` skill to handle this turn. A merge of \`${mergeBranch}\` into this branch is IN PROGRESS and has left conflict markers in your working tree. Resolve every conflicted file by correctly integrating BOTH sides \u2014 the change this session implemented (described below) and the incoming changes from \`${mergeBranch}\` \u2014 never blindly discard either side. Remove all conflict markers and verify the result builds and tests pass. Do NOT \`git add\`, commit, push, or open a pull request \u2014 the runner finalizes the merge commit and updates the existing pull request.`;
|
|
617
1159
|
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to the conflicting code. If there is no wiki, work from the code directly.`;
|
|
618
|
-
const
|
|
1160
|
+
const lines2 = [
|
|
619
1161
|
`You are "${ctx.agentName}", an autonomous coding agent resolving merge conflicts on an implemented FlumeCode plan.`,
|
|
620
1162
|
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the plan's implementation branch "${ctx.repo.checkoutBranch}" \u2014 the same branch its open pull request is built from \u2014 with an in-progress merge of "${mergeBranch}".`,
|
|
621
1163
|
task,
|
|
@@ -628,17 +1170,29 @@ function buildResolvePrompt(ctx) {
|
|
|
628
1170
|
`# Plan: ${ctx.request?.title ?? ""}`
|
|
629
1171
|
];
|
|
630
1172
|
if (ctx.request?.body) {
|
|
631
|
-
|
|
1173
|
+
lines2.push("", ctx.request.body);
|
|
1174
|
+
}
|
|
1175
|
+
appendThread(lines2, ctx);
|
|
1176
|
+
if (related.length > 0) {
|
|
1177
|
+
lines2.push(
|
|
1178
|
+
"",
|
|
1179
|
+
"# Related sessions behind the incoming changes",
|
|
1180
|
+
"Each conflicting change on the merge branch came from another coding session whose plan and report follow. Preserve THEIR intent too while integrating them with this session's work \u2014 do not undo what they built."
|
|
1181
|
+
);
|
|
1182
|
+
for (const r of related) {
|
|
1183
|
+
lines2.push("", `## PR #${r.prNumber} \u2014 ${r.title}`);
|
|
1184
|
+
if (r.plan) lines2.push("", "### Accepted plan", r.plan);
|
|
1185
|
+
if (r.report) lines2.push("", "### Final report", r.report);
|
|
1186
|
+
}
|
|
632
1187
|
}
|
|
633
|
-
|
|
634
|
-
lines.push(
|
|
1188
|
+
lines2.push(
|
|
635
1189
|
"",
|
|
636
1190
|
"Resolve the conflicts now. Your final reply is posted as a report in the plan thread: summarize which files conflicted and how you resolved each (the runner appends the pull-request link, so don't add one)."
|
|
637
1191
|
);
|
|
638
|
-
return
|
|
1192
|
+
return lines2.join("\n");
|
|
639
1193
|
}
|
|
640
1194
|
function buildDocumentPrompt(ctx) {
|
|
641
|
-
const
|
|
1195
|
+
const lines2 = [
|
|
642
1196
|
`You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
|
|
643
1197
|
`An implementation just ran in this working directory to satisfy the request below; its changes are uncommitted in the working tree.`,
|
|
644
1198
|
`Use the \`flumecode:document\` skill to bring the wiki in sync with those changes. Only edit files under \`.flumecode/wiki/\` \u2014 do not touch application code. The runner commits the wiki alongside the implementation in the same pull request.`,
|
|
@@ -646,14 +1200,14 @@ function buildDocumentPrompt(ctx) {
|
|
|
646
1200
|
`# Request: ${ctx.request?.title ?? ""}`
|
|
647
1201
|
];
|
|
648
1202
|
if (ctx.request?.body) {
|
|
649
|
-
|
|
1203
|
+
lines2.push("", ctx.request.body);
|
|
650
1204
|
}
|
|
651
|
-
appendThread(
|
|
652
|
-
|
|
653
|
-
return
|
|
1205
|
+
appendThread(lines2, ctx);
|
|
1206
|
+
lines2.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
|
|
1207
|
+
return lines2.join("\n");
|
|
654
1208
|
}
|
|
655
1209
|
function buildRepairPrompt(ctx, hookLog) {
|
|
656
|
-
const
|
|
1210
|
+
const lines2 = [
|
|
657
1211
|
`You are "${ctx.agentName}", fixing a failed pre-commit check in the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
658
1212
|
`The changes from the previous step are still uncommitted in the working tree. When the runner tried to commit them, the repository's pre-commit hook \u2014 which runs the project's own checks (lint / typecheck / unit tests) \u2014 failed. Make the working tree pass those checks: fix the failing code or tests at their root. Do NOT delete or skip tests, weaken assertions, or disable the checks to silence the failure. Preserve the intent of the original change; repair only what's broken. Do NOT commit or push \u2014 the runner re-commits once the checks pass.`,
|
|
659
1213
|
"",
|
|
@@ -669,13 +1223,13 @@ function buildRepairPrompt(ctx, hookLog) {
|
|
|
669
1223
|
"",
|
|
670
1224
|
"When done, reply with a one-line summary of what you fixed."
|
|
671
1225
|
];
|
|
672
|
-
return
|
|
1226
|
+
return lines2.join("\n");
|
|
673
1227
|
}
|
|
674
1228
|
function buildReleasePrompt(ctx, baseChecks) {
|
|
675
1229
|
const task = `Use the \`flumecode:create-release\` skill to handle this turn. You are driving a release: first analyse commits since the last tag, propose version bumps, and ask the user to confirm via widgets (Phase 1); once the user's widget answers appear in the thread, apply the bumps to package.json files and update CHANGELOG.md (Phase 2). Do NOT commit or push \u2014 the runner handles that and opens the bump PR.`;
|
|
676
1230
|
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this release. If there is no wiki, work from the code directly.`;
|
|
677
1231
|
const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
|
|
678
|
-
const
|
|
1232
|
+
const lines2 = [
|
|
679
1233
|
`You are "${ctx.agentName}", an autonomous coding agent driving a FlumeCode release.`,
|
|
680
1234
|
`The repository ${ctx.repo.fullName} is checked out in your current working directory on the release bump branch "${ctx.repo.checkoutBranch}".`,
|
|
681
1235
|
task,
|
|
@@ -689,10 +1243,10 @@ function buildReleasePrompt(ctx, baseChecks) {
|
|
|
689
1243
|
`# Release: ${ctx.request?.title ?? ""}`
|
|
690
1244
|
];
|
|
691
1245
|
if (ctx.request?.body) {
|
|
692
|
-
|
|
1246
|
+
lines2.push("", ctx.request.body);
|
|
693
1247
|
}
|
|
694
1248
|
if (baseChecks && !baseChecks.ok) {
|
|
695
|
-
|
|
1249
|
+
lines2.push(
|
|
696
1250
|
"",
|
|
697
1251
|
"# Pre-release check status",
|
|
698
1252
|
"",
|
|
@@ -708,12 +1262,12 @@ function buildReleasePrompt(ctx, baseChecks) {
|
|
|
708
1262
|
"```"
|
|
709
1263
|
);
|
|
710
1264
|
}
|
|
711
|
-
appendThread(
|
|
712
|
-
|
|
1265
|
+
appendThread(lines2, ctx);
|
|
1266
|
+
lines2.push(
|
|
713
1267
|
"",
|
|
714
1268
|
"Your final reply is posted verbatim as your comment in the release thread \u2014 if you called widgets (Phase 1), your reply text accompanies the questions; if you applied the bumps (Phase 2), make it the report the skill produced. The runner appends the pull-request link."
|
|
715
1269
|
);
|
|
716
|
-
return
|
|
1270
|
+
return lines2.join("\n");
|
|
717
1271
|
}
|
|
718
1272
|
function buildInitPrompt(ctx) {
|
|
719
1273
|
return [
|
|
@@ -724,273 +1278,6 @@ function buildInitPrompt(ctx) {
|
|
|
724
1278
|
].join("\n");
|
|
725
1279
|
}
|
|
726
1280
|
|
|
727
|
-
// src/types.ts
|
|
728
|
-
function jobTitle(ctx) {
|
|
729
|
-
return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// src/workspace.ts
|
|
733
|
-
import { execFile } from "node:child_process";
|
|
734
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
735
|
-
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
736
|
-
import { tmpdir } from "node:os";
|
|
737
|
-
import { join as join3 } from "node:path";
|
|
738
|
-
import { promisify } from "node:util";
|
|
739
|
-
var exec = promisify(execFile);
|
|
740
|
-
var WORKSPACE_PREFIX = "flume-runner-";
|
|
741
|
-
var MAX_BUFFER = 1 << 24;
|
|
742
|
-
async function git(args) {
|
|
743
|
-
return exec("git", args, { maxBuffer: MAX_BUFFER });
|
|
744
|
-
}
|
|
745
|
-
var RUNNER_GIT_EMAIL = "runner@flumecode.local";
|
|
746
|
-
var RUNNER_GIT_NAME = "FlumeCode Runner";
|
|
747
|
-
async function ensureGitIdentity(dir) {
|
|
748
|
-
await git(["-C", dir, "config", "user.email", RUNNER_GIT_EMAIL]);
|
|
749
|
-
await git(["-C", dir, "config", "user.name", RUNNER_GIT_NAME]);
|
|
750
|
-
}
|
|
751
|
-
function cloneUrl(ctx) {
|
|
752
|
-
const { owner, name, cloneToken } = ctx.repo;
|
|
753
|
-
return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
|
|
754
|
-
}
|
|
755
|
-
function detectPackageManager(dir) {
|
|
756
|
-
if (!existsSync2(join3(dir, "package.json"))) return null;
|
|
757
|
-
if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
758
|
-
if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
|
|
759
|
-
if (existsSync2(join3(dir, "package-lock.json"))) return "npm";
|
|
760
|
-
if (existsSync2(join3(dir, "bun.lockb"))) return "bun";
|
|
761
|
-
return "npm";
|
|
762
|
-
}
|
|
763
|
-
async function installDependencies(dir) {
|
|
764
|
-
const manager = detectPackageManager(dir);
|
|
765
|
-
if (manager === null) return { status: "skipped" };
|
|
766
|
-
const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
|
|
767
|
-
try {
|
|
768
|
-
await exec(manager, ["install"], { cwd: dir, maxBuffer: MAX_BUFFER, env, timeout: 5 * 6e4 });
|
|
769
|
-
return { status: "installed", manager };
|
|
770
|
-
} catch (err) {
|
|
771
|
-
return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
async function makeWorkspace() {
|
|
775
|
-
return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
|
|
776
|
-
}
|
|
777
|
-
var MAX_WORKSPACES = 8;
|
|
778
|
-
var workspaceRegistry = /* @__PURE__ */ new Map();
|
|
779
|
-
async function acquireWorkspace(key) {
|
|
780
|
-
const existing = workspaceRegistry.get(key);
|
|
781
|
-
if (existing !== void 0 && existsSync2(existing)) {
|
|
782
|
-
workspaceRegistry.delete(key);
|
|
783
|
-
workspaceRegistry.set(key, existing);
|
|
784
|
-
return { dir: existing, reused: true };
|
|
785
|
-
}
|
|
786
|
-
const dir = await makeWorkspace();
|
|
787
|
-
workspaceRegistry.set(key, dir);
|
|
788
|
-
if (workspaceRegistry.size > MAX_WORKSPACES) {
|
|
789
|
-
const oldest = workspaceRegistry.keys().next().value;
|
|
790
|
-
const oldDir = workspaceRegistry.get(oldest);
|
|
791
|
-
workspaceRegistry.delete(oldest);
|
|
792
|
-
rm(oldDir, { recursive: true, force: true }).catch(() => {
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
return { dir, reused: false };
|
|
796
|
-
}
|
|
797
|
-
async function discardWorkspace(key) {
|
|
798
|
-
const dir = workspaceRegistry.get(key);
|
|
799
|
-
workspaceRegistry.delete(key);
|
|
800
|
-
if (dir !== void 0) {
|
|
801
|
-
await cleanup(dir).catch(() => {
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
async function resetWorkspace(dir) {
|
|
806
|
-
await git(["-C", dir, "reset", "--hard", "HEAD"]).catch(() => {
|
|
807
|
-
});
|
|
808
|
-
await git(["-C", dir, "clean", "-fd"]).catch(() => {
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
async function prepareAtSha(ctx, dir, reused) {
|
|
812
|
-
if (!reused) {
|
|
813
|
-
await cloneAtSha(ctx, dir);
|
|
814
|
-
await ensureGitIdentity(dir);
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
817
|
-
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
818
|
-
await ensureGitIdentity(dir);
|
|
819
|
-
}
|
|
820
|
-
async function prepareResumingBranch(ctx, dir, reused) {
|
|
821
|
-
if (!reused) {
|
|
822
|
-
const result = await cloneResumingBranch(ctx, dir);
|
|
823
|
-
await ensureGitIdentity(dir);
|
|
824
|
-
return result;
|
|
825
|
-
}
|
|
826
|
-
await git(["-C", dir, "remote", "set-url", "origin", cloneUrl(ctx)]);
|
|
827
|
-
await ensureGitIdentity(dir);
|
|
828
|
-
return { resumed: true };
|
|
829
|
-
}
|
|
830
|
-
async function sweepWorkspaces() {
|
|
831
|
-
const base = tmpdir();
|
|
832
|
-
let entries;
|
|
833
|
-
try {
|
|
834
|
-
entries = await readdir(base);
|
|
835
|
-
} catch {
|
|
836
|
-
return 0;
|
|
837
|
-
}
|
|
838
|
-
let removed = 0;
|
|
839
|
-
for (const entry of entries) {
|
|
840
|
-
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
841
|
-
try {
|
|
842
|
-
await rm(join3(base, entry), { recursive: true, force: true });
|
|
843
|
-
removed++;
|
|
844
|
-
} catch {
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
return removed;
|
|
848
|
-
}
|
|
849
|
-
async function cloneAtSha(ctx, dir) {
|
|
850
|
-
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
851
|
-
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
852
|
-
}
|
|
853
|
-
async function cloneResumingBranch(ctx, dir) {
|
|
854
|
-
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
855
|
-
try {
|
|
856
|
-
await git(["-C", dir, "fetch", "--quiet", "origin", ctx.repo.checkoutBranch]);
|
|
857
|
-
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, "FETCH_HEAD"]);
|
|
858
|
-
return { resumed: true };
|
|
859
|
-
} catch {
|
|
860
|
-
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
861
|
-
return { resumed: false };
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
async function hasChanges(dir) {
|
|
865
|
-
await git(["-C", dir, "add", "-A"]);
|
|
866
|
-
const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
|
|
867
|
-
return stdout2.trim().length > 0;
|
|
868
|
-
}
|
|
869
|
-
var PreCommitError = class extends Error {
|
|
870
|
-
constructor(log) {
|
|
871
|
-
super("pre-commit checks failed");
|
|
872
|
-
this.log = log;
|
|
873
|
-
this.name = "PreCommitError";
|
|
874
|
-
}
|
|
875
|
-
};
|
|
876
|
-
function commitFailureLog(err) {
|
|
877
|
-
const e = err;
|
|
878
|
-
const parts = [e.stdout, e.stderr].map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
|
|
879
|
-
return parts.length > 0 ? parts.join("\n") : e.message ?? String(err);
|
|
880
|
-
}
|
|
881
|
-
function isUnsupportedGitSubcommand(err) {
|
|
882
|
-
const e = err;
|
|
883
|
-
const text = `${typeof e.stderr === "string" ? e.stderr : ""}
|
|
884
|
-
${e.message ?? ""}`;
|
|
885
|
-
return /is not a git command|unknown subcommand|usage: git hook/i.test(text);
|
|
886
|
-
}
|
|
887
|
-
async function runRepoChecks(dir) {
|
|
888
|
-
try {
|
|
889
|
-
await git(["-C", dir, "hook", "run", "pre-commit"]);
|
|
890
|
-
return { ok: true, log: "", skipped: false };
|
|
891
|
-
} catch (err) {
|
|
892
|
-
if (isUnsupportedGitSubcommand(err)) return { ok: true, log: "", skipped: true };
|
|
893
|
-
return { ok: false, log: commitFailureLog(err), skipped: false };
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
async function commitChanges(ctx, dir) {
|
|
897
|
-
if (!await hasChanges(dir)) return false;
|
|
898
|
-
try {
|
|
899
|
-
await git(["-C", dir, "commit", "--quiet", "-m", `FlumeCode: ${jobTitle(ctx)}`]);
|
|
900
|
-
} catch (err) {
|
|
901
|
-
throw new PreCommitError(commitFailureLog(err));
|
|
902
|
-
}
|
|
903
|
-
return true;
|
|
904
|
-
}
|
|
905
|
-
async function pushBranch(ctx, dir) {
|
|
906
|
-
await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
|
|
907
|
-
}
|
|
908
|
-
var RebaseConflictError = class extends Error {
|
|
909
|
-
constructor(mergeBranch, files) {
|
|
910
|
-
const list = files.length ? `: ${files.join(", ")}` : "";
|
|
911
|
-
super(`Rebase onto ${mergeBranch} hit conflicts in ${files.length} file(s)${list}`);
|
|
912
|
-
this.mergeBranch = mergeBranch;
|
|
913
|
-
this.files = files;
|
|
914
|
-
this.name = "RebaseConflictError";
|
|
915
|
-
}
|
|
916
|
-
};
|
|
917
|
-
async function rebaseOntoMergeBranch(ctx, dir) {
|
|
918
|
-
const { mergeBranch } = ctx.repo;
|
|
919
|
-
if (!mergeBranch) return;
|
|
920
|
-
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
921
|
-
try {
|
|
922
|
-
await git(["-C", dir, "rebase", "--empty=drop", "FETCH_HEAD"]);
|
|
923
|
-
} catch (err) {
|
|
924
|
-
const conflicted = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(
|
|
925
|
-
() => ({ stdout: "" })
|
|
926
|
-
);
|
|
927
|
-
const files = conflicted.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
928
|
-
await git(["-C", dir, "rebase", "--abort"]).catch(() => {
|
|
929
|
-
});
|
|
930
|
-
if (files.length === 0) throw err;
|
|
931
|
-
throw new RebaseConflictError(mergeBranch, files);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
async function mergeInMergeBranch(ctx, dir) {
|
|
935
|
-
const { mergeBranch } = ctx.repo;
|
|
936
|
-
if (!mergeBranch) return { conflicted: false };
|
|
937
|
-
await git(["-C", dir, "fetch", "--quiet", "origin", mergeBranch]);
|
|
938
|
-
try {
|
|
939
|
-
await git(["-C", dir, "merge", "--no-edit", "FETCH_HEAD"]);
|
|
940
|
-
return { conflicted: false };
|
|
941
|
-
} catch {
|
|
942
|
-
return { conflicted: true };
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
async function listUnmergedPaths(dir) {
|
|
946
|
-
const { stdout: stdout2 } = await git(["-C", dir, "diff", "--name-only", "--diff-filter=U"]).catch(() => ({
|
|
947
|
-
stdout: ""
|
|
948
|
-
}));
|
|
949
|
-
return stdout2.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
950
|
-
}
|
|
951
|
-
async function openPullRequest(ctx) {
|
|
952
|
-
const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
|
|
953
|
-
if (!mergeBranch) return null;
|
|
954
|
-
const apiBase = `https://api.github.com/repos/${owner}/${name}`;
|
|
955
|
-
const headers = {
|
|
956
|
-
authorization: `Bearer ${cloneToken}`,
|
|
957
|
-
accept: "application/vnd.github+json",
|
|
958
|
-
"x-github-api-version": "2022-11-28",
|
|
959
|
-
"content-type": "application/json"
|
|
960
|
-
};
|
|
961
|
-
const title = jobTitle(ctx);
|
|
962
|
-
const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
|
|
963
|
-
const res = await fetch(`${apiBase}/pulls`, {
|
|
964
|
-
method: "POST",
|
|
965
|
-
headers,
|
|
966
|
-
body: JSON.stringify({
|
|
967
|
-
title: `FlumeCode: ${title}`,
|
|
968
|
-
head: checkoutBranch,
|
|
969
|
-
base: mergeBranch,
|
|
970
|
-
body
|
|
971
|
-
})
|
|
972
|
-
});
|
|
973
|
-
if (res.status === 201) {
|
|
974
|
-
const data = await res.json();
|
|
975
|
-
return { number: data.number, url: data.html_url };
|
|
976
|
-
}
|
|
977
|
-
if (res.status === 422) {
|
|
978
|
-
const list = await fetch(
|
|
979
|
-
`${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
|
|
980
|
-
{ headers }
|
|
981
|
-
);
|
|
982
|
-
if (list.ok) {
|
|
983
|
-
const open = await list.json();
|
|
984
|
-
if (open[0]) return { number: open[0].number, url: open[0].html_url };
|
|
985
|
-
}
|
|
986
|
-
return null;
|
|
987
|
-
}
|
|
988
|
-
throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
|
|
989
|
-
}
|
|
990
|
-
async function cleanup(dir) {
|
|
991
|
-
await rm(dir, { recursive: true, force: true });
|
|
992
|
-
}
|
|
993
|
-
|
|
994
1281
|
// src/run.ts
|
|
995
1282
|
var IDLE_MS = 5e3;
|
|
996
1283
|
var CANCEL_POLL_MS = 2500;
|
|
@@ -1001,7 +1288,7 @@ var MAX_IMPLEMENT_RETRIES = 1;
|
|
|
1001
1288
|
var INIT_MAX_TURNS = 200;
|
|
1002
1289
|
var DOCUMENT_MAX_TURNS = 120;
|
|
1003
1290
|
var HEARTBEAT_MS = 5 * 6e4;
|
|
1004
|
-
async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
|
|
1291
|
+
async function pushAndOpenPr(ctx, dir, config, abort, opts = { rebase: true }) {
|
|
1005
1292
|
if (abort.signal.aborted) throw new Error("Run canceled by user");
|
|
1006
1293
|
const committed = await commitWithRepair(ctx, dir, abort);
|
|
1007
1294
|
if (!committed) return { outcome: { kind: "none" }, autoMerged: false };
|
|
@@ -1015,8 +1302,8 @@ async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
|
|
|
1015
1302
|
console.warn(
|
|
1016
1303
|
` rebase onto ${ctx.repo.mergeBranch} conflicted \u2014 merging it in and resolving with the agent\u2026`
|
|
1017
1304
|
);
|
|
1018
|
-
await mergeAndResolveConflicts(ctx, dir, abort);
|
|
1019
|
-
await commitWithRepair(ctx, dir, abort);
|
|
1305
|
+
await mergeAndResolveConflicts(ctx, dir, config, abort);
|
|
1306
|
+
await commitWithRepair(ctx, dir, abort, { skipSocket: true });
|
|
1020
1307
|
autoMerged = true;
|
|
1021
1308
|
}
|
|
1022
1309
|
}
|
|
@@ -1024,28 +1311,36 @@ async function pushAndOpenPr(ctx, dir, abort, opts = { rebase: true }) {
|
|
|
1024
1311
|
const pr = await openPullRequest(ctx);
|
|
1025
1312
|
return { outcome: pr ? { kind: "pr", pr } : { kind: "pushed" }, autoMerged };
|
|
1026
1313
|
}
|
|
1027
|
-
async function mergeAndResolveConflicts(ctx, dir, abort) {
|
|
1314
|
+
async function mergeAndResolveConflicts(ctx, dir, config, abort) {
|
|
1028
1315
|
const { conflicted } = await mergeInMergeBranch(ctx, dir);
|
|
1029
1316
|
if (!conflicted) return { resolved: false, text: null };
|
|
1317
|
+
const conflictedPaths = await listUnmergedPaths(dir);
|
|
1318
|
+
const prNumbers = await incomingPrNumbers(ctx, dir, conflictedPaths);
|
|
1319
|
+
const related = await fetchRelatedSessions(config, {
|
|
1320
|
+
owner: ctx.repo.owner,
|
|
1321
|
+
name: ctx.repo.name,
|
|
1322
|
+
prNumbers
|
|
1323
|
+
});
|
|
1030
1324
|
const result = await runClaudeCode({
|
|
1031
1325
|
cwd: dir,
|
|
1032
|
-
prompt: buildResolvePrompt(ctx),
|
|
1326
|
+
prompt: buildResolvePrompt(ctx, related),
|
|
1033
1327
|
permissionMode: ctx.permissionMode,
|
|
1034
1328
|
model: ORCHESTRATOR_MODEL,
|
|
1035
1329
|
maxTurns: ORCHESTRATOR_MAX_TURNS,
|
|
1036
1330
|
abortController: abort
|
|
1037
1331
|
});
|
|
1038
|
-
const unresolved = await
|
|
1332
|
+
const unresolved = await listConflictMarkerPaths(dir, conflictedPaths);
|
|
1039
1333
|
if (unresolved.length > 0) {
|
|
1040
1334
|
throw new Error(
|
|
1041
|
-
`Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still conflict: ${unresolved.join(", ")}`
|
|
1335
|
+
`Could not fully resolve the merge \u2014 ${unresolved.length} file(s) still contain conflict markers: ${unresolved.join(", ")}`
|
|
1042
1336
|
);
|
|
1043
1337
|
}
|
|
1044
1338
|
return { resolved: true, text: result.text.trim() || null };
|
|
1045
1339
|
}
|
|
1046
|
-
async function commitWithRepair(ctx, dir, abort) {
|
|
1340
|
+
async function commitWithRepair(ctx, dir, abort, opts = {}) {
|
|
1047
1341
|
for (let attempt = 1; ; attempt++) {
|
|
1048
1342
|
try {
|
|
1343
|
+
if (!opts.skipSocket) await runSocket("pre-commit", dir);
|
|
1049
1344
|
return await commitChanges(ctx, dir);
|
|
1050
1345
|
} catch (err) {
|
|
1051
1346
|
if (!(err instanceof PreCommitError) || attempt > MAX_COMMIT_REPAIRS) throw err;
|
|
@@ -1084,38 +1379,38 @@ function outcomeBanner(outcome, opts) {
|
|
|
1084
1379
|
\u2139\uFE0F **No code changes were made** \u2014 ${opts.noChange ?? "there was nothing to open a pull request for."}`;
|
|
1085
1380
|
}
|
|
1086
1381
|
}
|
|
1087
|
-
async function processJob(ctx, abort = new AbortController()) {
|
|
1382
|
+
async function processJob(ctx, config, abort = new AbortController()) {
|
|
1088
1383
|
const { dir, reused } = await acquireWorkspace(ctx.workspaceKey);
|
|
1089
1384
|
let prepared = false;
|
|
1090
1385
|
try {
|
|
1091
1386
|
if (ctx.kind === "init") {
|
|
1092
1387
|
await prepareAtSha(ctx, dir, reused);
|
|
1093
1388
|
prepared = true;
|
|
1094
|
-
return await processInitJob(ctx, dir, abort);
|
|
1389
|
+
return await processInitJob(ctx, dir, config, abort);
|
|
1095
1390
|
}
|
|
1096
1391
|
if (ctx.kind === "implement") {
|
|
1097
1392
|
const { resumed } = await prepareResumingBranch(ctx, dir, reused);
|
|
1098
1393
|
prepared = true;
|
|
1099
|
-
return await processImplementJob(ctx, dir, resumed, abort);
|
|
1394
|
+
return await processImplementJob(ctx, dir, resumed, config, abort);
|
|
1100
1395
|
}
|
|
1101
1396
|
if (ctx.kind === "revise") {
|
|
1102
1397
|
const { resumed } = await prepareResumingBranch(ctx, dir, reused);
|
|
1103
1398
|
prepared = true;
|
|
1104
|
-
return await processReviseJob(ctx, dir, resumed, abort);
|
|
1399
|
+
return await processReviseJob(ctx, dir, resumed, config, abort);
|
|
1105
1400
|
}
|
|
1106
1401
|
if (ctx.kind === "resolve") {
|
|
1107
1402
|
await prepareResumingBranch(ctx, dir, reused);
|
|
1108
1403
|
prepared = true;
|
|
1109
|
-
return await processResolveJob(ctx, dir, abort);
|
|
1404
|
+
return await processResolveJob(ctx, dir, config, abort);
|
|
1110
1405
|
}
|
|
1111
1406
|
if (ctx.kind === "release") {
|
|
1112
1407
|
const { resumed } = await prepareResumingBranch(ctx, dir, reused);
|
|
1113
1408
|
prepared = true;
|
|
1114
|
-
return await processReleaseJob(ctx, dir, resumed, abort);
|
|
1409
|
+
return await processReleaseJob(ctx, dir, resumed, config, abort);
|
|
1115
1410
|
}
|
|
1116
1411
|
await prepareAtSha(ctx, dir, reused);
|
|
1117
1412
|
prepared = true;
|
|
1118
|
-
return await processChatJob(ctx, dir, abort);
|
|
1413
|
+
return await processChatJob(ctx, dir, config, abort);
|
|
1119
1414
|
} catch (err) {
|
|
1120
1415
|
if (abort.signal.aborted && prepared) {
|
|
1121
1416
|
await resetWorkspace(dir);
|
|
@@ -1125,7 +1420,7 @@ async function processJob(ctx, abort = new AbortController()) {
|
|
|
1125
1420
|
throw err;
|
|
1126
1421
|
}
|
|
1127
1422
|
}
|
|
1128
|
-
async function processInitJob(ctx, dir, abort) {
|
|
1423
|
+
async function processInitJob(ctx, dir, config, abort) {
|
|
1129
1424
|
console.log(`
|
|
1130
1425
|
\u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
|
|
1131
1426
|
const summary = (await runClaudeCode({
|
|
@@ -1136,7 +1431,7 @@ async function processInitJob(ctx, dir, abort) {
|
|
|
1136
1431
|
abortController: abort
|
|
1137
1432
|
})).text.trim();
|
|
1138
1433
|
let reply = summary || "(the agent produced no summary)";
|
|
1139
|
-
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort);
|
|
1434
|
+
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort);
|
|
1140
1435
|
reply += outcomeBanner(outcome, {
|
|
1141
1436
|
branch: ctx.repo.checkoutBranch,
|
|
1142
1437
|
noChange: "no files were generated; the wiki may already exist.",
|
|
@@ -1144,7 +1439,7 @@ async function processInitJob(ctx, dir, abort) {
|
|
|
1144
1439
|
});
|
|
1145
1440
|
return { text: reply, widgets: [] };
|
|
1146
1441
|
}
|
|
1147
|
-
async function processChatJob(ctx, dir, abort) {
|
|
1442
|
+
async function processChatJob(ctx, dir, config, abort) {
|
|
1148
1443
|
console.log(`
|
|
1149
1444
|
\u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
1150
1445
|
const orchestrating = ctx.permissionMode !== "plan";
|
|
@@ -1186,14 +1481,14 @@ async function processChatJob(ctx, dir, abort) {
|
|
|
1186
1481
|
console.warn(` wiki update skipped: ${errorMessage2(err)}`);
|
|
1187
1482
|
}
|
|
1188
1483
|
}
|
|
1189
|
-
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort);
|
|
1484
|
+
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort);
|
|
1190
1485
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1191
1486
|
return { text: reply, widgets: [] };
|
|
1192
1487
|
}
|
|
1193
1488
|
function reportClaimsWork(report) {
|
|
1194
1489
|
return !!report && report.acceptanceCriteria.some((ac) => ac.status === "met" && ac.evidence.length > 0);
|
|
1195
1490
|
}
|
|
1196
|
-
async function processImplementJob(ctx, dir, resumed, abort) {
|
|
1491
|
+
async function processImplementJob(ctx, dir, resumed, config, abort) {
|
|
1197
1492
|
console.log(`
|
|
1198
1493
|
\u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
1199
1494
|
const installResult = await installDependencies(dir);
|
|
@@ -1241,7 +1536,9 @@ async function processImplementJob(ctx, dir, resumed, abort) {
|
|
|
1241
1536
|
console.warn(` wiki update skipped: ${errorMessage2(err)}`);
|
|
1242
1537
|
}
|
|
1243
1538
|
}
|
|
1244
|
-
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, {
|
|
1539
|
+
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
|
|
1540
|
+
rebase: !resumed
|
|
1541
|
+
});
|
|
1245
1542
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1246
1543
|
return {
|
|
1247
1544
|
text: reply,
|
|
@@ -1250,7 +1547,7 @@ async function processImplementJob(ctx, dir, resumed, abort) {
|
|
|
1250
1547
|
...outcome.kind === "pr" ? { pr: outcome.pr } : {}
|
|
1251
1548
|
};
|
|
1252
1549
|
}
|
|
1253
|
-
async function processReviseJob(ctx, dir, resumed, abort) {
|
|
1550
|
+
async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
1254
1551
|
console.log(`
|
|
1255
1552
|
\u25B6 Revise ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
1256
1553
|
const installResult = await installDependencies(dir);
|
|
@@ -1290,7 +1587,9 @@ async function processReviseJob(ctx, dir, resumed, abort) {
|
|
|
1290
1587
|
console.warn(` wiki update skipped: ${errorMessage2(err)}`);
|
|
1291
1588
|
}
|
|
1292
1589
|
}
|
|
1293
|
-
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, {
|
|
1590
|
+
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
|
|
1591
|
+
rebase: !resumed
|
|
1592
|
+
});
|
|
1294
1593
|
if (outcome.kind !== "none") {
|
|
1295
1594
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1296
1595
|
}
|
|
@@ -1301,11 +1600,11 @@ async function processReviseJob(ctx, dir, resumed, abort) {
|
|
|
1301
1600
|
...result.plans?.length ? { plans: result.plans } : {}
|
|
1302
1601
|
};
|
|
1303
1602
|
}
|
|
1304
|
-
async function processResolveJob(ctx, dir, abort) {
|
|
1603
|
+
async function processResolveJob(ctx, dir, config, abort) {
|
|
1305
1604
|
console.log(`
|
|
1306
1605
|
\u25B6 Resolve ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
1307
1606
|
const installResult = await installDependencies(dir);
|
|
1308
|
-
const { resolved, text } = await mergeAndResolveConflicts(ctx, dir, abort);
|
|
1607
|
+
const { resolved, text } = await mergeAndResolveConflicts(ctx, dir, config, abort);
|
|
1309
1608
|
let reply = resolved ? text || "(the agent produced no report)" : `Merged \`${ctx.repo.mergeBranch ?? "the merge branch"}\` into \`${ctx.repo.checkoutBranch}\` cleanly \u2014 there were no conflicts to resolve.`;
|
|
1310
1609
|
if (installResult.status === "failed") {
|
|
1311
1610
|
reply += `
|
|
@@ -1313,14 +1612,14 @@ async function processResolveJob(ctx, dir, abort) {
|
|
|
1313
1612
|
> \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
|
|
1314
1613
|
}
|
|
1315
1614
|
if (abort.signal.aborted) throw new Error("Run canceled by user");
|
|
1316
|
-
await commitWithRepair(ctx, dir, abort);
|
|
1615
|
+
await commitWithRepair(ctx, dir, abort, { skipSocket: true });
|
|
1317
1616
|
await pushBranch(ctx, dir);
|
|
1318
1617
|
const pr = await openPullRequest(ctx);
|
|
1319
1618
|
const outcome = pr ? { kind: "pr", pr } : { kind: "pushed" };
|
|
1320
1619
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch });
|
|
1321
1620
|
return { text: reply, widgets: [], ...pr ? { pr } : {} };
|
|
1322
1621
|
}
|
|
1323
|
-
async function processReleaseJob(ctx, dir, resumed, abort) {
|
|
1622
|
+
async function processReleaseJob(ctx, dir, resumed, config, abort) {
|
|
1324
1623
|
console.log(`
|
|
1325
1624
|
\u25B6 Release ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
1326
1625
|
const installResult = await installDependencies(dir);
|
|
@@ -1351,7 +1650,9 @@ async function processReleaseJob(ctx, dir, resumed, abort) {
|
|
|
1351
1650
|
);
|
|
1352
1651
|
return { text: reply, widgets: result.widgets };
|
|
1353
1652
|
}
|
|
1354
|
-
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, abort, {
|
|
1653
|
+
const { outcome, autoMerged } = await pushAndOpenPr(ctx, dir, config, abort, {
|
|
1654
|
+
rebase: !resumed
|
|
1655
|
+
});
|
|
1355
1656
|
if (outcome.kind !== "none") {
|
|
1356
1657
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, autoMerged });
|
|
1357
1658
|
}
|
|
@@ -1401,6 +1702,11 @@ async function pollLoop(config) {
|
|
|
1401
1702
|
await sleep(IDLE_MS);
|
|
1402
1703
|
continue;
|
|
1403
1704
|
}
|
|
1705
|
+
startJobLog({
|
|
1706
|
+
jobId: ctx.jobId,
|
|
1707
|
+
kind: ctx.kind,
|
|
1708
|
+
secrets: [ctx.repo?.cloneToken ?? ""].filter(Boolean)
|
|
1709
|
+
});
|
|
1404
1710
|
const abort = new AbortController();
|
|
1405
1711
|
let stopPolling = false;
|
|
1406
1712
|
const scheduleCancelPoll = () => {
|
|
@@ -1419,14 +1725,17 @@ async function pollLoop(config) {
|
|
|
1419
1725
|
};
|
|
1420
1726
|
scheduleCancelPoll();
|
|
1421
1727
|
try {
|
|
1422
|
-
|
|
1728
|
+
resetUsage();
|
|
1729
|
+
const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
|
|
1730
|
+
const usage = getUsage();
|
|
1423
1731
|
await reportJob(config, ctx.jobId, {
|
|
1424
1732
|
status: "done",
|
|
1425
1733
|
text,
|
|
1426
1734
|
widgets,
|
|
1427
1735
|
pr,
|
|
1428
1736
|
...plans?.length ? { plans } : {},
|
|
1429
|
-
...report ? { report } : {}
|
|
1737
|
+
...report ? { report } : {},
|
|
1738
|
+
...usage.totalTokens > 0 ? { usage } : {}
|
|
1430
1739
|
});
|
|
1431
1740
|
console.log(`\u2713 Job ${ctx.jobId} done`);
|
|
1432
1741
|
} catch (err) {
|
|
@@ -1450,6 +1759,13 @@ async function pollLoop(config) {
|
|
|
1450
1759
|
}
|
|
1451
1760
|
} finally {
|
|
1452
1761
|
stopPolling = true;
|
|
1762
|
+
if (!abort.signal.aborted) {
|
|
1763
|
+
try {
|
|
1764
|
+
await uploadJobLog(config, ctx.jobId, getJobLog());
|
|
1765
|
+
} catch (e) {
|
|
1766
|
+
console.error(` (failed to upload logs: ${errorMessage2(e)})`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1453
1769
|
}
|
|
1454
1770
|
}
|
|
1455
1771
|
} finally {
|
|
@@ -1467,9 +1783,9 @@ var MAX_HOOK_LOG_CHARS = 4e3;
|
|
|
1467
1783
|
function trimHookLog(log) {
|
|
1468
1784
|
let trimmed = log.trimEnd();
|
|
1469
1785
|
let elided = false;
|
|
1470
|
-
const
|
|
1471
|
-
if (
|
|
1472
|
-
trimmed =
|
|
1786
|
+
const lines2 = trimmed.split("\n");
|
|
1787
|
+
if (lines2.length > MAX_HOOK_LOG_LINES) {
|
|
1788
|
+
trimmed = lines2.slice(-MAX_HOOK_LOG_LINES).join("\n");
|
|
1473
1789
|
elided = true;
|
|
1474
1790
|
}
|
|
1475
1791
|
if (trimmed.length > MAX_HOOK_LOG_CHARS) {
|