@weldr/runr 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +216 -0
- package/LICENSE +190 -0
- package/NOTICE +4 -0
- package/README.md +200 -0
- package/dist/cli.js +464 -0
- package/dist/commands/__tests__/report.test.js +202 -0
- package/dist/commands/compare.js +168 -0
- package/dist/commands/doctor.js +124 -0
- package/dist/commands/follow.js +251 -0
- package/dist/commands/gc.js +161 -0
- package/dist/commands/guards-only.js +89 -0
- package/dist/commands/metrics.js +441 -0
- package/dist/commands/orchestrate.js +800 -0
- package/dist/commands/paths.js +31 -0
- package/dist/commands/preflight.js +152 -0
- package/dist/commands/report.js +478 -0
- package/dist/commands/resume.js +149 -0
- package/dist/commands/run.js +538 -0
- package/dist/commands/status.js +189 -0
- package/dist/commands/summarize.js +220 -0
- package/dist/commands/version.js +82 -0
- package/dist/commands/wait.js +170 -0
- package/dist/config/__tests__/presets.test.js +104 -0
- package/dist/config/load.js +66 -0
- package/dist/config/schema.js +160 -0
- package/dist/context/__tests__/artifact.test.js +130 -0
- package/dist/context/__tests__/pack.test.js +191 -0
- package/dist/context/artifact.js +67 -0
- package/dist/context/index.js +2 -0
- package/dist/context/pack.js +273 -0
- package/dist/diagnosis/analyzer.js +678 -0
- package/dist/diagnosis/formatter.js +136 -0
- package/dist/diagnosis/index.js +6 -0
- package/dist/diagnosis/types.js +7 -0
- package/dist/env/__tests__/fingerprint.test.js +116 -0
- package/dist/env/fingerprint.js +111 -0
- package/dist/orchestrator/__tests__/policy.test.js +185 -0
- package/dist/orchestrator/__tests__/schema-version.test.js +65 -0
- package/dist/orchestrator/artifacts.js +405 -0
- package/dist/orchestrator/state-machine.js +646 -0
- package/dist/orchestrator/types.js +88 -0
- package/dist/ownership/normalize.js +45 -0
- package/dist/repo/context.js +90 -0
- package/dist/repo/git.js +13 -0
- package/dist/repo/worktree.js +239 -0
- package/dist/store/run-store.js +107 -0
- package/dist/store/run-utils.js +69 -0
- package/dist/store/runs-root.js +126 -0
- package/dist/supervisor/__tests__/evidence-gate.test.js +111 -0
- package/dist/supervisor/__tests__/ownership.test.js +103 -0
- package/dist/supervisor/__tests__/state-machine.test.js +290 -0
- package/dist/supervisor/collision.js +240 -0
- package/dist/supervisor/evidence-gate.js +98 -0
- package/dist/supervisor/planner.js +18 -0
- package/dist/supervisor/runner.js +1562 -0
- package/dist/supervisor/scope-guard.js +55 -0
- package/dist/supervisor/state-machine.js +121 -0
- package/dist/supervisor/verification-policy.js +64 -0
- package/dist/tasks/task-metadata.js +72 -0
- package/dist/types/schemas.js +1 -0
- package/dist/verification/engine.js +49 -0
- package/dist/workers/__tests__/claude.test.js +88 -0
- package/dist/workers/__tests__/codex.test.js +81 -0
- package/dist/workers/claude.js +119 -0
- package/dist/workers/codex.js +162 -0
- package/dist/workers/json.js +22 -0
- package/dist/workers/mock.js +193 -0
- package/dist/workers/prompts.js +98 -0
- package/dist/workers/schemas.js +39 -0
- package/package.json +47 -0
- package/templates/prompts/implementer.md +70 -0
- package/templates/prompts/planner.md +62 -0
- package/templates/prompts/reviewer.md +77 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { computeKpiFromEvents } from './report.js';
|
|
5
|
+
import { getRunsRoot } from '../store/runs-root.js';
|
|
6
|
+
export async function compareCommand(options) {
|
|
7
|
+
const result = await loadComparison(options);
|
|
8
|
+
const output = formatComparison(result);
|
|
9
|
+
console.log(output);
|
|
10
|
+
}
|
|
11
|
+
async function loadComparison(options) {
|
|
12
|
+
const kpiA = await loadKpiForRun(options.runA, options.repo);
|
|
13
|
+
const kpiB = await loadKpiForRun(options.runB, options.repo);
|
|
14
|
+
return {
|
|
15
|
+
runA: { id: options.runA, kpi: kpiA },
|
|
16
|
+
runB: { id: options.runB, kpi: kpiB }
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function loadKpiForRun(runId, repoPath) {
|
|
20
|
+
const runDir = path.join(getRunsRoot(repoPath), runId);
|
|
21
|
+
if (!fs.existsSync(runDir)) {
|
|
22
|
+
throw new Error(`Run not found: ${runDir}`);
|
|
23
|
+
}
|
|
24
|
+
const timelinePath = path.join(runDir, 'timeline.jsonl');
|
|
25
|
+
if (!fs.existsSync(timelinePath)) {
|
|
26
|
+
throw new Error(`Timeline not found: ${timelinePath}`);
|
|
27
|
+
}
|
|
28
|
+
const events = await readTimelineEvents(timelinePath);
|
|
29
|
+
return computeKpiFromEvents(events);
|
|
30
|
+
}
|
|
31
|
+
async function readTimelineEvents(timelinePath) {
|
|
32
|
+
const events = [];
|
|
33
|
+
const stream = fs.createReadStream(timelinePath, { encoding: 'utf-8' });
|
|
34
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
35
|
+
for await (const line of rl) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
events.push(JSON.parse(trimmed));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return events;
|
|
47
|
+
}
|
|
48
|
+
function formatComparison(result) {
|
|
49
|
+
const { runA, runB } = result;
|
|
50
|
+
const lines = [];
|
|
51
|
+
lines.push('Compare');
|
|
52
|
+
lines.push(` A: ${runA.id}`);
|
|
53
|
+
lines.push(` B: ${runB.id}`);
|
|
54
|
+
lines.push('');
|
|
55
|
+
// Duration comparison
|
|
56
|
+
lines.push('Duration');
|
|
57
|
+
const durA = runA.kpi.total_duration_ms;
|
|
58
|
+
const durB = runB.kpi.total_duration_ms;
|
|
59
|
+
lines.push(` A: ${formatDuration(durA)}`);
|
|
60
|
+
lines.push(` B: ${formatDuration(durB)}`);
|
|
61
|
+
lines.push(` Δ: ${formatDelta(durA, durB)}`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
// Unattributed comparison
|
|
64
|
+
lines.push('Unattributed');
|
|
65
|
+
const unA = runA.kpi.unattributed_ms;
|
|
66
|
+
const unB = runB.kpi.unattributed_ms;
|
|
67
|
+
lines.push(` A: ${formatDuration(unA)}`);
|
|
68
|
+
lines.push(` B: ${formatDuration(unB)}`);
|
|
69
|
+
lines.push(` Δ: ${formatDelta(unA, unB)}`);
|
|
70
|
+
lines.push('');
|
|
71
|
+
// Worker calls
|
|
72
|
+
lines.push('Worker Calls');
|
|
73
|
+
lines.push(` A: claude=${runA.kpi.workers.claude} codex=${runA.kpi.workers.codex}`);
|
|
74
|
+
lines.push(` B: claude=${runB.kpi.workers.claude} codex=${runB.kpi.workers.codex}`);
|
|
75
|
+
const claudeDelta = formatWorkerDelta(runA.kpi.workers.claude, runB.kpi.workers.claude);
|
|
76
|
+
const codexDelta = formatWorkerDelta(runA.kpi.workers.codex, runB.kpi.workers.codex);
|
|
77
|
+
lines.push(` Δ: claude=${claudeDelta} codex=${codexDelta}`);
|
|
78
|
+
lines.push('');
|
|
79
|
+
// Verification
|
|
80
|
+
lines.push('Verification');
|
|
81
|
+
lines.push(` A: attempts=${runA.kpi.verify.attempts} retries=${runA.kpi.verify.retries} duration=${formatDuration(runA.kpi.verify.total_duration_ms)}`);
|
|
82
|
+
lines.push(` B: attempts=${runB.kpi.verify.attempts} retries=${runB.kpi.verify.retries} duration=${formatDuration(runB.kpi.verify.total_duration_ms)}`);
|
|
83
|
+
const attemptsDelta = runB.kpi.verify.attempts - runA.kpi.verify.attempts;
|
|
84
|
+
const retriesDelta = runB.kpi.verify.retries - runA.kpi.verify.retries;
|
|
85
|
+
const verifyDurDelta = formatDelta(runA.kpi.verify.total_duration_ms, runB.kpi.verify.total_duration_ms);
|
|
86
|
+
lines.push(` Δ: attempts=${formatNumDelta(attemptsDelta)} retries=${formatNumDelta(retriesDelta)} duration=${verifyDurDelta}`);
|
|
87
|
+
lines.push('');
|
|
88
|
+
// Milestones
|
|
89
|
+
lines.push('Milestones');
|
|
90
|
+
lines.push(` A: ${runA.kpi.milestones.completed}`);
|
|
91
|
+
lines.push(` B: ${runB.kpi.milestones.completed}`);
|
|
92
|
+
const msDelta = runB.kpi.milestones.completed - runA.kpi.milestones.completed;
|
|
93
|
+
lines.push(` Δ: ${formatNumDelta(msDelta)}`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
// Phase comparison
|
|
96
|
+
lines.push('Phases');
|
|
97
|
+
const allPhases = new Set([
|
|
98
|
+
...Object.keys(runA.kpi.phases),
|
|
99
|
+
...Object.keys(runB.kpi.phases)
|
|
100
|
+
]);
|
|
101
|
+
const phaseOrder = ['PLAN', 'IMPLEMENT', 'VERIFY', 'REVIEW', 'CHECKPOINT', 'FINALIZE'];
|
|
102
|
+
const sortedPhases = [...allPhases].sort((a, b) => {
|
|
103
|
+
const aIdx = phaseOrder.indexOf(a);
|
|
104
|
+
const bIdx = phaseOrder.indexOf(b);
|
|
105
|
+
if (aIdx === -1 && bIdx === -1)
|
|
106
|
+
return a.localeCompare(b);
|
|
107
|
+
if (aIdx === -1)
|
|
108
|
+
return 1;
|
|
109
|
+
if (bIdx === -1)
|
|
110
|
+
return -1;
|
|
111
|
+
return aIdx - bIdx;
|
|
112
|
+
});
|
|
113
|
+
for (const phase of sortedPhases) {
|
|
114
|
+
const pA = runA.kpi.phases[phase] ?? { duration_ms: 0, count: 0 };
|
|
115
|
+
const pB = runB.kpi.phases[phase] ?? { duration_ms: 0, count: 0 };
|
|
116
|
+
const durDelta = formatDelta(pA.duration_ms, pB.duration_ms);
|
|
117
|
+
const countDelta = pB.count - pA.count;
|
|
118
|
+
const highlight = pB.duration_ms > pA.duration_ms * 1.2 ? ' ⚠️' : '';
|
|
119
|
+
lines.push(` ${phase}: A=${formatDuration(pA.duration_ms)}(x${pA.count}) B=${formatDuration(pB.duration_ms)}(x${pB.count}) Δ=${durDelta}(${formatNumDelta(countDelta)})${highlight}`);
|
|
120
|
+
}
|
|
121
|
+
lines.push('');
|
|
122
|
+
// Outcome
|
|
123
|
+
lines.push('Outcome');
|
|
124
|
+
lines.push(` A: ${runA.kpi.outcome}${runA.kpi.stop_reason ? ` (${runA.kpi.stop_reason})` : ''}`);
|
|
125
|
+
lines.push(` B: ${runB.kpi.outcome}${runB.kpi.stop_reason ? ` (${runB.kpi.stop_reason})` : ''}`);
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
128
|
+
function formatDuration(ms) {
|
|
129
|
+
if (ms === null)
|
|
130
|
+
return 'unknown';
|
|
131
|
+
if (ms < 0)
|
|
132
|
+
return `-${formatPositiveDuration(Math.abs(ms))}`;
|
|
133
|
+
return formatPositiveDuration(ms);
|
|
134
|
+
}
|
|
135
|
+
function formatPositiveDuration(ms) {
|
|
136
|
+
if (ms < 1000)
|
|
137
|
+
return `${ms}ms`;
|
|
138
|
+
const seconds = Math.floor(ms / 1000);
|
|
139
|
+
if (seconds < 60)
|
|
140
|
+
return `${seconds}s`;
|
|
141
|
+
const minutes = Math.floor(seconds / 60);
|
|
142
|
+
const remainingSeconds = seconds % 60;
|
|
143
|
+
if (minutes < 60) {
|
|
144
|
+
return remainingSeconds > 0 ? `${minutes}m${remainingSeconds}s` : `${minutes}m`;
|
|
145
|
+
}
|
|
146
|
+
const hours = Math.floor(minutes / 60);
|
|
147
|
+
const remainingMinutes = minutes % 60;
|
|
148
|
+
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
|
|
149
|
+
}
|
|
150
|
+
function formatDelta(a, b) {
|
|
151
|
+
if (a === null || b === null)
|
|
152
|
+
return 'n/a';
|
|
153
|
+
const diff = b - a;
|
|
154
|
+
if (diff === 0)
|
|
155
|
+
return '0';
|
|
156
|
+
const sign = diff > 0 ? '+' : '-';
|
|
157
|
+
return `${sign}${formatPositiveDuration(Math.abs(diff))}`;
|
|
158
|
+
}
|
|
159
|
+
function formatNumDelta(diff) {
|
|
160
|
+
if (diff === 0)
|
|
161
|
+
return '0';
|
|
162
|
+
return diff > 0 ? `+${diff}` : `${diff}`;
|
|
163
|
+
}
|
|
164
|
+
function formatWorkerDelta(a, b) {
|
|
165
|
+
if (a === 'unknown' || b === 'unknown')
|
|
166
|
+
return 'n/a';
|
|
167
|
+
return formatNumDelta(b - a);
|
|
168
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig, resolveConfigPath } from '../config/load.js';
|
|
4
|
+
async function checkWorker(name, worker, repoPath) {
|
|
5
|
+
const result = {
|
|
6
|
+
name,
|
|
7
|
+
bin: worker.bin,
|
|
8
|
+
version: null,
|
|
9
|
+
headless: false,
|
|
10
|
+
error: null
|
|
11
|
+
};
|
|
12
|
+
// Check version
|
|
13
|
+
try {
|
|
14
|
+
const versionResult = await execa(worker.bin, ['--version'], {
|
|
15
|
+
timeout: 5000,
|
|
16
|
+
reject: false
|
|
17
|
+
});
|
|
18
|
+
if (versionResult.exitCode === 0) {
|
|
19
|
+
result.version = versionResult.stdout.trim().split('\n')[0];
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
result.error = `Version check failed: ${versionResult.stderr || 'unknown error'}`;
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
result.error = `Command not found: ${worker.bin}`;
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
// Check headless mode with a simple ping
|
|
31
|
+
try {
|
|
32
|
+
const testPrompt = 'Respond with exactly: PING_OK';
|
|
33
|
+
let testArgs;
|
|
34
|
+
if (name === 'codex') {
|
|
35
|
+
testArgs = ['exec', '--full-auto', '--json', '-C', repoPath];
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
testArgs = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
|
|
39
|
+
}
|
|
40
|
+
const headlessResult = await execa(worker.bin, testArgs, {
|
|
41
|
+
input: testPrompt,
|
|
42
|
+
timeout: 30000,
|
|
43
|
+
reject: false,
|
|
44
|
+
cwd: repoPath
|
|
45
|
+
});
|
|
46
|
+
if (headlessResult.exitCode === 0) {
|
|
47
|
+
result.headless = true;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const stderr = headlessResult.stderr || '';
|
|
51
|
+
if (stderr.includes('stdin is not a terminal')) {
|
|
52
|
+
result.error = 'Headless mode not supported (stdin is not a terminal)';
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
result.error = `Headless test failed: ${stderr.slice(0, 100)}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
result.error = `Headless test error: ${err.message}`;
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
export async function runDoctorChecks(config, repoPath) {
|
|
65
|
+
const checks = [];
|
|
66
|
+
for (const [name, workerConfig] of Object.entries(config.workers)) {
|
|
67
|
+
const check = await checkWorker(name, workerConfig, repoPath);
|
|
68
|
+
checks.push(check);
|
|
69
|
+
}
|
|
70
|
+
return checks;
|
|
71
|
+
}
|
|
72
|
+
export async function doctorCommand(options) {
|
|
73
|
+
const repoPath = path.resolve(options.repo || '.');
|
|
74
|
+
const configPath = resolveConfigPath(repoPath, options.config);
|
|
75
|
+
console.log('Doctor Check');
|
|
76
|
+
console.log('============\n');
|
|
77
|
+
let config;
|
|
78
|
+
try {
|
|
79
|
+
config = loadConfig(configPath);
|
|
80
|
+
console.log(`Config: ${configPath}`);
|
|
81
|
+
console.log(`Repo: ${repoPath}\n`);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.log(`Config: FAIL - ${err.message}`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const checks = await runDoctorChecks(config, repoPath);
|
|
89
|
+
console.log('Workers\n-------');
|
|
90
|
+
for (const check of checks) {
|
|
91
|
+
const status = check.error ? 'FAIL' : 'PASS';
|
|
92
|
+
const version = check.version || 'unknown';
|
|
93
|
+
const headless = check.headless ? 'headless OK' : 'headless FAIL';
|
|
94
|
+
console.log(`${check.name}: ${status}`);
|
|
95
|
+
console.log(` bin: ${check.bin}`);
|
|
96
|
+
console.log(` version: ${version}`);
|
|
97
|
+
console.log(` ${headless}`);
|
|
98
|
+
if (check.error) {
|
|
99
|
+
console.log(` error: ${check.error}`);
|
|
100
|
+
}
|
|
101
|
+
console.log('');
|
|
102
|
+
}
|
|
103
|
+
// Show phase configuration
|
|
104
|
+
console.log('Phases\n------');
|
|
105
|
+
console.log(` plan: ${config.phases.plan}`);
|
|
106
|
+
console.log(` implement: ${config.phases.implement}`);
|
|
107
|
+
console.log(` review: ${config.phases.review}`);
|
|
108
|
+
console.log('');
|
|
109
|
+
// Check that configured phase workers are available
|
|
110
|
+
const phaseWorkers = new Set([config.phases.plan, config.phases.implement, config.phases.review]);
|
|
111
|
+
const failedWorkers = checks.filter((c) => c.error).map((c) => c.name);
|
|
112
|
+
const usedButFailed = [...phaseWorkers].filter((w) => failedWorkers.includes(w));
|
|
113
|
+
const failed = checks.filter((c) => c.error);
|
|
114
|
+
if (failed.length > 0) {
|
|
115
|
+
console.log(`\nResult: ${failed.length} worker(s) failed`);
|
|
116
|
+
if (usedButFailed.length > 0) {
|
|
117
|
+
console.log(`Warning: Phase(s) configured to use failed worker(s): ${usedButFailed.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
console.log('\nResult: All workers OK');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { getRunsRoot } from '../store/runs-root.js';
|
|
5
|
+
const TERMINAL_PHASES = ['STOPPED', 'DONE'];
|
|
6
|
+
const POLL_INTERVAL_MS = 1000;
|
|
7
|
+
/**
|
|
8
|
+
* Find the best run to follow: prefer running runs, else latest.
|
|
9
|
+
* Returns { runId, wasRunning } so caller can inform user.
|
|
10
|
+
*/
|
|
11
|
+
export function findBestRunToFollow(repoPath) {
|
|
12
|
+
const runsDir = getRunsRoot(repoPath);
|
|
13
|
+
if (!fs.existsSync(runsDir)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const runIds = fs
|
|
17
|
+
.readdirSync(runsDir, { withFileTypes: true })
|
|
18
|
+
.filter((e) => e.isDirectory() && /^\d{14}$/.test(e.name))
|
|
19
|
+
.map((e) => e.name)
|
|
20
|
+
.sort()
|
|
21
|
+
.reverse();
|
|
22
|
+
if (runIds.length === 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
// Check for a running run (newest first)
|
|
26
|
+
for (const runId of runIds) {
|
|
27
|
+
const statePath = path.join(runsDir, runId, 'state.json');
|
|
28
|
+
if (!fs.existsSync(statePath))
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
32
|
+
if (!TERMINAL_PHASES.includes(state.phase)) {
|
|
33
|
+
return { runId, wasRunning: true };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// No running run, return latest
|
|
41
|
+
return { runId: runIds[0], wasRunning: false };
|
|
42
|
+
}
|
|
43
|
+
function formatDuration(ms) {
|
|
44
|
+
if (ms < 1000)
|
|
45
|
+
return `${ms}ms`;
|
|
46
|
+
const seconds = Math.floor(ms / 1000);
|
|
47
|
+
if (seconds < 60)
|
|
48
|
+
return `${seconds}s`;
|
|
49
|
+
const minutes = Math.floor(seconds / 60);
|
|
50
|
+
const remainingSeconds = seconds % 60;
|
|
51
|
+
if (minutes < 60) {
|
|
52
|
+
return remainingSeconds > 0 ? `${minutes}m${remainingSeconds}s` : `${minutes}m`;
|
|
53
|
+
}
|
|
54
|
+
const hours = Math.floor(minutes / 60);
|
|
55
|
+
const remainingMinutes = minutes % 60;
|
|
56
|
+
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
|
|
57
|
+
}
|
|
58
|
+
function readLastWorkerCall(runDir) {
|
|
59
|
+
const statePath = path.join(runDir, 'state.json');
|
|
60
|
+
if (!fs.existsSync(statePath))
|
|
61
|
+
return null;
|
|
62
|
+
try {
|
|
63
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
64
|
+
const state = JSON.parse(content);
|
|
65
|
+
if (state.last_worker_call && typeof state.last_worker_call === 'object') {
|
|
66
|
+
return state.last_worker_call;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
function formatEvent(event) {
|
|
75
|
+
const time = new Date(event.timestamp).toLocaleTimeString();
|
|
76
|
+
const prefix = `[${time}] ${event.type}`;
|
|
77
|
+
switch (event.type) {
|
|
78
|
+
case 'run_started':
|
|
79
|
+
return `${prefix} - task: ${event.payload.task}`;
|
|
80
|
+
case 'preflight': {
|
|
81
|
+
const pf = event.payload;
|
|
82
|
+
const guardStatus = pf.guard?.ok ? 'pass' : 'FAIL';
|
|
83
|
+
const pingStatus = pf.ping?.skipped ? 'skipped' : pf.ping?.ok ? 'pass' : 'FAIL';
|
|
84
|
+
return `${prefix} - guard: ${guardStatus}, ping: ${pingStatus}`;
|
|
85
|
+
}
|
|
86
|
+
case 'phase_start':
|
|
87
|
+
return `${prefix} → ${event.payload.phase}`;
|
|
88
|
+
case 'plan_generated': {
|
|
89
|
+
const plan = event.payload;
|
|
90
|
+
const count = plan.milestones?.length ?? 0;
|
|
91
|
+
return `${prefix} - ${count} milestones`;
|
|
92
|
+
}
|
|
93
|
+
case 'implement_complete': {
|
|
94
|
+
const impl = event.payload;
|
|
95
|
+
const files = impl.changed_files?.length ?? 0;
|
|
96
|
+
return `${prefix} - ${files} files changed`;
|
|
97
|
+
}
|
|
98
|
+
case 'review_complete': {
|
|
99
|
+
const review = event.payload;
|
|
100
|
+
return `${prefix} - verdict: ${review.verdict}`;
|
|
101
|
+
}
|
|
102
|
+
case 'tier_passed':
|
|
103
|
+
case 'tier_failed': {
|
|
104
|
+
const tier = event.payload;
|
|
105
|
+
return `${prefix} - ${tier.tier} (${tier.passed ?? 0} passed, ${tier.failed ?? 0} failed)`;
|
|
106
|
+
}
|
|
107
|
+
case 'worker_fallback': {
|
|
108
|
+
const fb = event.payload;
|
|
109
|
+
return `${prefix} - ${fb.from} → ${fb.to} (${fb.reason})`;
|
|
110
|
+
}
|
|
111
|
+
case 'parse_failed': {
|
|
112
|
+
const pf = event.payload;
|
|
113
|
+
return `${prefix} - stage: ${pf.stage}, retry: ${pf.retry_count}`;
|
|
114
|
+
}
|
|
115
|
+
case 'late_worker_result_ignored': {
|
|
116
|
+
const late = event.payload;
|
|
117
|
+
return `${prefix} - ${late.stage} from ${late.worker}`;
|
|
118
|
+
}
|
|
119
|
+
case 'stop': {
|
|
120
|
+
const stop = event.payload;
|
|
121
|
+
const suffix = stop.worker_in_flight ? ' (worker was in-flight)' : '';
|
|
122
|
+
return `${prefix} - reason: ${stop.reason}${suffix}`;
|
|
123
|
+
}
|
|
124
|
+
case 'run_complete': {
|
|
125
|
+
const rc = event.payload;
|
|
126
|
+
return `${prefix} - outcome: ${rc.outcome}`;
|
|
127
|
+
}
|
|
128
|
+
case 'milestone_complete':
|
|
129
|
+
return `${prefix} - milestone ${event.payload.milestone_index}`;
|
|
130
|
+
case 'stalled_timeout': {
|
|
131
|
+
const st = event.payload;
|
|
132
|
+
const sec = st.elapsed_ms ? Math.round(st.elapsed_ms / 1000) : '?';
|
|
133
|
+
return `${prefix} - after ${sec}s`;
|
|
134
|
+
}
|
|
135
|
+
default:
|
|
136
|
+
return prefix;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function tailTimeline(timelinePath, fromLine) {
|
|
140
|
+
if (!fs.existsSync(timelinePath)) {
|
|
141
|
+
return { events: [], newLineCount: 0 };
|
|
142
|
+
}
|
|
143
|
+
const events = [];
|
|
144
|
+
const fileStream = fs.createReadStream(timelinePath);
|
|
145
|
+
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
146
|
+
let lineNum = 0;
|
|
147
|
+
for await (const line of rl) {
|
|
148
|
+
lineNum++;
|
|
149
|
+
if (lineNum <= fromLine)
|
|
150
|
+
continue;
|
|
151
|
+
if (!line.trim())
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
const event = JSON.parse(line);
|
|
155
|
+
events.push(event);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Skip malformed lines
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { events, newLineCount: lineNum };
|
|
162
|
+
}
|
|
163
|
+
function readState(statePath) {
|
|
164
|
+
if (!fs.existsSync(statePath)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
169
|
+
return JSON.parse(content);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function followCommand(options) {
|
|
176
|
+
const runDir = path.join(getRunsRoot(options.repo), options.runId);
|
|
177
|
+
if (!fs.existsSync(runDir)) {
|
|
178
|
+
console.error(`Run directory not found: ${runDir}`);
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const timelinePath = path.join(runDir, 'timeline.jsonl');
|
|
183
|
+
const statePath = path.join(runDir, 'state.json');
|
|
184
|
+
console.log(`Following run ${options.runId}...`);
|
|
185
|
+
console.log('---');
|
|
186
|
+
let lastLineCount = 0;
|
|
187
|
+
let terminated = false;
|
|
188
|
+
// Initial read of existing events
|
|
189
|
+
const initial = await tailTimeline(timelinePath, 0);
|
|
190
|
+
for (const event of initial.events) {
|
|
191
|
+
console.log(formatEvent(event));
|
|
192
|
+
}
|
|
193
|
+
lastLineCount = initial.newLineCount;
|
|
194
|
+
// Check if already terminated
|
|
195
|
+
const initialState = readState(statePath);
|
|
196
|
+
if (initialState && TERMINAL_PHASES.includes(initialState.phase)) {
|
|
197
|
+
console.log('---');
|
|
198
|
+
console.log(`Run already terminated: ${initialState.phase}`);
|
|
199
|
+
if (initialState.stop_reason) {
|
|
200
|
+
console.log(`Reason: ${initialState.stop_reason}`);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Poll for new events with progress age display
|
|
205
|
+
let lastStatusLine = '';
|
|
206
|
+
while (!terminated) {
|
|
207
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
208
|
+
const update = await tailTimeline(timelinePath, lastLineCount);
|
|
209
|
+
for (const event of update.events) {
|
|
210
|
+
// Clear status line before printing event
|
|
211
|
+
if (lastStatusLine) {
|
|
212
|
+
process.stdout.write('\r' + ' '.repeat(lastStatusLine.length) + '\r');
|
|
213
|
+
lastStatusLine = '';
|
|
214
|
+
}
|
|
215
|
+
console.log(formatEvent(event));
|
|
216
|
+
}
|
|
217
|
+
lastLineCount = update.newLineCount;
|
|
218
|
+
// Check for termination
|
|
219
|
+
const state = readState(statePath);
|
|
220
|
+
if (state && TERMINAL_PHASES.includes(state.phase)) {
|
|
221
|
+
terminated = true;
|
|
222
|
+
if (lastStatusLine) {
|
|
223
|
+
process.stdout.write('\r' + ' '.repeat(lastStatusLine.length) + '\r');
|
|
224
|
+
}
|
|
225
|
+
console.log('---');
|
|
226
|
+
console.log(`Run terminated: ${state.phase}`);
|
|
227
|
+
if (state.stop_reason) {
|
|
228
|
+
console.log(`Reason: ${state.stop_reason}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (state) {
|
|
232
|
+
// Show progress age status line
|
|
233
|
+
const progressAge = state.last_progress_at
|
|
234
|
+
? formatDuration(Date.now() - new Date(state.last_progress_at).getTime())
|
|
235
|
+
: '?';
|
|
236
|
+
const workerCall = readLastWorkerCall(runDir);
|
|
237
|
+
const workerStatus = workerCall
|
|
238
|
+
? `worker_in_flight=${workerCall.worker}:${workerCall.stage}`
|
|
239
|
+
: 'idle';
|
|
240
|
+
const statusLine = ` [${state.phase}] last progress ${progressAge} ago, ${workerStatus}`;
|
|
241
|
+
// Only update if changed
|
|
242
|
+
if (statusLine !== lastStatusLine) {
|
|
243
|
+
if (lastStatusLine) {
|
|
244
|
+
process.stdout.write('\r' + ' '.repeat(lastStatusLine.length) + '\r');
|
|
245
|
+
}
|
|
246
|
+
process.stdout.write(statusLine);
|
|
247
|
+
lastStatusLine = statusLine;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|