agentxchain 2.47.0 → 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 +12 -0
- package/package.json +1 -1
- package/src/commands/events.js +150 -0
- package/src/lib/export.js +2 -0
- package/src/lib/governed-state.js +107 -0
- package/src/lib/repo-observer.js +2 -0
- package/src/lib/run-events.js +117 -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'));
|
|
@@ -267,6 +268,17 @@ program
|
|
|
267
268
|
.option('-d, --dir <path>', 'Project directory')
|
|
268
269
|
.action(historyCommand);
|
|
269
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
|
+
|
|
270
282
|
program
|
|
271
283
|
.command('validate')
|
|
272
284
|
.description('Validate project protocol artifacts')
|
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/lib/export.js
CHANGED
|
@@ -31,6 +31,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
|
31
31
|
'.agentxchain/hook-annotations.jsonl',
|
|
32
32
|
'.agentxchain/notification-audit.jsonl',
|
|
33
33
|
'.agentxchain/run-history.jsonl',
|
|
34
|
+
'.agentxchain/events.jsonl',
|
|
34
35
|
'.agentxchain/dispatch',
|
|
35
36
|
'.agentxchain/staging',
|
|
36
37
|
'.agentxchain/transactions/accept',
|
|
@@ -53,6 +54,7 @@ export const RUN_RESTORE_ROOTS = [
|
|
|
53
54
|
'.agentxchain/hook-annotations.jsonl',
|
|
54
55
|
'.agentxchain/notification-audit.jsonl',
|
|
55
56
|
'.agentxchain/run-history.jsonl',
|
|
57
|
+
'.agentxchain/events.jsonl',
|
|
56
58
|
'.agentxchain/dispatch',
|
|
57
59
|
'.agentxchain/staging',
|
|
58
60
|
'.agentxchain/transactions/accept',
|
|
@@ -40,6 +40,7 @@ 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';
|
|
45
46
|
import { buildDefaultRunProvenance } from './run-provenance.js';
|
|
@@ -1751,6 +1752,12 @@ export function raiseOperatorEscalation(root, config, details) {
|
|
|
1751
1752
|
detail,
|
|
1752
1753
|
recovery_action: recoveryAction,
|
|
1753
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
|
+
});
|
|
1754
1761
|
|
|
1755
1762
|
return {
|
|
1756
1763
|
ok: true,
|
|
@@ -1794,6 +1801,12 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
1794
1801
|
resolved_via: details.via || 'unknown',
|
|
1795
1802
|
previous_escalation: state.escalation || null,
|
|
1796
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
|
+
});
|
|
1797
1810
|
}
|
|
1798
1811
|
|
|
1799
1812
|
return { ok: true, state: attachLegacyCurrentTurnAlias(nextState) };
|
|
@@ -1846,6 +1859,12 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
1846
1859
|
};
|
|
1847
1860
|
|
|
1848
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
|
+
});
|
|
1849
1868
|
return { ok: true, state: attachLegacyCurrentTurnAlias(updatedState) };
|
|
1850
1869
|
}
|
|
1851
1870
|
|
|
@@ -2051,6 +2070,13 @@ export function assignGovernedTurn(root, config, roleId) {
|
|
|
2051
2070
|
|
|
2052
2071
|
writeState(root, updatedState);
|
|
2053
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
|
+
|
|
2054
2080
|
// Session checkpoint — non-fatal, written after every successful turn assignment
|
|
2055
2081
|
writeSessionCheckpoint(root, updatedState, 'turn_assigned', {
|
|
2056
2082
|
role: roleId,
|
|
@@ -3027,6 +3053,14 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3027
3053
|
}
|
|
3028
3054
|
}
|
|
3029
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
|
+
|
|
3030
3064
|
if (updatedState.status === 'blocked') {
|
|
3031
3065
|
// DEC-RHTR-SPEC: Record blocked outcome in cross-run history (non-fatal)
|
|
3032
3066
|
// Covers needs_human, budget:exhausted, and any other non-hook blocked states
|
|
@@ -3037,6 +3071,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3037
3071
|
blockedOn: updatedState.blocked_on,
|
|
3038
3072
|
recovery: updatedState.blocked_reason?.recovery || null,
|
|
3039
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
|
+
});
|
|
3040
3081
|
}
|
|
3041
3082
|
|
|
3042
3083
|
if (updatedState.pending_phase_transition) {
|
|
@@ -3046,6 +3087,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3046
3087
|
gate: updatedState.pending_phase_transition.gate,
|
|
3047
3088
|
requested_by_turn: updatedState.pending_phase_transition.requested_by_turn,
|
|
3048
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
|
+
});
|
|
3049
3100
|
}
|
|
3050
3101
|
|
|
3051
3102
|
if (updatedState.pending_run_completion) {
|
|
@@ -3054,6 +3105,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3054
3105
|
requested_by_turn: updatedState.pending_run_completion.requested_by_turn,
|
|
3055
3106
|
requested_at: updatedState.pending_run_completion.requested_at,
|
|
3056
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
|
+
});
|
|
3057
3114
|
}
|
|
3058
3115
|
|
|
3059
3116
|
if (updatedState.status === 'completed') {
|
|
@@ -3062,6 +3119,12 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3062
3119
|
completed_via: completionResult?.action === 'complete' ? 'accept_turn' : 'accept_turn_direct',
|
|
3063
3120
|
requested_by_turn: completionResult?.requested_by_turn || turnResult.turn_id,
|
|
3064
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
|
+
});
|
|
3065
3128
|
}
|
|
3066
3129
|
|
|
3067
3130
|
// Session checkpoint — non-fatal, written after every successful acceptance
|
|
@@ -3211,6 +3274,13 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3211
3274
|
};
|
|
3212
3275
|
|
|
3213
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
|
+
});
|
|
3214
3284
|
return {
|
|
3215
3285
|
ok: true,
|
|
3216
3286
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -3271,6 +3341,19 @@ export function rejectGovernedTurn(root, config, validationResult, reasonOrOptio
|
|
|
3271
3341
|
blockedOn: updatedState.blocked_on,
|
|
3272
3342
|
recovery: updatedState.blocked_reason?.recovery || null,
|
|
3273
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
|
+
});
|
|
3274
3357
|
|
|
3275
3358
|
// Fire on_escalation hooks (advisory-only) after blocked state is persisted.
|
|
3276
3359
|
const hooksConfig = config?.hooks || {};
|
|
@@ -3382,6 +3465,18 @@ export function approvePhaseTransition(root, config) {
|
|
|
3382
3465
|
};
|
|
3383
3466
|
|
|
3384
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
|
+
});
|
|
3385
3480
|
|
|
3386
3481
|
// Session checkpoint — non-fatal
|
|
3387
3482
|
writeSessionCheckpoint(root, updatedState, 'phase_approved');
|
|
@@ -3484,6 +3579,18 @@ export function approveRunCompletion(root, config) {
|
|
|
3484
3579
|
gate: completion.gate,
|
|
3485
3580
|
requested_by_turn: completion.requested_by_turn || null,
|
|
3486
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
|
+
});
|
|
3487
3594
|
|
|
3488
3595
|
// Session checkpoint — non-fatal
|
|
3489
3596
|
writeSessionCheckpoint(root, updatedState, 'run_completed');
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -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
|
+
}
|