@syntax-syllogism/aloop 0.5.3 → 0.6.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/CHANGELOG.md +43 -0
- package/README.md +49 -15
- package/bin/loop.mjs +197 -7
- package/package.json +7 -1
- package/presets/work-item/loop.config.mjs +1 -1
- package/presets/work-item/prompts/docs.md +6 -4
- package/presets/work-item/prompts/pr-description.md +27 -0
- package/presets/work-item/prompts/review.md +6 -1
- package/prompts/address.md +2 -1
- package/prompts/docs.md +5 -3
- package/prompts/{git.md → pr-description.md} +11 -8
- package/prompts/review.md +9 -4
- package/src/adapters.mjs +84 -9
- package/src/backends/gitlab.mjs +231 -0
- package/src/command.mjs +156 -6
- package/src/config.mjs +278 -53
- package/src/git.mjs +26 -0
- package/src/hermetic.mjs +160 -0
- package/src/index.mjs +22 -1
- package/src/manifest.mjs +67 -0
- package/src/metrics.mjs +251 -0
- package/src/operations.mjs +651 -0
- package/src/pipeline.mjs +505 -427
- package/src/policy.mjs +63 -0
- package/src/publish.mjs +183 -0
- package/src/reporter.mjs +102 -0
- package/src/runner.mjs +509 -0
- package/src/state.mjs +338 -16
- package/src/verdict.mjs +36 -2
- package/src/worktree.mjs +33 -0
- package/presets/work-item/prompts/git.md +0 -44
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import { access, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { adapterFor, agentForPhase } from './adapters.mjs';
|
|
6
|
+
import { runCommand, signalProcessGroup } from './command.mjs';
|
|
7
|
+
import { defaults, loadConfig, loadRunsDir } from './config.mjs';
|
|
8
|
+
import { GitFacade } from './git.mjs';
|
|
9
|
+
import { computeAggregateMetrics, computeRunMetrics, readRunManifests } from './metrics.mjs';
|
|
10
|
+
import { RunState, slugFor } from './state.mjs';
|
|
11
|
+
|
|
12
|
+
const PULL_REQUEST_URL = /https?:\/\/[^\s"'`<>]+(?:\/pull\/\d+|\/merge_requests\/\d+)[^\s"'`<>]*/i;
|
|
13
|
+
|
|
14
|
+
async function readJson(path, { optional = true } = {}) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT') {
|
|
19
|
+
if (optional) return null;
|
|
20
|
+
throw new Error(`Unable to read ${path}: ${error.message}`, { cause: error });
|
|
21
|
+
}
|
|
22
|
+
if (error instanceof SyntaxError) throw new Error(`Invalid JSON in ${path}.`);
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Metrics-oriented read API (runsDir based) -----------------------------
|
|
28
|
+
|
|
29
|
+
async function isDirectory(path) {
|
|
30
|
+
try {
|
|
31
|
+
return (await stat(path)).isDirectory();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function isLocked(path) {
|
|
38
|
+
try {
|
|
39
|
+
await access(join(path, 'lock'), constants.F_OK);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function statusFor(state, manifest, locked) {
|
|
47
|
+
if (locked) return 'active';
|
|
48
|
+
if (state?.status) return state.status;
|
|
49
|
+
const last = manifest?.phases?.at(-1);
|
|
50
|
+
if (last?.status === 'stalled') return 'stalled';
|
|
51
|
+
if (last?.status === 'completed' && state?.completed?.length) return 'completed';
|
|
52
|
+
return 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function currentPhase(manifest) {
|
|
56
|
+
return manifest?.phases?.at(-1)?.phase ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function runInfo(runsDir, name) {
|
|
60
|
+
const dir = join(runsDir, name);
|
|
61
|
+
if (!(await isDirectory(dir))) throw new Error(`Run "${name}" was not found in ${runsDir}.`);
|
|
62
|
+
const [state, manifest, locked] = await Promise.all([
|
|
63
|
+
readJson(join(dir, 'state.json')),
|
|
64
|
+
readJson(join(dir, 'manifest.json')),
|
|
65
|
+
isLocked(dir),
|
|
66
|
+
]);
|
|
67
|
+
if (!manifest) throw new Error(`Run "${name}" has no manifest.json.`);
|
|
68
|
+
const metrics = computeRunMetrics(manifest);
|
|
69
|
+
return {
|
|
70
|
+
name,
|
|
71
|
+
runId: state?.runId ?? manifest.runId ?? null,
|
|
72
|
+
status: statusFor(state, manifest, locked),
|
|
73
|
+
phase: currentPhase(manifest),
|
|
74
|
+
branch: state?.branch ?? manifest.branch ?? null,
|
|
75
|
+
worktree: state?.worktree ?? manifest.worktree ?? null,
|
|
76
|
+
startedAt: state?.startedAt ?? manifest.startedAt ?? null,
|
|
77
|
+
updatedAt: state?.updatedAt ?? null,
|
|
78
|
+
taskFile: state?.taskFile ?? manifest.taskFile ?? null,
|
|
79
|
+
prUrl: state?.prUrl ?? manifest.prUrl ?? null,
|
|
80
|
+
manifest,
|
|
81
|
+
metrics,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function resolveRunsDir(cwd, configPath) {
|
|
86
|
+
const repoRoot = await new GitFacade(cwd).toplevel();
|
|
87
|
+
const runsDir = await loadRunsDir(repoRoot, configPath ? resolve(cwd, configPath) : undefined);
|
|
88
|
+
return { repoRoot, runsDir: join(repoRoot, runsDir) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function getRunStatus(runsDir, name) {
|
|
92
|
+
const info = await runInfo(runsDir, name);
|
|
93
|
+
return {
|
|
94
|
+
name: info.name,
|
|
95
|
+
runId: info.runId,
|
|
96
|
+
status: info.status,
|
|
97
|
+
phase: info.phase,
|
|
98
|
+
branch: info.branch,
|
|
99
|
+
startedAt: info.startedAt,
|
|
100
|
+
updatedAt: info.updatedAt,
|
|
101
|
+
prUrl: info.prUrl,
|
|
102
|
+
metrics: info.metrics,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function inspectRunMetrics(info) {
|
|
107
|
+
return {
|
|
108
|
+
...getRunStatusFields(info),
|
|
109
|
+
phases: info.manifest.phases ?? [],
|
|
110
|
+
metrics: info.metrics,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getRunStatusFields(info) {
|
|
115
|
+
return {
|
|
116
|
+
name: info.name,
|
|
117
|
+
runId: info.runId,
|
|
118
|
+
status: info.status,
|
|
119
|
+
phase: info.phase,
|
|
120
|
+
branch: info.branch,
|
|
121
|
+
worktree: info.worktree,
|
|
122
|
+
startedAt: info.startedAt,
|
|
123
|
+
updatedAt: info.updatedAt,
|
|
124
|
+
taskFile: info.taskFile,
|
|
125
|
+
prUrl: info.prUrl,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function listRunStatuses(runsDir, { includeMetrics = false } = {}) {
|
|
130
|
+
const manifests = await readRunManifests(runsDir);
|
|
131
|
+
const statuses = [];
|
|
132
|
+
for (const { name } of manifests) {
|
|
133
|
+
const info = await runInfo(runsDir, name);
|
|
134
|
+
statuses.push({
|
|
135
|
+
...getRunStatusFields(info),
|
|
136
|
+
...(includeMetrics ? { metrics: info.metrics } : {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return statuses;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function getAggregateMetrics(runsDir) {
|
|
143
|
+
const manifests = await readRunManifests(runsDir);
|
|
144
|
+
return computeAggregateMetrics(manifests.map(({ manifest }) => manifest));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatValue(value) {
|
|
148
|
+
return value === null || value === undefined ? '?' : String(value);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function printJson(value) {
|
|
152
|
+
console.log(JSON.stringify(value, null, 2));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function printRunTable(runs, includeMetrics) {
|
|
156
|
+
if (!runs.length) {
|
|
157
|
+
console.log('No runs found.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
console.log('NAME STATUS PHASE BRANCH');
|
|
161
|
+
for (const run of runs) {
|
|
162
|
+
const metrics = includeMetrics
|
|
163
|
+
? ` duration=${run.metrics.total.durationMs}ms tokens=${formatValue(run.metrics.total.tokens)} cost=${formatValue(run.metrics.total.cost)}`
|
|
164
|
+
: '';
|
|
165
|
+
console.log(`${run.name} ${run.status} ${run.phase ?? '-'} ${run.branch ?? '-'}${metrics}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function printStatus(status) {
|
|
170
|
+
console.log(`${status.name}: ${status.status}`);
|
|
171
|
+
console.log(`phase: ${status.phase ?? '-'}`);
|
|
172
|
+
console.log(`branch: ${status.branch ?? '-'}`);
|
|
173
|
+
console.log(`duration: ${status.metrics.total.durationMs}ms`);
|
|
174
|
+
console.log(`tokens: ${formatValue(status.metrics.total.tokens)}`);
|
|
175
|
+
console.log(`cost: ${formatValue(status.metrics.total.cost)}`);
|
|
176
|
+
console.log(`reviewer catch rate: ${formatValue(status.metrics.reviewer.catchRate)}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function printAggregate(metrics) {
|
|
180
|
+
console.log(`${metrics.runs} run${metrics.runs === 1 ? '' : 's'}`);
|
|
181
|
+
console.log(`duration: ${metrics.total.durationMs}ms`);
|
|
182
|
+
console.log(`tokens: ${formatValue(metrics.total.tokens)}`);
|
|
183
|
+
console.log(`cost: ${formatValue(metrics.total.cost)}`);
|
|
184
|
+
console.log(`convergence: ${formatValue(metrics.convergence.convergenceRate)}`);
|
|
185
|
+
console.log(`reviewer catch rate: ${formatValue(metrics.reviewer.catchRate)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function printInspection(result) {
|
|
189
|
+
console.log(`${result.name}: ${result.status}`);
|
|
190
|
+
for (const phase of result.phases) {
|
|
191
|
+
console.log(` ${phase.phase} ${phase.status} ${phase.durationMs ?? '?'}ms`);
|
|
192
|
+
}
|
|
193
|
+
printAggregate({
|
|
194
|
+
runs: 1,
|
|
195
|
+
total: result.metrics.total,
|
|
196
|
+
convergence: result.metrics.convergence.roundsToConverge === null
|
|
197
|
+
? { convergenceRate: 0 }
|
|
198
|
+
: { convergenceRate: 1 },
|
|
199
|
+
reviewer: result.metrics.reviewer,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Metrics-focused command dispatch used by the aggregate `metrics` reporting
|
|
204
|
+
// and retained for programmatic callers. The operator CLI (bin/loop.mjs) owns
|
|
205
|
+
// the run/list/status/inspect/cancel/clean/doctor surface.
|
|
206
|
+
export async function runOperationalCommand(args) {
|
|
207
|
+
const { runsDir } = await resolveRunsDir(args.cwd ?? process.cwd(), args.config);
|
|
208
|
+
if (args.command === 'status') {
|
|
209
|
+
if (!args.name) throw new Error('status requires a run name.');
|
|
210
|
+
const result = await getRunStatus(runsDir, args.name);
|
|
211
|
+
if (args.json) printJson(result); else printStatus(result);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
if (args.command === 'inspect') {
|
|
215
|
+
if (!args.name) throw new Error('inspect requires a run name.');
|
|
216
|
+
const result = inspectRunMetrics(await runInfo(runsDir, args.name));
|
|
217
|
+
if (args.json) printJson(result); else printInspection(result);
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
if (args.command === 'list') {
|
|
221
|
+
const result = await listRunStatuses(runsDir, { includeMetrics: args.metrics });
|
|
222
|
+
if (args.json) printJson(result); else printRunTable(result, args.metrics);
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
if (args.command === 'metrics') {
|
|
226
|
+
const result = await getAggregateMetrics(runsDir);
|
|
227
|
+
if (args.json) printJson(result); else printAggregate(result);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`Unknown command "${args.command}".`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Operational command API (cwd/config based, lock aware) ----------------
|
|
234
|
+
|
|
235
|
+
function isProcessAlive(pid) {
|
|
236
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
237
|
+
try {
|
|
238
|
+
process.kill(pid, 0);
|
|
239
|
+
return true;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return error.code === 'EPERM';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isProcessGroupAlive(groupId) {
|
|
246
|
+
if (process.platform === 'win32' || !Number.isInteger(groupId) || groupId <= 0 || groupId === process.pid) return false;
|
|
247
|
+
try {
|
|
248
|
+
process.kill(-groupId, 0);
|
|
249
|
+
return true;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return error.code === 'EPERM';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isActiveProcessAlive({ pid, processGroupId }) {
|
|
256
|
+
return isProcessGroupAlive(processGroupId) || isProcessAlive(pid);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function readLock(runDir) {
|
|
260
|
+
const lockDir = join(runDir, 'lock');
|
|
261
|
+
const activeProcessRecord = await readJson(join(runDir, 'active-command.json'), { optional: true });
|
|
262
|
+
const activeProcessPid = activeProcessRecord?.pid ?? null;
|
|
263
|
+
const activeProcessGroupId = activeProcessRecord?.processGroupId ?? null;
|
|
264
|
+
const activeProcess = { pid: activeProcessPid, processGroupId: activeProcessGroupId };
|
|
265
|
+
let entries;
|
|
266
|
+
try {
|
|
267
|
+
entries = await readdir(lockDir);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error.code === 'ENOENT') {
|
|
270
|
+
return {
|
|
271
|
+
present: false,
|
|
272
|
+
active: isActiveProcessAlive(activeProcess),
|
|
273
|
+
owner: null,
|
|
274
|
+
activeProcessPid,
|
|
275
|
+
activeProcessGroupId,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
const ownerName = entries.find((entry) => /^owner-\d+-[0-9a-f-]+$/.test(entry));
|
|
281
|
+
if (!ownerName) {
|
|
282
|
+
return {
|
|
283
|
+
present: true,
|
|
284
|
+
active: isActiveProcessAlive(activeProcess),
|
|
285
|
+
owner: null,
|
|
286
|
+
activeProcessPid,
|
|
287
|
+
activeProcessGroupId,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const owner = await readJson(join(lockDir, ownerName));
|
|
291
|
+
return {
|
|
292
|
+
present: true,
|
|
293
|
+
active: isProcessAlive(owner.pid) || isActiveProcessAlive(activeProcess),
|
|
294
|
+
owner: { ...owner, path: join(lockDir, ownerName) },
|
|
295
|
+
activeProcessPid,
|
|
296
|
+
activeProcessGroupId,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function latestEntry(entries, predicate = () => true) {
|
|
301
|
+
return [...entries].reverse().find(predicate) ?? null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function prUrl(value) {
|
|
305
|
+
return typeof value === 'string' ? value.match(PULL_REQUEST_URL)?.[0] ?? null : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function canonicalPrUrl(state, manifest) {
|
|
309
|
+
const phaseUrls = (manifest.phases ?? []).flatMap((phase) => [
|
|
310
|
+
phase.prUrl,
|
|
311
|
+
phase.pullRequest?.url,
|
|
312
|
+
]);
|
|
313
|
+
return [
|
|
314
|
+
state.prUrl,
|
|
315
|
+
state.pullRequest?.url,
|
|
316
|
+
manifest.prUrl,
|
|
317
|
+
manifest.pullRequest?.url,
|
|
318
|
+
...phaseUrls,
|
|
319
|
+
].map(prUrl).find(Boolean) ?? null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function deriveStatus(data, manifest, lock) {
|
|
323
|
+
if (lock.active) return 'running';
|
|
324
|
+
if (data.status) return data.status;
|
|
325
|
+
if (data.cancelledAt) return 'cancelled';
|
|
326
|
+
const stalled = latestEntry(manifest.phases, (entry) => entry.status === 'stalled');
|
|
327
|
+
if (stalled) return 'stalled';
|
|
328
|
+
if (data.completed?.length) return 'completed';
|
|
329
|
+
return 'unknown';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function deriveCurrentPhase(data, phases) {
|
|
333
|
+
if (data.currentPhase) return data.currentPhase;
|
|
334
|
+
return latestEntry(phases, (entry) => entry.status !== 'skipped')?.phase ?? null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function deriveVerdict(data, phases) {
|
|
338
|
+
const entry = latestEntry(phases, (candidate) => candidate.verdict);
|
|
339
|
+
return entry?.verdict?.verdict ?? entry?.verdict ?? data.verdict ?? null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function deriveGateStatus(phases) {
|
|
343
|
+
return latestEntry(phases, (entry) => entry.role === 'gate' || entry.kind === 'gate')?.status ?? null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function summarizeRun(slug, runDir, state, manifest, lock) {
|
|
347
|
+
const phases = manifest.phases ?? [];
|
|
348
|
+
return {
|
|
349
|
+
name: state.name ?? manifest.name ?? slug,
|
|
350
|
+
slug,
|
|
351
|
+
runId: state.runId ?? null,
|
|
352
|
+
status: deriveStatus(state, { phases }, lock),
|
|
353
|
+
branch: state.branch ?? manifest.branch ?? null,
|
|
354
|
+
baseBranch: state.baseBranch ?? manifest.baseBranch ?? null,
|
|
355
|
+
worktree: state.worktree ?? manifest.worktree ?? null,
|
|
356
|
+
repoRoot: state.repoRoot ?? manifest.repoRoot ?? null,
|
|
357
|
+
startedAt: state.startedAt ?? manifest.startedAt ?? null,
|
|
358
|
+
updatedAt: state.updatedAt ?? manifest.updatedAt ?? null,
|
|
359
|
+
currentPhase: deriveCurrentPhase(state, phases),
|
|
360
|
+
verdict: deriveVerdict(state, phases),
|
|
361
|
+
gateStatus: deriveGateStatus(phases),
|
|
362
|
+
prUrl: canonicalPrUrl(state, manifest),
|
|
363
|
+
runDir,
|
|
364
|
+
lock: {
|
|
365
|
+
present: lock.present,
|
|
366
|
+
active: lock.active,
|
|
367
|
+
pid: lock.owner?.pid ?? null,
|
|
368
|
+
activeProcessPid: lock.activeProcessPid,
|
|
369
|
+
activeProcessGroupId: lock.activeProcessGroupId,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function readRun(runsDir, slug) {
|
|
375
|
+
const runDir = join(runsDir, slug);
|
|
376
|
+
const state = await readJson(join(runDir, 'state.json'), { optional: true });
|
|
377
|
+
if (!state) return null;
|
|
378
|
+
const manifest = await readJson(join(runDir, 'manifest.json'), { optional: true }) ?? { phases: [] };
|
|
379
|
+
const lock = await readLock(runDir);
|
|
380
|
+
return {
|
|
381
|
+
summary: summarizeRun(slug, runDir, state, manifest, lock),
|
|
382
|
+
state,
|
|
383
|
+
manifest,
|
|
384
|
+
lock,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function pathExists(path) {
|
|
389
|
+
try {
|
|
390
|
+
await access(path, constants.F_OK);
|
|
391
|
+
return true;
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function loadRunsConfig(repoRoot, configPath) {
|
|
398
|
+
const path = configPath ? resolve(configPath) : join(repoRoot, 'loop.config.mjs');
|
|
399
|
+
// Distinguish an absent entry config from a config that exists but fails to
|
|
400
|
+
// import (e.g. a missing local module/package). Only the former may fall back
|
|
401
|
+
// to defaults; a broken config must surface so operational commands do not
|
|
402
|
+
// silently target the wrong runsDir. This mirrors `loadConfig` in config.mjs.
|
|
403
|
+
if (!(await pathExists(path))) {
|
|
404
|
+
if (configPath) throw new Error(`Loop config file does not exist: ${path}`);
|
|
405
|
+
return { ...defaults };
|
|
406
|
+
}
|
|
407
|
+
const configured = (await import(`${pathToFileURL(path).href}?t=${Date.now()}`)).default ?? {};
|
|
408
|
+
return { ...defaults, ...configured };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function operationalContext(cwd, configPath, { validate = false } = {}) {
|
|
412
|
+
const rootGit = new GitFacade(cwd);
|
|
413
|
+
const repoRoot = await rootGit.toplevel();
|
|
414
|
+
const config = validate
|
|
415
|
+
? await loadConfig(repoRoot, {}, configPath ? resolve(cwd, configPath) : undefined)
|
|
416
|
+
: await loadRunsConfig(repoRoot, configPath ? resolve(cwd, configPath) : undefined);
|
|
417
|
+
return { config, repoRoot, rootGit, runsDir: resolve(repoRoot, config.runsDir) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function listRuns({ cwd = process.cwd(), configPath } = {}) {
|
|
421
|
+
const context = await operationalContext(cwd, configPath);
|
|
422
|
+
let entries;
|
|
423
|
+
try {
|
|
424
|
+
entries = await readdir(context.runsDir, { withFileTypes: true });
|
|
425
|
+
} catch (error) {
|
|
426
|
+
if (error.code === 'ENOENT') return [];
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
const runs = [];
|
|
430
|
+
for (const entry of entries.filter((candidate) => candidate.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
431
|
+
const run = await readRun(context.runsDir, entry.name);
|
|
432
|
+
if (run) runs.push(run.summary);
|
|
433
|
+
}
|
|
434
|
+
return runs.sort((a, b) => (b.updatedAt ?? b.startedAt ?? '').localeCompare(a.updatedAt ?? a.startedAt ?? ''));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function getRun(name, { cwd = process.cwd(), configPath } = {}) {
|
|
438
|
+
const slug = slugFor(name);
|
|
439
|
+
if (!slug) throw new Error('A run name is required.');
|
|
440
|
+
const context = await operationalContext(cwd, configPath);
|
|
441
|
+
const run = await readRun(context.runsDir, slug);
|
|
442
|
+
if (!run) throw new Error(`Run "${name}" was not found in ${context.runsDir}.`);
|
|
443
|
+
return run;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function inspectRun(name, options = {}) {
|
|
447
|
+
const run = await getRun(name, options);
|
|
448
|
+
return {
|
|
449
|
+
...run.summary,
|
|
450
|
+
state: run.state,
|
|
451
|
+
manifest: run.manifest,
|
|
452
|
+
phases: run.manifest.phases ?? [],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function cancelRun(name, { cwd = process.cwd(), configPath } = {}) {
|
|
457
|
+
const context = await operationalContext(cwd, configPath);
|
|
458
|
+
const slug = slugFor(name);
|
|
459
|
+
if (!slug) throw new Error('A run name is required.');
|
|
460
|
+
const run = await readRun(context.runsDir, slug);
|
|
461
|
+
if (!run) throw new Error(`Run "${name}" was not found in ${context.runsDir}.`);
|
|
462
|
+
if (!run.lock.active || run.state.status === 'completed') {
|
|
463
|
+
throw new Error(`Run "${name}" is not active and cannot be cancelled.`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const cancelledAt = new Date().toISOString();
|
|
467
|
+
let killedPid = null;
|
|
468
|
+
let activeProcess = {
|
|
469
|
+
pid: run.lock.activeProcessPid,
|
|
470
|
+
processGroupId: run.lock.activeProcessGroupId,
|
|
471
|
+
};
|
|
472
|
+
if (!isActiveProcessAlive(activeProcess) && run.lock.owner?.pid && run.lock.owner.pid !== process.pid) {
|
|
473
|
+
activeProcess = await discoverActiveProcess(run.summary.runDir, run.lock.owner.pid);
|
|
474
|
+
}
|
|
475
|
+
if (activeProcess && !await stopProcesses([activeProcess])) {
|
|
476
|
+
throw new Error(`Cancellation timed out for run "${name}"; its lock was retained.`);
|
|
477
|
+
}
|
|
478
|
+
const runnerTargets = [];
|
|
479
|
+
if (run.lock.owner?.pid && run.lock.owner.pid !== process.pid && run.lock.active) {
|
|
480
|
+
killedPid = run.lock.owner.pid;
|
|
481
|
+
runnerTargets.push({ pid: run.lock.owner.pid });
|
|
482
|
+
}
|
|
483
|
+
if (runnerTargets.length && !await stopProcesses(runnerTargets)) {
|
|
484
|
+
throw new Error(`Cancellation timed out for run "${name}"; its lock was retained.`);
|
|
485
|
+
}
|
|
486
|
+
if (!await stopActiveProcessesAfterRunner(run.summary.runDir, runnerTargets[0]?.pid ?? null)) {
|
|
487
|
+
throw new Error(`Cancellation timed out for run "${name}"; its lock was retained.`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// No runner can write state after this point, so this record cannot be
|
|
491
|
+
// overwritten by a final command/phase save from the cancelled run.
|
|
492
|
+
const state = await RunState.open(context.runsDir, slug, {}, { resume: true, lock: false });
|
|
493
|
+
await state.record({ status: 'cancelled', cancelledAt, cancelReason: 'cancelled by operator' });
|
|
494
|
+
|
|
495
|
+
if (run.lock.owner) {
|
|
496
|
+
await RunState.releaseLock(run.summary.runDir, run.lock.owner);
|
|
497
|
+
} else if (run.lock.present) {
|
|
498
|
+
await rm(join(run.summary.runDir, 'lock'), { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const refreshed = await readRun(context.runsDir, slug);
|
|
502
|
+
return { ...refreshed.summary, killedPid, status: 'cancelled' };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function ageTimestamp(run) {
|
|
506
|
+
return Date.parse(run.updatedAt ?? run.startedAt ?? '') || 0;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function signalProcess(pid, signal) {
|
|
510
|
+
if (!isProcessAlive(pid) || pid === process.pid) return false;
|
|
511
|
+
try {
|
|
512
|
+
process.kill(pid, signal);
|
|
513
|
+
return true;
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error.code !== 'ESRCH') throw error;
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function waitForProcessesToStop(targets, timeoutMs) {
|
|
521
|
+
const watched = targets.filter(({ pid, processGroupId }) =>
|
|
522
|
+
(Number.isInteger(processGroupId) && processGroupId > 0 && processGroupId !== process.pid)
|
|
523
|
+
|| (Number.isInteger(pid) && pid > 0 && pid !== process.pid));
|
|
524
|
+
const deadline = Date.now() + timeoutMs;
|
|
525
|
+
while (watched.some(isActiveProcessAlive) && Date.now() < deadline) {
|
|
526
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
|
527
|
+
}
|
|
528
|
+
return !watched.some(isActiveProcessAlive);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function discoverActiveProcess(runDir, runnerPid) {
|
|
532
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
533
|
+
const activeProcess = await readJson(join(runDir, 'active-command.json'), { optional: true });
|
|
534
|
+
if (activeProcess && isActiveProcessAlive(activeProcess)) return activeProcess;
|
|
535
|
+
if (!isProcessAlive(runnerPid)) return null;
|
|
536
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function stopActiveProcessesAfterRunner(runDir, runnerPid) {
|
|
542
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
543
|
+
const activeProcess = await readJson(join(runDir, 'active-command.json'), { optional: true });
|
|
544
|
+
if (activeProcess && isActiveProcessAlive(activeProcess) && !await stopProcesses([activeProcess])) {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const remainingProcess = await readJson(join(runDir, 'active-command.json'), { optional: true });
|
|
549
|
+
const runnerAlive = runnerPid !== null && isProcessAlive(runnerPid);
|
|
550
|
+
if (!runnerAlive && (!remainingProcess || !isActiveProcessAlive(remainingProcess))) return true;
|
|
551
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function signalTarget(target, signal) {
|
|
557
|
+
if (process.platform === 'win32') return signalProcessGroup(target.pid, signal);
|
|
558
|
+
if (target.processGroupId && signalProcessGroup(target.processGroupId, signal)) return true;
|
|
559
|
+
return signalProcess(target.pid, signal);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function stopProcesses(targets) {
|
|
563
|
+
const watched = targets.filter(({ pid, processGroupId }) =>
|
|
564
|
+
(Number.isInteger(processGroupId) && processGroupId > 0 && processGroupId !== process.pid)
|
|
565
|
+
|| (Number.isInteger(pid) && pid > 0 && pid !== process.pid));
|
|
566
|
+
if (!watched.length) return true;
|
|
567
|
+
for (const target of watched) signalTarget(target, 'SIGTERM');
|
|
568
|
+
if (await waitForProcessesToStop(watched, 1000)) return true;
|
|
569
|
+
for (const target of watched) signalTarget(target, 'SIGKILL');
|
|
570
|
+
return waitForProcessesToStop(watched, 5000);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export async function cleanRuns({ cwd = process.cwd(), configPath, olderThanDays = 30, dryRun = false, yes = false } = {}) {
|
|
574
|
+
const context = await operationalContext(cwd, configPath);
|
|
575
|
+
const runs = await listRuns({ cwd, configPath });
|
|
576
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
577
|
+
const candidates = runs.filter((run) => run.status === 'completed' && ageTimestamp(run) <= cutoff);
|
|
578
|
+
const previewOnly = dryRun || !yes;
|
|
579
|
+
const removed = [];
|
|
580
|
+
const errors = [];
|
|
581
|
+
|
|
582
|
+
if (!previewOnly) {
|
|
583
|
+
for (const run of candidates) {
|
|
584
|
+
try {
|
|
585
|
+
if (run.worktree && resolve(run.worktree) !== resolve(context.repoRoot)) {
|
|
586
|
+
await context.rootGit.removeWorktree(run.worktree);
|
|
587
|
+
}
|
|
588
|
+
await rm(run.runDir, { recursive: true, force: true });
|
|
589
|
+
removed.push(run.name);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
errors.push({ name: run.name, error: error.message });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
olderThanDays,
|
|
598
|
+
cutoff: new Date(cutoff).toISOString(),
|
|
599
|
+
dryRun: previewOnly,
|
|
600
|
+
candidates,
|
|
601
|
+
removed,
|
|
602
|
+
errors,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function checkCommand(command, args, cwd) {
|
|
607
|
+
try {
|
|
608
|
+
await runCommand(command, args, { cwd });
|
|
609
|
+
return { ok: true, detail: `${command} is available` };
|
|
610
|
+
} catch (error) {
|
|
611
|
+
return { ok: false, detail: error.code === 'ENOENT' ? `${command} is not installed` : (error.output || error.message).trim() };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export async function doctor({ cwd = process.cwd(), configPath } = {}) {
|
|
616
|
+
const checks = [];
|
|
617
|
+
let context;
|
|
618
|
+
try {
|
|
619
|
+
context = await operationalContext(cwd, configPath, { validate: true });
|
|
620
|
+
checks.push({ name: 'config', ok: true, detail: `loaded ${configPath ?? 'loop.config.mjs (or defaults)'}` });
|
|
621
|
+
} catch (error) {
|
|
622
|
+
checks.push({ name: 'config', ok: false, detail: error.message });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const repoRoot = context?.repoRoot ?? cwd;
|
|
626
|
+
checks.push({ name: 'git', ...(await checkCommand('git', ['--version'], repoRoot)) });
|
|
627
|
+
if (context) {
|
|
628
|
+
try {
|
|
629
|
+
await context.rootGit.run(['worktree', 'list', '--porcelain']);
|
|
630
|
+
checks.push({ name: 'git worktree', ok: true, detail: 'worktree support is available' });
|
|
631
|
+
} catch (error) {
|
|
632
|
+
checks.push({ name: 'git worktree', ok: false, detail: error.message });
|
|
633
|
+
}
|
|
634
|
+
const agents = new Map();
|
|
635
|
+
for (const phase of context.config.resolvedPhases.flatMap((item) => [item, ...(item.repair ?? [])])) {
|
|
636
|
+
if (phase.kind !== 'agent') continue;
|
|
637
|
+
const agent = agentForPhase(context.config, phase.name);
|
|
638
|
+
agents.set(agent.name, agent);
|
|
639
|
+
}
|
|
640
|
+
for (const [name, agent] of agents) {
|
|
641
|
+
try {
|
|
642
|
+
const command = adapterFor(name, context.config.adapters).command({ prompt: '', cwd: repoRoot, addDirs: [], agent }).command;
|
|
643
|
+
checks.push({ name: `engine ${name}`, ...(await checkCommand(process.platform === 'win32' ? 'where' : 'which', [command], repoRoot)) });
|
|
644
|
+
} catch (error) {
|
|
645
|
+
checks.push({ name: `engine ${name}`, ok: false, detail: error.message });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
checks.push({ name: 'gh auth', ...(await checkCommand('gh', ['auth', 'status'], repoRoot)) });
|
|
650
|
+
return { ok: checks.every((check) => check.ok), checks };
|
|
651
|
+
}
|