agentxchain 2.46.2 → 2.48.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/bin/agentxchain.js +16 -1
- package/package.json +1 -1
- package/src/commands/events.js +150 -0
- package/src/commands/history.js +41 -1
- package/src/commands/run.js +32 -1
- package/src/commands/status.js +6 -0
- package/src/lib/export.js +4 -0
- package/src/lib/governed-state.js +162 -5
- package/src/lib/repo-observer.js +2 -0
- package/src/lib/report.js +8 -0
- package/src/lib/run-events.js +117 -0
- package/src/lib/run-history.js +111 -0
- package/src/lib/run-loop.js +9 -3
- package/src/lib/run-provenance.js +90 -0
package/bin/agentxchain.js
CHANGED
|
@@ -106,6 +106,7 @@ import { intakeResolveCommand } from '../src/commands/intake-resolve.js';
|
|
|
106
106
|
import { intakeStatusCommand } from '../src/commands/intake-status.js';
|
|
107
107
|
import { demoCommand } from '../src/commands/demo.js';
|
|
108
108
|
import { historyCommand } from '../src/commands/history.js';
|
|
109
|
+
import { eventsCommand } from '../src/commands/events.js';
|
|
109
110
|
|
|
110
111
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
111
112
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -262,10 +263,22 @@ program
|
|
|
262
263
|
.description('Show cross-run history of governed runs in this project')
|
|
263
264
|
.option('-j, --json', 'Output as JSON')
|
|
264
265
|
.option('-l, --limit <n>', 'Number of recent runs to show (default: 20)')
|
|
265
|
-
.option('-s, --status <status>', 'Filter by status: completed
|
|
266
|
+
.option('-s, --status <status>', 'Filter by status: completed or blocked')
|
|
267
|
+
.option('--lineage <run_id>', 'Show lineage chain for a specific run')
|
|
266
268
|
.option('-d, --dir <path>', 'Project directory')
|
|
267
269
|
.action(historyCommand);
|
|
268
270
|
|
|
271
|
+
program
|
|
272
|
+
.command('events')
|
|
273
|
+
.description('Show repo-local run lifecycle events')
|
|
274
|
+
.option('-f, --follow', 'Stream events as they occur')
|
|
275
|
+
.option('-t, --type <type>', 'Filter by event type (comma-separated)')
|
|
276
|
+
.option('--since <timestamp>', 'Show events after ISO-8601 timestamp')
|
|
277
|
+
.option('-j, --json', 'Output raw JSONL')
|
|
278
|
+
.option('-l, --limit <n>', 'Max events to show (default: 50, 0 = all)')
|
|
279
|
+
.option('-d, --dir <path>', 'Project directory')
|
|
280
|
+
.action(eventsCommand);
|
|
281
|
+
|
|
269
282
|
program
|
|
270
283
|
.command('validate')
|
|
271
284
|
.description('Validate project protocol artifacts')
|
|
@@ -355,6 +368,8 @@ program
|
|
|
355
368
|
.option('--verbose', 'Stream adapter subprocess output')
|
|
356
369
|
.option('--dry-run', 'Print what would be dispatched without executing')
|
|
357
370
|
.option('--no-report', 'Suppress automatic governance report after run completes')
|
|
371
|
+
.option('--continue-from <run_id>', 'Continue from a prior terminal run (sets trigger=continuation)')
|
|
372
|
+
.option('--recover-from <run_id>', 'Recover from a prior blocked run (sets trigger=recovery)')
|
|
358
373
|
.action(runCommand);
|
|
359
374
|
|
|
360
375
|
program
|
package/package.json
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentxchain events — repo-local run event stream reader.
|
|
3
|
+
*
|
|
4
|
+
* Reads and optionally follows the `.agentxchain/events.jsonl` log,
|
|
5
|
+
* giving operators structured visibility into governed run lifecycle
|
|
6
|
+
* without requiring webhooks or a dashboard.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
import { existsSync, watchFile, unwatchFile } from 'fs';
|
|
11
|
+
import { readFileSync } from 'fs';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { readRunEvents, RUN_EVENTS_PATH, VALID_RUN_EVENTS } from '../lib/run-events.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {boolean} [opts.follow] - Stream events as they arrive
|
|
18
|
+
* @param {string} [opts.type] - Comma-separated event types
|
|
19
|
+
* @param {string} [opts.since] - ISO-8601 timestamp filter
|
|
20
|
+
* @param {boolean} [opts.json] - Raw JSONL output
|
|
21
|
+
* @param {number} [opts.limit] - Max events to show (default 50)
|
|
22
|
+
* @param {string} [opts.dir] - Project directory
|
|
23
|
+
*/
|
|
24
|
+
export async function eventsCommand(opts) {
|
|
25
|
+
const root = findProjectRoot(opts.dir || process.cwd());
|
|
26
|
+
if (!root) {
|
|
27
|
+
console.error(chalk.red('No AgentXchain project found. Run this inside a governed project.'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const limit = opts.limit != null ? parseInt(opts.limit, 10) : 50;
|
|
32
|
+
const events = readRunEvents(root, {
|
|
33
|
+
type: opts.type,
|
|
34
|
+
since: opts.since,
|
|
35
|
+
limit: limit === 0 ? undefined : limit,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (opts.json) {
|
|
39
|
+
for (const evt of events) {
|
|
40
|
+
console.log(JSON.stringify(evt));
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
if (events.length === 0 && !opts.follow) {
|
|
44
|
+
console.log(chalk.dim('No events found.'));
|
|
45
|
+
if (opts.type) console.log(chalk.dim(` (filtered by type: ${opts.type})`));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const evt of events) {
|
|
49
|
+
printEvent(evt);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.follow) {
|
|
54
|
+
return followEvents(root, opts);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printEvent(evt) {
|
|
59
|
+
const ts = evt.timestamp ? new Date(evt.timestamp).toLocaleTimeString() : '—';
|
|
60
|
+
const type = colorEventType(evt.event_type);
|
|
61
|
+
const runId = evt.run_id ? evt.run_id.slice(0, 12) : '—';
|
|
62
|
+
const phase = evt.phase || '—';
|
|
63
|
+
const turnInfo = evt.turn?.role_id ? ` [${evt.turn.role_id}]` : '';
|
|
64
|
+
console.log(`${chalk.dim(ts)} ${type} ${chalk.cyan(runId)} ${phase}${turnInfo}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function colorEventType(type) {
|
|
68
|
+
const colors = {
|
|
69
|
+
run_started: chalk.green,
|
|
70
|
+
run_completed: chalk.green.bold,
|
|
71
|
+
run_blocked: chalk.red,
|
|
72
|
+
turn_dispatched: chalk.blue,
|
|
73
|
+
turn_accepted: chalk.green,
|
|
74
|
+
turn_rejected: chalk.yellow,
|
|
75
|
+
phase_entered: chalk.magenta,
|
|
76
|
+
escalation_raised: chalk.red.bold,
|
|
77
|
+
escalation_resolved: chalk.green,
|
|
78
|
+
gate_pending: chalk.yellow,
|
|
79
|
+
gate_approved: chalk.green,
|
|
80
|
+
};
|
|
81
|
+
const colorFn = colors[type] || chalk.white;
|
|
82
|
+
return colorFn(pad(type, 22));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pad(str, len) {
|
|
86
|
+
return (str || '').padEnd(len);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function followEvents(root, opts) {
|
|
90
|
+
const filePath = resolve(root, RUN_EVENTS_PATH);
|
|
91
|
+
let lastSize = 0;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (existsSync(filePath)) {
|
|
95
|
+
lastSize = readFileSync(filePath).length;
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
console.log(chalk.dim('Watching for events... (Ctrl+C to stop)'));
|
|
100
|
+
|
|
101
|
+
return new Promise(() => {
|
|
102
|
+
const checkForNewEvents = () => {
|
|
103
|
+
try {
|
|
104
|
+
if (!existsSync(filePath)) return;
|
|
105
|
+
const content = readFileSync(filePath, 'utf8');
|
|
106
|
+
if (content.length <= lastSize) return;
|
|
107
|
+
|
|
108
|
+
const newContent = content.slice(lastSize);
|
|
109
|
+
lastSize = content.length;
|
|
110
|
+
|
|
111
|
+
const lines = newContent.split('\n').filter(Boolean);
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
try {
|
|
114
|
+
const evt = JSON.parse(line);
|
|
115
|
+
if (opts.type) {
|
|
116
|
+
const types = new Set(opts.type.split(',').map(t => t.trim()));
|
|
117
|
+
if (!types.has(evt.event_type)) continue;
|
|
118
|
+
}
|
|
119
|
+
if (opts.json) {
|
|
120
|
+
console.log(JSON.stringify(evt));
|
|
121
|
+
} else {
|
|
122
|
+
printEvent(evt);
|
|
123
|
+
}
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
watchFile(filePath, { interval: 200 }, checkForNewEvents);
|
|
130
|
+
|
|
131
|
+
process.on('SIGINT', () => {
|
|
132
|
+
unwatchFile(filePath, checkForNewEvents);
|
|
133
|
+
process.exit(0);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Walk up to find the nearest directory containing agentxchain.json.
|
|
140
|
+
*/
|
|
141
|
+
function findProjectRoot(start) {
|
|
142
|
+
let dir = resolve(start);
|
|
143
|
+
while (true) {
|
|
144
|
+
if (existsSync(resolve(dir, 'agentxchain.json'))) return dir;
|
|
145
|
+
if (existsSync(resolve(dir, '.agentxchain', 'state.json'))) return dir;
|
|
146
|
+
const parent = resolve(dir, '..');
|
|
147
|
+
if (parent === dir) return null;
|
|
148
|
+
dir = parent;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/commands/history.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { resolve } from 'path';
|
|
8
8
|
import { existsSync, readFileSync } from 'fs';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
-
import { queryRunHistory } from '../lib/run-history.js';
|
|
10
|
+
import { queryRunHistory, queryRunLineage } from '../lib/run-history.js';
|
|
11
|
+
import { getRunTriggerLabel, summarizeRunProvenance } from '../lib/run-provenance.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* @param {object} opts - { json?: boolean, limit?: number, status?: string, dir?: string }
|
|
@@ -19,6 +20,42 @@ export async function historyCommand(opts) {
|
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// ── Lineage mode ─────────────────────────────────────────────────────────
|
|
24
|
+
if (opts.lineage) {
|
|
25
|
+
const result = queryRunLineage(root, opts.lineage);
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
console.error(chalk.red(result.error));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (opts.json) {
|
|
32
|
+
console.log(JSON.stringify(result.chain, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(chalk.bold(`Run Lineage for ${opts.lineage}:`));
|
|
37
|
+
result.chain.forEach((entry, i) => {
|
|
38
|
+
if (entry.broken_link) {
|
|
39
|
+
const prefix = i === 0 ? ' ' : ' └─ ';
|
|
40
|
+
console.log(chalk.red(`${prefix}[broken link: ${entry.missing_run_id}]`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const runId = (entry.run_id || '—').slice(0, 12);
|
|
44
|
+
const status = formatStatus(entry.status);
|
|
45
|
+
const phases = (entry.phases_completed || []).join(',') || '—';
|
|
46
|
+
const turns = `${entry.total_turns || 0} turns`;
|
|
47
|
+
const cost = entry.total_cost_usd != null ? `$${entry.total_cost_usd.toFixed(2)}` : '';
|
|
48
|
+
const trigger = getRunTriggerLabel(entry.provenance);
|
|
49
|
+
const parentNote = entry.provenance?.parent_run_id
|
|
50
|
+
? ` from ${entry.provenance.parent_run_id.slice(0, 12)}`
|
|
51
|
+
: '';
|
|
52
|
+
const prefix = i === 0 ? ' ' : ' └─ ';
|
|
53
|
+
console.log(`${prefix}${runId} ${status} ${pad(phases, 20)} ${pad(turns, 10)} ${pad(cost, 8)} (${trigger}${parentNote})`);
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Standard history view ────────────────────────────────────────────────
|
|
22
59
|
const limit = opts.limit ? parseInt(opts.limit, 10) : 20;
|
|
23
60
|
const entries = queryRunHistory(root, {
|
|
24
61
|
limit,
|
|
@@ -43,6 +80,7 @@ export async function historyCommand(opts) {
|
|
|
43
80
|
pad('#', 4),
|
|
44
81
|
pad('Run ID', 14),
|
|
45
82
|
pad('Status', 11),
|
|
83
|
+
pad('Trigger', 14),
|
|
46
84
|
pad('Phases', 8),
|
|
47
85
|
pad('Turns', 6),
|
|
48
86
|
pad('Cost', 10),
|
|
@@ -57,6 +95,7 @@ export async function historyCommand(opts) {
|
|
|
57
95
|
const idx = String(i + 1);
|
|
58
96
|
const runId = (entry.run_id || '—').slice(0, 12);
|
|
59
97
|
const status = formatStatus(entry.status);
|
|
98
|
+
const trigger = getRunTriggerLabel(entry.provenance);
|
|
60
99
|
const phases = String(entry.phases_completed?.length || 0);
|
|
61
100
|
const turns = String(entry.total_turns || 0);
|
|
62
101
|
const cost = entry.total_cost_usd != null
|
|
@@ -73,6 +112,7 @@ export async function historyCommand(opts) {
|
|
|
73
112
|
pad(idx, 4),
|
|
74
113
|
pad(runId, 14),
|
|
75
114
|
pad(status, 11),
|
|
115
|
+
pad(trigger, 14),
|
|
76
116
|
pad(phases, 8),
|
|
77
117
|
pad(turns, 6),
|
|
78
118
|
pad(cost, 10),
|
package/src/commands/run.js
CHANGED
|
@@ -20,6 +20,7 @@ import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
|
20
20
|
import { runLoop } from '../lib/run-loop.js';
|
|
21
21
|
import { buildRunExport } from '../lib/export.js';
|
|
22
22
|
import { buildGovernanceReport, formatGovernanceReportMarkdown } from '../lib/report.js';
|
|
23
|
+
import { validateParentRun } from '../lib/run-history.js';
|
|
23
24
|
import { dispatchApiProxy } from '../lib/adapters/api-proxy-adapter.js';
|
|
24
25
|
import {
|
|
25
26
|
dispatchLocalCli,
|
|
@@ -56,6 +57,30 @@ export async function runCommand(opts) {
|
|
|
56
57
|
process.exit(1);
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
// ── Provenance flag validation ──────────────────────────────────────────
|
|
61
|
+
const continueFrom = opts.continueFrom;
|
|
62
|
+
const recoverFrom = opts.recoverFrom;
|
|
63
|
+
|
|
64
|
+
if (continueFrom && recoverFrom) {
|
|
65
|
+
console.log(chalk.red('Cannot specify both --continue-from and --recover-from'));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let provenance = undefined;
|
|
70
|
+
if (continueFrom || recoverFrom) {
|
|
71
|
+
const parentId = continueFrom || recoverFrom;
|
|
72
|
+
const validation = validateParentRun(root, parentId);
|
|
73
|
+
if (!validation.ok) {
|
|
74
|
+
console.log(chalk.red(validation.error));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
provenance = {
|
|
78
|
+
trigger: continueFrom ? 'continuation' : 'recovery',
|
|
79
|
+
parent_run_id: parentId,
|
|
80
|
+
created_by: 'operator',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
const maxTurns = opts.maxTurns || 50;
|
|
60
85
|
const autoApprove = !!opts.autoApprove;
|
|
61
86
|
const verbose = !!opts.verbose;
|
|
@@ -319,7 +344,13 @@ export async function runCommand(opts) {
|
|
|
319
344
|
};
|
|
320
345
|
|
|
321
346
|
// ── Execute ─────────────────────────────────────────────────────────────
|
|
322
|
-
const
|
|
347
|
+
const runLoopOpts = {
|
|
348
|
+
maxTurns,
|
|
349
|
+
startNewRunFromCompleted: true,
|
|
350
|
+
startNewRunFromBlocked: Boolean(provenance),
|
|
351
|
+
};
|
|
352
|
+
if (provenance) runLoopOpts.provenance = provenance;
|
|
353
|
+
const result = await runLoop(root, config, callbacks, runLoopOpts);
|
|
323
354
|
|
|
324
355
|
// ── Summary ─────────────────────────────────────────────────────────────
|
|
325
356
|
console.log('');
|
package/src/commands/status.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getContinuityStatus } from '../lib/continuity-status.js';
|
|
|
6
6
|
import { getConnectorHealth } from '../lib/connector-health.js';
|
|
7
7
|
import { deriveWorkflowKitArtifacts } from '../lib/workflow-kit-artifacts.js';
|
|
8
8
|
import { evaluateTimeouts } from '../lib/timeout-evaluator.js';
|
|
9
|
+
import { summarizeRunProvenance } from '../lib/run-provenance.js';
|
|
9
10
|
|
|
10
11
|
export async function statusCommand(opts) {
|
|
11
12
|
const context = loadProjectContext();
|
|
@@ -90,6 +91,7 @@ function renderGovernedStatus(context, opts) {
|
|
|
90
91
|
template: config.template || 'generic',
|
|
91
92
|
config,
|
|
92
93
|
state,
|
|
94
|
+
provenance: state?.provenance || null,
|
|
93
95
|
continuity,
|
|
94
96
|
connector_health: connectorHealth,
|
|
95
97
|
workflow_kit_artifacts: workflowKitArtifacts,
|
|
@@ -107,6 +109,10 @@ function renderGovernedStatus(context, opts) {
|
|
|
107
109
|
console.log(` ${chalk.dim('Template:')} ${config.template || 'generic'}`);
|
|
108
110
|
console.log(` ${chalk.dim('Phase:')} ${state?.phase ? formatGovernedPhase(state.phase) : chalk.dim('unknown')}`);
|
|
109
111
|
console.log(` ${chalk.dim('Run:')} ${formatRunStatus(state?.status)}`);
|
|
112
|
+
const provenanceSummary = summarizeRunProvenance(state?.provenance);
|
|
113
|
+
if (provenanceSummary) {
|
|
114
|
+
console.log(` ${chalk.dim('Origin:')} ${chalk.magenta(provenanceSummary)}`);
|
|
115
|
+
}
|
|
110
116
|
if (state?.accepted_integration_ref) {
|
|
111
117
|
console.log(` ${chalk.dim('Accepted:')} ${state.accepted_integration_ref}`);
|
|
112
118
|
}
|
package/src/lib/export.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join, relative, resolve } from 'node:path';
|
|
|
6
6
|
import { loadProjectContext, loadProjectState } from './config.js';
|
|
7
7
|
import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
|
|
8
8
|
import { loadCoordinatorState } from './coordinator-state.js';
|
|
9
|
+
import { normalizeRunProvenance } from './run-provenance.js';
|
|
9
10
|
|
|
10
11
|
const EXPORT_SCHEMA_VERSION = '0.3';
|
|
11
12
|
|
|
@@ -30,6 +31,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
|
30
31
|
'.agentxchain/hook-annotations.jsonl',
|
|
31
32
|
'.agentxchain/notification-audit.jsonl',
|
|
32
33
|
'.agentxchain/run-history.jsonl',
|
|
34
|
+
'.agentxchain/events.jsonl',
|
|
33
35
|
'.agentxchain/dispatch',
|
|
34
36
|
'.agentxchain/staging',
|
|
35
37
|
'.agentxchain/transactions/accept',
|
|
@@ -52,6 +54,7 @@ export const RUN_RESTORE_ROOTS = [
|
|
|
52
54
|
'.agentxchain/hook-annotations.jsonl',
|
|
53
55
|
'.agentxchain/notification-audit.jsonl',
|
|
54
56
|
'.agentxchain/run-history.jsonl',
|
|
57
|
+
'.agentxchain/events.jsonl',
|
|
55
58
|
'.agentxchain/dispatch',
|
|
56
59
|
'.agentxchain/staging',
|
|
57
60
|
'.agentxchain/transactions/accept',
|
|
@@ -296,6 +299,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
296
299
|
run_id: state?.run_id || null,
|
|
297
300
|
status: state?.status || null,
|
|
298
301
|
phase: state?.phase || null,
|
|
302
|
+
provenance: normalizeRunProvenance(state?.provenance),
|
|
299
303
|
active_turn_ids: activeTurns,
|
|
300
304
|
retained_turn_ids: retainedTurns,
|
|
301
305
|
history_entries: countJsonl(files, '.agentxchain/history.jsonl'),
|
|
@@ -40,8 +40,10 @@ import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
|
40
40
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
41
41
|
import { runHooks } from './hook-runner.js';
|
|
42
42
|
import { emitNotifications } from './notification-runner.js';
|
|
43
|
+
import { emitRunEvent } from './run-events.js';
|
|
43
44
|
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
44
45
|
import { recordRunHistory } from './run-history.js';
|
|
46
|
+
import { buildDefaultRunProvenance } from './run-provenance.js';
|
|
45
47
|
|
|
46
48
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
47
49
|
|
|
@@ -61,6 +63,48 @@ function generateId(prefix) {
|
|
|
61
63
|
return `${prefix}_${randomBytes(8).toString('hex')}`;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
function getInitialPhase(config) {
|
|
67
|
+
return Object.keys(config?.routing || {})[0] || 'planning';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildInitialPhaseGateStatus(config) {
|
|
71
|
+
return Object.fromEntries(
|
|
72
|
+
[...new Set(
|
|
73
|
+
Object.values(config?.routing || {})
|
|
74
|
+
.map((route) => route?.exit_gate)
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
)].map((gateId) => [gateId, 'pending'])
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildFreshIdleStateForNewRun(state, config) {
|
|
81
|
+
return {
|
|
82
|
+
schema_version: state?.schema_version || GOVERNED_SCHEMA_VERSION,
|
|
83
|
+
run_id: null,
|
|
84
|
+
project_id: state?.project_id || config?.project?.id || null,
|
|
85
|
+
status: 'idle',
|
|
86
|
+
phase: getInitialPhase(config),
|
|
87
|
+
accepted_integration_ref: null,
|
|
88
|
+
active_turns: {},
|
|
89
|
+
turn_sequence: 0,
|
|
90
|
+
last_completed_turn_id: null,
|
|
91
|
+
blocked_on: null,
|
|
92
|
+
blocked_reason: null,
|
|
93
|
+
escalation: null,
|
|
94
|
+
pending_phase_transition: null,
|
|
95
|
+
pending_run_completion: null,
|
|
96
|
+
queued_phase_transition: null,
|
|
97
|
+
queued_run_completion: null,
|
|
98
|
+
last_gate_failure: null,
|
|
99
|
+
phase_gate_status: buildInitialPhaseGateStatus(config),
|
|
100
|
+
budget_reservations: {},
|
|
101
|
+
budget_status: {
|
|
102
|
+
spent_usd: 0,
|
|
103
|
+
remaining_usd: config?.budget?.per_run_max_usd ?? null,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
64
108
|
function normalizeGateFailure(value) {
|
|
65
109
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
66
110
|
return null;
|
|
@@ -1708,6 +1752,12 @@ export function raiseOperatorEscalation(root, config, details) {
|
|
|
1708
1752
|
detail,
|
|
1709
1753
|
recovery_action: recoveryAction,
|
|
1710
1754
|
}, targetTurn);
|
|
1755
|
+
emitRunEvent(root, 'escalation_raised', {
|
|
1756
|
+
run_id: blocked.state.run_id,
|
|
1757
|
+
phase: blocked.state.phase,
|
|
1758
|
+
status: 'blocked',
|
|
1759
|
+
payload: { source: 'operator', reason },
|
|
1760
|
+
});
|
|
1711
1761
|
|
|
1712
1762
|
return {
|
|
1713
1763
|
ok: true,
|
|
@@ -1751,6 +1801,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
1751
1801
|
resolved_via: details.via || 'unknown',
|
|
1752
1802
|
previous_escalation: state.escalation || null,
|
|
1753
1803
|
}, state.escalation?.from_turn_id ? getActiveTurns(state)[state.escalation.from_turn_id] || getActiveTurn(state) : getActiveTurn(state));
|
|
1804
|
+
emitRunEvent(details.root || root, 'escalation_resolved', {
|
|
1805
|
+
run_id: nextState.run_id,
|
|
1806
|
+
phase: nextState.phase,
|
|
1807
|
+
status: nextState.status,
|
|
1808
|
+
payload: { resolved_via: details.via || 'unknown' },
|
|
1809
|
+
});
|
|
1754
1810
|
}
|
|
1755
1811
|
|
|
1756
1812
|
return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
|
|
@@ -1766,21 +1822,27 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
1766
1822
|
* @param {object} config - normalized config
|
|
1767
1823
|
* @returns {{ ok: boolean, error?: string, state?: object }}
|
|
1768
1824
|
*/
|
|
1769
|
-
export function initializeGovernedRun(root, config) {
|
|
1770
|
-
|
|
1825
|
+
export function initializeGovernedRun(root, config, options = {}) {
|
|
1826
|
+
let state = readState(root);
|
|
1771
1827
|
if (!state) {
|
|
1772
1828
|
return { ok: false, error: 'No governed state.json found' };
|
|
1773
1829
|
}
|
|
1774
|
-
|
|
1830
|
+
const allowTerminalRestart = options.allow_terminal_restart === true
|
|
1831
|
+
&& (state.status === 'completed' || state.status === 'blocked');
|
|
1832
|
+
if (state.status === 'completed' && !allowTerminalRestart) {
|
|
1775
1833
|
return { ok: false, error: 'Cannot initialize run: this run is already completed. Start a new project or reset state.' };
|
|
1776
1834
|
}
|
|
1777
1835
|
const allowBlockedBootstrap = state.status === 'blocked' && state.run_id === null && getActiveTurnCount(state) === 0;
|
|
1778
|
-
if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap) {
|
|
1836
|
+
if (state.status !== 'idle' && state.status !== 'paused' && !allowBlockedBootstrap && !allowTerminalRestart) {
|
|
1779
1837
|
return { ok: false, error: `Cannot initialize run: status is "${state.status}", expected "idle", "paused", or pre-run "blocked"` };
|
|
1780
1838
|
}
|
|
1839
|
+
if (allowTerminalRestart) {
|
|
1840
|
+
state = buildFreshIdleStateForNewRun(state, config);
|
|
1841
|
+
}
|
|
1781
1842
|
|
|
1782
1843
|
const runId = generateId('run');
|
|
1783
1844
|
const now = new Date().toISOString();
|
|
1845
|
+
const provenance = buildDefaultRunProvenance(options.provenance);
|
|
1784
1846
|
const updatedState = {
|
|
1785
1847
|
...state,
|
|
1786
1848
|
run_id: runId,
|
|
@@ -1792,10 +1854,17 @@ export function initializeGovernedRun(root, config) {
|
|
|
1792
1854
|
budget_status: {
|
|
1793
1855
|
spent_usd: 0,
|
|
1794
1856
|
remaining_usd: config.budget?.per_run_max_usd ?? null
|
|
1795
|
-
}
|
|
1857
|
+
},
|
|
1858
|
+
provenance,
|
|
1796
1859
|
};
|
|
1797
1860
|
|
|
1798
1861
|
writeState(root, updatedState);
|
|
1862
|
+
emitRunEvent(root, 'run_started', {
|
|
1863
|
+
run_id: runId,
|
|
1864
|
+
phase: updatedState.phase,
|
|
1865
|
+
status: 'active',
|
|
1866
|
+
payload: { provenance: provenance || {} },
|
|
1867
|
+
});
|
|
1799
1868
|
return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
|
|
1800
1869
|
}
|
|
1801
1870
|
|
|
@@ -2001,6 +2070,13 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2001
2070
|
|
|
2002
2071
|
writeState(root, updatedState);
|
|
2003
2072
|
|
|
2073
|
+
emitRunEvent(root, 'turn_dispatched', {
|
|
2074
|
+
run_id: updatedState.run_id,
|
|
2075
|
+
phase: updatedState.phase,
|
|
2076
|
+
status: updatedState.status,
|
|
2077
|
+
turn: { turn_id: turnId, role_id: roleId },
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2004
2080
|
// Session checkpoint — non-fatal, written after every successful turn assignment
|
|
2005
2081
|
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
2006
2082
|
role: roleId,
|
|
@@ -2977,6 +3053,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2977
3053
|
}
|
|
2978
3054
|
}
|
|
2979
3055
|
|
|
3056
|
+
// Emit turn_accepted event to local log.
|
|
3057
|
+
emitRunEvent(root, 'turn_accepted', {
|
|
3058
|
+
run_id: updatedState.run_id,
|
|
3059
|
+
phase: updatedState.phase,
|
|
3060
|
+
status: updatedState.status,
|
|
3061
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3062
|
+
});
|
|
3063
|
+
|
|
2980
3064
|
if (updatedState.status === 'blocked') {
|
|
2981
3065
|
// DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
|
|
2982
3066
|
// Covers needs_human, budget:exhausted, and any other non-hook blocked states
|
|
@@ -2987,6 +3071,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2987
3071
|
blockedOn: updatedState.blocked_on,
|
|
2988
3072
|
recovery: updatedState.blocked_reason?.recovery || null,
|
|
2989
3073
|
}, currentTurn);
|
|
3074
|
+
emitRunEvent(root, 'run_blocked', {
|
|
3075
|
+
run_id: updatedState.run_id,
|
|
3076
|
+
phase: updatedState.phase,
|
|
3077
|
+
status: 'blocked',
|
|
3078
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3079
|
+
payload: { category: updatedState.blocked_reason?.category || 'needs_human' },
|
|
3080
|
+
});
|
|
2990
3081
|
}
|
|
2991
3082
|
|
|
2992
3083
|
if (updatedState.pending_phase_transition) {
|
|
@@ -2996,6 +3087,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2996
3087
|
gate: updatedState.pending_phase_transition.gate,
|
|
2997
3088
|
requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
|
|
2998
3089
|
}, currentTurn);
|
|
3090
|
+
emitRunEvent(root, 'gate_pending', {
|
|
3091
|
+
run_id: updatedState.run_id,
|
|
3092
|
+
phase: updatedState.phase,
|
|
3093
|
+
status: updatedState.status,
|
|
3094
|
+
payload: {
|
|
3095
|
+
gate_type: 'phase_transition',
|
|
3096
|
+
from: updatedState.pending_phase_transition.from,
|
|
3097
|
+
to: updatedState.pending_phase_transition.to,
|
|
3098
|
+
},
|
|
3099
|
+
});
|
|
2999
3100
|
}
|
|
3000
3101
|
|
|
3001
3102
|
if (updatedState.pending_run_completion) {
|
|
@@ -3004,6 +3105,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3004
3105
|
requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
|
|
3005
3106
|
requested_at: updatedState.pending_run_completion.requested_at,
|
|
3006
3107
|
}, currentTurn);
|
|
3108
|
+
emitRunEvent(root, 'gate_pending', {
|
|
3109
|
+
run_id: updatedState.run_id,
|
|
3110
|
+
phase: updatedState.phase,
|
|
3111
|
+
status: updatedState.status,
|
|
3112
|
+
payload: { gate_type: 'run_completion' },
|
|
3113
|
+
});
|
|
3007
3114
|
}
|
|
3008
3115
|
|
|
3009
3116
|
if (updatedState.status === 'completed') {
|
|
@@ -3012,6 +3119,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3012
3119
|
completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
|
|
3013
3120
|
requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
|
|
3014
3121
|
}, currentTurn);
|
|
3122
|
+
emitRunEvent(root, 'run_completed', {
|
|
3123
|
+
run_id: updatedState.run_id,
|
|
3124
|
+
phase: updatedState.phase,
|
|
3125
|
+
status: 'completed',
|
|
3126
|
+
payload: { completed_at: updatedState.completed_at || now },
|
|
3127
|
+
});
|
|
3015
3128
|
}
|
|
3016
3129
|
|
|
3017
3130
|
// Session checkpoint — non-fatal, written after every successful acceptance
|
|
@@ -3161,6 +3274,13 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3161
3274
|
};
|
|
3162
3275
|
|
|
3163
3276
|
writeState(root, updatedState);
|
|
3277
|
+
emitRunEvent(root, 'turn_rejected', {
|
|
3278
|
+
run_id: updatedState.run_id,
|
|
3279
|
+
phase: updatedState.phase,
|
|
3280
|
+
status: updatedState.status,
|
|
3281
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3282
|
+
payload: { attempt: currentAttempt, retrying: true },
|
|
3283
|
+
});
|
|
3164
3284
|
return {
|
|
3165
3285
|
ok: true,
|
|
3166
3286
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -3221,6 +3341,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3221
3341
|
blockedOn: updatedState.blocked_on,
|
|
3222
3342
|
recovery: updatedState.blocked_reason?.recovery || null,
|
|
3223
3343
|
}, updatedState.active_turns[currentTurn.turn_id]);
|
|
3344
|
+
emitRunEvent(root, 'turn_rejected', {
|
|
3345
|
+
run_id: updatedState.run_id,
|
|
3346
|
+
phase: updatedState.phase,
|
|
3347
|
+
status: 'blocked',
|
|
3348
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
3349
|
+
payload: { attempt: currentAttempt, retrying: false, escalated: true },
|
|
3350
|
+
});
|
|
3351
|
+
emitRunEvent(root, 'run_blocked', {
|
|
3352
|
+
run_id: updatedState.run_id,
|
|
3353
|
+
phase: updatedState.phase,
|
|
3354
|
+
status: 'blocked',
|
|
3355
|
+
payload: { category: 'retries_exhausted' },
|
|
3356
|
+
});
|
|
3224
3357
|
|
|
3225
3358
|
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
3226
3359
|
const hooksConfig = config?.hooks || {};
|
|
@@ -3332,6 +3465,18 @@ export function approvePhaseTransition(root, config) {
|
|
|
3332
3465
|
};
|
|
3333
3466
|
|
|
3334
3467
|
writeState(root, updatedState);
|
|
3468
|
+
emitRunEvent(root, 'gate_approved', {
|
|
3469
|
+
run_id: updatedState.run_id,
|
|
3470
|
+
phase: updatedState.phase,
|
|
3471
|
+
status: 'active',
|
|
3472
|
+
payload: { gate_type: 'phase_transition', from: transition.from, to: transition.to },
|
|
3473
|
+
});
|
|
3474
|
+
emitRunEvent(root, 'phase_entered', {
|
|
3475
|
+
run_id: updatedState.run_id,
|
|
3476
|
+
phase: updatedState.phase,
|
|
3477
|
+
status: 'active',
|
|
3478
|
+
payload: { from: transition.from },
|
|
3479
|
+
});
|
|
3335
3480
|
|
|
3336
3481
|
// Session checkpoint — non-fatal
|
|
3337
3482
|
writeSessionCheckpoint(root, updatedState, 'phase_approved');
|
|
@@ -3434,6 +3579,18 @@ export function approveRunCompletion(root, config) {
|
|
|
3434
3579
|
gate: completion.gate,
|
|
3435
3580
|
requested_by_turn: completion.requested_by_turn || null,
|
|
3436
3581
|
}, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
|
|
3582
|
+
emitRunEvent(root, 'gate_approved', {
|
|
3583
|
+
run_id: updatedState.run_id,
|
|
3584
|
+
phase: updatedState.phase,
|
|
3585
|
+
status: 'completed',
|
|
3586
|
+
payload: { gate_type: 'run_completion' },
|
|
3587
|
+
});
|
|
3588
|
+
emitRunEvent(root, 'run_completed', {
|
|
3589
|
+
run_id: updatedState.run_id,
|
|
3590
|
+
phase: updatedState.phase,
|
|
3591
|
+
status: 'completed',
|
|
3592
|
+
payload: { completed_at: updatedState.completed_at },
|
|
3593
|
+
});
|
|
3437
3594
|
|
|
3438
3595
|
// Session checkpoint — non-fatal
|
|
3439
3596
|
writeSessionCheckpoint(root, updatedState, 'run_completed');
|
package/src/lib/repo-observer.js
CHANGED
package/src/lib/report.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { verifyExportArtifact } from './export-verifier.js';
|
|
2
|
+
import { normalizeRunProvenance, summarizeRunProvenance } from './run-provenance.js';
|
|
2
3
|
|
|
3
4
|
export const GOVERNANCE_REPORT_VERSION = '0.1';
|
|
4
5
|
|
|
@@ -639,6 +640,7 @@ function buildRunSubject(artifact) {
|
|
|
639
640
|
phase: artifact.summary?.phase || null,
|
|
640
641
|
blocked_on: artifact.state?.blocked_on || null,
|
|
641
642
|
blocked_reason: artifact.state?.blocked_reason || null,
|
|
643
|
+
provenance: normalizeRunProvenance(artifact.summary?.provenance || artifact.state?.provenance),
|
|
642
644
|
active_turn_count: activeTurns.length,
|
|
643
645
|
retained_turn_count: retainedTurns.length,
|
|
644
646
|
active_turn_ids: activeTurns,
|
|
@@ -899,6 +901,9 @@ export function formatGovernanceReportText(report) {
|
|
|
899
901
|
if (run.duration_seconds != null) {
|
|
900
902
|
lines.push(`Duration: ${run.duration_seconds}s`);
|
|
901
903
|
}
|
|
904
|
+
if (summarizeRunProvenance(run.provenance)) {
|
|
905
|
+
lines.push(`Provenance: ${summarizeRunProvenance(run.provenance)}`);
|
|
906
|
+
}
|
|
902
907
|
|
|
903
908
|
lines.push(
|
|
904
909
|
`History entries: ${artifacts.history_entries}`,
|
|
@@ -1300,6 +1305,9 @@ export function formatGovernanceReportMarkdown(report) {
|
|
|
1300
1305
|
if (run.duration_seconds != null) {
|
|
1301
1306
|
lines.push(`- Duration: \`${run.duration_seconds}s\``);
|
|
1302
1307
|
}
|
|
1308
|
+
if (summarizeRunProvenance(run.provenance)) {
|
|
1309
|
+
lines.push(`- Provenance: \`${summarizeRunProvenance(run.provenance)}\``);
|
|
1310
|
+
}
|
|
1303
1311
|
|
|
1304
1312
|
lines.push(
|
|
1305
1313
|
`- History entries: ${artifacts.history_entries}`,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-events.js — Repo-local structured event log for governed runs.
|
|
3
|
+
*
|
|
4
|
+
* Appends lifecycle events to `.agentxchain/events.jsonl` so operators
|
|
5
|
+
* can observe run progress without webhooks or dashboard.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
export const RUN_EVENTS_PATH = '.agentxchain/events.jsonl';
|
|
13
|
+
|
|
14
|
+
export const VALID_RUN_EVENTS = [
|
|
15
|
+
'run_started',
|
|
16
|
+
'phase_entered',
|
|
17
|
+
'turn_dispatched',
|
|
18
|
+
'turn_accepted',
|
|
19
|
+
'turn_rejected',
|
|
20
|
+
'run_blocked',
|
|
21
|
+
'run_completed',
|
|
22
|
+
'escalation_raised',
|
|
23
|
+
'escalation_resolved',
|
|
24
|
+
'gate_pending',
|
|
25
|
+
'gate_approved',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Emit a structured lifecycle event to the local event log.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} root - Project root directory
|
|
32
|
+
* @param {string} eventType - One of VALID_RUN_EVENTS
|
|
33
|
+
* @param {object} details - Event details
|
|
34
|
+
* @param {string} [details.run_id] - Current run ID
|
|
35
|
+
* @param {string} [details.phase] - Current phase
|
|
36
|
+
* @param {string} [details.status] - Current run status
|
|
37
|
+
* @param {object} [details.turn] - Turn context (turn_id, role_id, etc.)
|
|
38
|
+
* @param {object} [details.payload] - Additional event-specific data
|
|
39
|
+
* @returns {{ ok: boolean, event_id: string }}
|
|
40
|
+
*/
|
|
41
|
+
export function emitRunEvent(root, eventType, details = {}) {
|
|
42
|
+
const event_id = `evt_${randomBytes(8).toString('hex')}`;
|
|
43
|
+
const entry = {
|
|
44
|
+
event_id,
|
|
45
|
+
event_type: eventType,
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
run_id: details.run_id || null,
|
|
48
|
+
phase: details.phase || null,
|
|
49
|
+
status: details.status || null,
|
|
50
|
+
turn: details.turn || null,
|
|
51
|
+
payload: details.payload || {},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const filePath = join(root, RUN_EVENTS_PATH);
|
|
56
|
+
const dir = dirname(filePath);
|
|
57
|
+
if (!existsSync(dir)) {
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
appendFileSync(filePath, `${JSON.stringify(entry)}\n`);
|
|
61
|
+
return { ok: true, event_id };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Best-effort — never interrupt governed operations for event logging.
|
|
64
|
+
if (process.env.AGENTXCHAIN_DEBUG) {
|
|
65
|
+
process.stderr.write(`[run-events] write failed: ${err.message}\n`);
|
|
66
|
+
}
|
|
67
|
+
return { ok: false, event_id };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read events from the local event log.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} root - Project root directory
|
|
75
|
+
* @param {object} [opts] - Filter options
|
|
76
|
+
* @param {string} [opts.type] - Comma-separated event types to include
|
|
77
|
+
* @param {string} [opts.since] - ISO-8601 timestamp; only events after this
|
|
78
|
+
* @param {number} [opts.limit] - Max events to return (from end of file)
|
|
79
|
+
* @returns {object[]}
|
|
80
|
+
*/
|
|
81
|
+
export function readRunEvents(root, opts = {}) {
|
|
82
|
+
const filePath = join(root, RUN_EVENTS_PATH);
|
|
83
|
+
if (!existsSync(filePath)) return [];
|
|
84
|
+
|
|
85
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
86
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
87
|
+
|
|
88
|
+
let events = [];
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
try {
|
|
91
|
+
events.push(JSON.parse(line));
|
|
92
|
+
} catch {
|
|
93
|
+
// Skip malformed lines.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Apply type filter.
|
|
98
|
+
if (opts.type) {
|
|
99
|
+
const types = new Set(opts.type.split(',').map(t => t.trim()));
|
|
100
|
+
events = events.filter(e => types.has(e.event_type));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Apply since filter.
|
|
104
|
+
if (opts.since) {
|
|
105
|
+
const sinceMs = new Date(opts.since).getTime();
|
|
106
|
+
if (!Number.isNaN(sinceMs)) {
|
|
107
|
+
events = events.filter(e => new Date(e.timestamp).getTime() > sinceMs);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Apply limit (from end).
|
|
112
|
+
if (opts.limit && opts.limit > 0 && events.length > opts.limit) {
|
|
113
|
+
events = events.slice(-opts.limit);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return events;
|
|
117
|
+
}
|
package/src/lib/run-history.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
11
11
|
import { join, dirname } from 'path';
|
|
12
|
+
import { normalizeRunProvenance } from './run-provenance.js';
|
|
12
13
|
|
|
13
14
|
const RUN_HISTORY_PATH = '.agentxchain/run-history.jsonl';
|
|
14
15
|
const HISTORY_PATH = '.agentxchain/history.jsonl';
|
|
@@ -79,6 +80,7 @@ export function recordRunHistory(root, state, config, status) {
|
|
|
79
80
|
gate_results: state?.phase_gate_status || {},
|
|
80
81
|
connector_used: connectorUsed,
|
|
81
82
|
model_used: modelUsed,
|
|
83
|
+
provenance: normalizeRunProvenance(state?.provenance),
|
|
82
84
|
recorded_at: new Date().toISOString(),
|
|
83
85
|
};
|
|
84
86
|
|
|
@@ -132,6 +134,115 @@ export function queryRunHistory(root, opts = {}) {
|
|
|
132
134
|
return entries;
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Walk run lineage backwards from a given run_id via parent_run_id links.
|
|
139
|
+
*
|
|
140
|
+
* Returns an ordered array (oldest ancestor first) of history entries
|
|
141
|
+
* forming the lineage chain. If a parent_run_id references a run not
|
|
142
|
+
* found in history, the chain terminates with a broken_link sentinel.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} root - project root directory
|
|
145
|
+
* @param {string} runId - the run to trace lineage for
|
|
146
|
+
* @returns {{ ok: boolean, chain?: Array<object>, error?: string }}
|
|
147
|
+
*/
|
|
148
|
+
export function queryRunLineage(root, runId) {
|
|
149
|
+
const filePath = join(root, RUN_HISTORY_PATH);
|
|
150
|
+
if (!existsSync(filePath)) {
|
|
151
|
+
return { ok: false, error: 'No run history found. Run at least one governed run first.' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let content;
|
|
155
|
+
try {
|
|
156
|
+
content = readFileSync(filePath, 'utf8').trim();
|
|
157
|
+
} catch {
|
|
158
|
+
return { ok: false, error: 'Failed to read run history file.' };
|
|
159
|
+
}
|
|
160
|
+
if (!content) {
|
|
161
|
+
return { ok: false, error: 'No run history found. Run at least one governed run first.' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const entries = content
|
|
165
|
+
.split('\n')
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
168
|
+
.filter(Boolean);
|
|
169
|
+
|
|
170
|
+
// Build lookup by run_id
|
|
171
|
+
const byId = new Map();
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (entry.run_id) byId.set(entry.run_id, entry);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Find the target entry
|
|
177
|
+
const target = byId.get(runId);
|
|
178
|
+
if (!target) {
|
|
179
|
+
return { ok: false, error: `Run ${runId} not found in run history.` };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Walk backwards collecting ancestors
|
|
183
|
+
const chain = [target];
|
|
184
|
+
let current = target;
|
|
185
|
+
const visited = new Set([runId]);
|
|
186
|
+
|
|
187
|
+
while (current.provenance?.parent_run_id) {
|
|
188
|
+
const parentId = current.provenance.parent_run_id;
|
|
189
|
+
if (visited.has(parentId)) break; // safety: prevent cycles
|
|
190
|
+
visited.add(parentId);
|
|
191
|
+
|
|
192
|
+
const parent = byId.get(parentId);
|
|
193
|
+
if (!parent) {
|
|
194
|
+
chain.unshift({ broken_link: true, missing_run_id: parentId });
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
chain.unshift(parent);
|
|
198
|
+
current = parent;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { ok: true, chain };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validate that a run_id exists in history and is in a terminal state.
|
|
206
|
+
* Used by --continue-from and --recover-from flag validation.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} root - project root directory
|
|
209
|
+
* @param {string} runId - the run_id to validate
|
|
210
|
+
* @returns {{ ok: boolean, entry?: object, error?: string }}
|
|
211
|
+
*/
|
|
212
|
+
export function validateParentRun(root, runId) {
|
|
213
|
+
const filePath = join(root, RUN_HISTORY_PATH);
|
|
214
|
+
if (!existsSync(filePath)) {
|
|
215
|
+
return { ok: false, error: `Run ${runId} not found in run history` };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let content;
|
|
219
|
+
try {
|
|
220
|
+
content = readFileSync(filePath, 'utf8').trim();
|
|
221
|
+
} catch {
|
|
222
|
+
return { ok: false, error: `Run ${runId} not found in run history` };
|
|
223
|
+
}
|
|
224
|
+
if (!content) {
|
|
225
|
+
return { ok: false, error: `Run ${runId} not found in run history` };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const entries = content
|
|
229
|
+
.split('\n')
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
232
|
+
.filter(Boolean);
|
|
233
|
+
|
|
234
|
+
const entry = entries.find(e => e.run_id === runId);
|
|
235
|
+
if (!entry) {
|
|
236
|
+
return { ok: false, error: `Run ${runId} not found in run history` };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!WRITABLE_TERMINAL_STATUSES.has(entry.status)) {
|
|
240
|
+
return { ok: false, error: `Run ${runId} is still active (status: ${entry.status}). Cannot chain from a non-terminal run.` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { ok: true, entry };
|
|
244
|
+
}
|
|
245
|
+
|
|
135
246
|
/**
|
|
136
247
|
* Get the path to the run-history file.
|
|
137
248
|
*/
|
package/src/lib/run-loop.js
CHANGED
|
@@ -38,7 +38,7 @@ const DEFAULT_MAX_TURNS = 50;
|
|
|
38
38
|
* @param {string} root - project root directory
|
|
39
39
|
* @param {object} config - normalized governed config
|
|
40
40
|
* @param {object} callbacks - { selectRole, dispatch, approveGate, onEvent? }
|
|
41
|
-
* @param {object} [options] - { maxTurns?: number }
|
|
41
|
+
* @param {object} [options] - { maxTurns?: number, provenance?: object, startNewRunFromCompleted?: boolean, startNewRunFromBlocked?: boolean }
|
|
42
42
|
* @returns {Promise<RunLoopResult>}
|
|
43
43
|
*/
|
|
44
44
|
export async function runLoop(root, config, callbacks, options = {}) {
|
|
@@ -58,8 +58,14 @@ export async function runLoop(root, config, callbacks, options = {}) {
|
|
|
58
58
|
|
|
59
59
|
// ── Initialize if idle ──────────────────────────────────────────────────
|
|
60
60
|
let state = loadState(root, config);
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const shouldRestartCompleted = state?.status === 'completed' && options.startNewRunFromCompleted === true;
|
|
62
|
+
const shouldRestartBlocked = state?.status === 'blocked' && options.startNewRunFromBlocked === true;
|
|
63
|
+
if (!state || state.status === 'idle' || shouldRestartCompleted || shouldRestartBlocked) {
|
|
64
|
+
const initOpts = options.provenance ? { provenance: options.provenance } : {};
|
|
65
|
+
if (shouldRestartCompleted || shouldRestartBlocked) {
|
|
66
|
+
initOpts.allow_terminal_restart = true;
|
|
67
|
+
}
|
|
68
|
+
const initResult = initRun(root, config, initOpts);
|
|
63
69
|
if (!initResult.ok) {
|
|
64
70
|
return makeResult(false, 'init_failed', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, [initResult.error]);
|
|
65
71
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const VALID_TRIGGERS = new Set([
|
|
2
|
+
'manual',
|
|
3
|
+
'continuation',
|
|
4
|
+
'recovery',
|
|
5
|
+
'intake',
|
|
6
|
+
'schedule',
|
|
7
|
+
'coordinator',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const VALID_CREATORS = new Set([
|
|
11
|
+
'operator',
|
|
12
|
+
'coordinator',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function normalizeString(value) {
|
|
16
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeRunProvenance(value, { fallbackManual = false } = {}) {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
21
|
+
return fallbackManual ? buildDefaultRunProvenance() : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const trigger = normalizeString(value.trigger);
|
|
25
|
+
const createdBy = normalizeString(value.created_by);
|
|
26
|
+
const normalized = {
|
|
27
|
+
trigger: VALID_TRIGGERS.has(trigger) ? trigger : (fallbackManual ? 'manual' : null),
|
|
28
|
+
parent_run_id: normalizeString(value.parent_run_id),
|
|
29
|
+
trigger_reason: normalizeString(value.trigger_reason),
|
|
30
|
+
intake_intent_id: normalizeString(value.intake_intent_id),
|
|
31
|
+
created_by: VALID_CREATORS.has(createdBy) ? createdBy : 'operator',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (!normalized.trigger) {
|
|
35
|
+
return fallbackManual ? buildDefaultRunProvenance() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildDefaultRunProvenance(overrides = {}) {
|
|
42
|
+
return normalizeRunProvenance({
|
|
43
|
+
trigger: 'manual',
|
|
44
|
+
parent_run_id: null,
|
|
45
|
+
trigger_reason: null,
|
|
46
|
+
intake_intent_id: null,
|
|
47
|
+
created_by: 'operator',
|
|
48
|
+
...overrides,
|
|
49
|
+
}, { fallbackManual: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getRunTriggerLabel(provenance) {
|
|
53
|
+
const normalized = normalizeRunProvenance(provenance);
|
|
54
|
+
return normalized?.trigger || 'legacy';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function summarizeRunProvenance(provenance) {
|
|
58
|
+
const normalized = normalizeRunProvenance(provenance);
|
|
59
|
+
if (!normalized) return null;
|
|
60
|
+
|
|
61
|
+
const details = [];
|
|
62
|
+
if (normalized.parent_run_id) {
|
|
63
|
+
details.push(`from ${normalized.parent_run_id}`);
|
|
64
|
+
}
|
|
65
|
+
if (normalized.intake_intent_id) {
|
|
66
|
+
details.push(`intent ${normalized.intake_intent_id}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const base = details.length > 0
|
|
70
|
+
? `${normalized.trigger} ${details.join(' ')}`
|
|
71
|
+
: normalized.trigger;
|
|
72
|
+
const creatorSuffix = normalized.created_by === 'coordinator'
|
|
73
|
+
? ' (created by coordinator)'
|
|
74
|
+
: '';
|
|
75
|
+
const reasonSuffix = normalized.trigger_reason
|
|
76
|
+
? ` ("${normalized.trigger_reason}")`
|
|
77
|
+
: '';
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
normalized.trigger === 'manual'
|
|
81
|
+
&& !normalized.parent_run_id
|
|
82
|
+
&& !normalized.intake_intent_id
|
|
83
|
+
&& !normalized.trigger_reason
|
|
84
|
+
&& normalized.created_by !== 'coordinator'
|
|
85
|
+
) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `${base}${creatorSuffix}${reasonSuffix}`;
|
|
90
|
+
}
|