agentxchain 2.48.0 → 2.50.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 +36 -0
- package/package.json +1 -1
- package/scripts/sync-homebrew.sh +26 -3
- package/src/commands/init.js +20 -0
- package/src/commands/run.js +97 -69
- package/src/commands/schedule.js +358 -0
- package/src/lib/export.js +4 -0
- package/src/lib/normalized-config.js +80 -0
- package/src/lib/repo-observer.js +2 -0
- package/src/lib/run-schedule.js +227 -0
package/bin/agentxchain.js
CHANGED
|
@@ -107,6 +107,7 @@ 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
109
|
import { eventsCommand } from '../src/commands/events.js';
|
|
110
|
+
import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
|
|
110
111
|
|
|
111
112
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
112
113
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -258,6 +259,41 @@ program
|
|
|
258
259
|
.option('-v, --verbose', 'Show stack traces on failure')
|
|
259
260
|
.action(demoCommand);
|
|
260
261
|
|
|
262
|
+
const scheduleCmd = program
|
|
263
|
+
.command('schedule')
|
|
264
|
+
.description('Run governed schedules for repo-local lights-out execution');
|
|
265
|
+
|
|
266
|
+
scheduleCmd
|
|
267
|
+
.command('list')
|
|
268
|
+
.description('List configured governed schedules and due status')
|
|
269
|
+
.option('--schedule <id>', 'Show a single schedule')
|
|
270
|
+
.option('--at <iso8601>', 'Evaluate due status at a fixed time')
|
|
271
|
+
.option('-j, --json', 'Output as JSON')
|
|
272
|
+
.action(scheduleListCommand);
|
|
273
|
+
|
|
274
|
+
scheduleCmd
|
|
275
|
+
.command('run-due')
|
|
276
|
+
.description('Execute every due governed schedule once')
|
|
277
|
+
.option('--schedule <id>', 'Run one configured schedule only')
|
|
278
|
+
.option('--at <iso8601>', 'Evaluate due status at a fixed time')
|
|
279
|
+
.option('-j, --json', 'Output as JSON')
|
|
280
|
+
.action(scheduleRunDueCommand);
|
|
281
|
+
|
|
282
|
+
scheduleCmd
|
|
283
|
+
.command('daemon')
|
|
284
|
+
.description('Poll for due governed schedules and run them locally')
|
|
285
|
+
.option('--schedule <id>', 'Run one configured schedule only')
|
|
286
|
+
.option('--poll-seconds <n>', 'Polling interval in seconds', '60')
|
|
287
|
+
.option('--max-cycles <n>', 'Stop after N cycles (test helper)')
|
|
288
|
+
.option('-j, --json', 'Output as JSON')
|
|
289
|
+
.action(scheduleDaemonCommand);
|
|
290
|
+
|
|
291
|
+
scheduleCmd
|
|
292
|
+
.command('status')
|
|
293
|
+
.description('Show daemon health: running, stale, not_running, or never_started')
|
|
294
|
+
.option('-j, --json', 'Output as JSON')
|
|
295
|
+
.action(scheduleStatusCommand);
|
|
296
|
+
|
|
261
297
|
program
|
|
262
298
|
.command('history')
|
|
263
299
|
.description('Show cross-run history of governed runs in this project')
|
package/package.json
CHANGED
package/scripts/sync-homebrew.sh
CHANGED
|
@@ -27,6 +27,18 @@ formula_sha() {
|
|
|
27
27
|
grep -E '^\s*sha256\s+"' "$formula_path" | sed 's/.*sha256 *"\([a-f0-9]*\)".*/\1/' || true
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
canonical_tap_matches_target() {
|
|
31
|
+
local formula_path="$1"
|
|
32
|
+
local expected_url="$2"
|
|
33
|
+
local expected_sha="$3"
|
|
34
|
+
[[ -f "$formula_path" ]] || return 1
|
|
35
|
+
local remote_url
|
|
36
|
+
local remote_sha
|
|
37
|
+
remote_url="$(formula_url "$formula_path")"
|
|
38
|
+
remote_sha="$(formula_sha "$formula_path")"
|
|
39
|
+
[[ "$remote_url" == "$expected_url" && "$remote_sha" == "$expected_sha" ]]
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
usage() {
|
|
31
43
|
echo "Usage: bash scripts/sync-homebrew.sh --target-version <semver> [--push-tap] [--dry-run]" >&2
|
|
32
44
|
}
|
|
@@ -207,10 +219,21 @@ if $PUSH_TAP; then
|
|
|
207
219
|
git add Formula/agentxchain.rb
|
|
208
220
|
git commit -m "agentxchain ${TARGET_VERSION}"
|
|
209
221
|
if ! git push origin HEAD:main; then
|
|
210
|
-
echo "
|
|
211
|
-
|
|
222
|
+
echo " Push rejected by ${CANONICAL_TAP_REPO}; verifying remote state..."
|
|
223
|
+
git fetch origin main >/dev/null 2>&1 || true
|
|
224
|
+
REMOTE_FORMULA="$(mktemp "${TMPDIR:-/tmp}/homebrew-tap-remote-formula.XXXXXX")"
|
|
225
|
+
if git show origin/main:Formula/agentxchain.rb >"$REMOTE_FORMULA" 2>/dev/null \
|
|
226
|
+
&& canonical_tap_matches_target "$REMOTE_FORMULA" "$TARBALL_URL" "$TARBALL_SHA"; then
|
|
227
|
+
rm -f "$REMOTE_FORMULA"
|
|
228
|
+
echo " Canonical tap already matches target after push rejection — treating sync as complete."
|
|
229
|
+
else
|
|
230
|
+
rm -f "$REMOTE_FORMULA"
|
|
231
|
+
echo "FAIL: could not push to ${CANONICAL_TAP_REPO} and remote tap does not match target artifact" >&2
|
|
232
|
+
exit 1
|
|
233
|
+
fi
|
|
234
|
+
else
|
|
235
|
+
echo " Pushed to ${CANONICAL_TAP_REPO}"
|
|
212
236
|
fi
|
|
213
|
-
echo " Pushed to ${CANONICAL_TAP_REPO}"
|
|
214
237
|
fi
|
|
215
238
|
)
|
|
216
239
|
|
package/src/commands/init.js
CHANGED
|
@@ -65,6 +65,20 @@ function appendAcceptanceHints(baseMatrix, acceptanceHints) {
|
|
|
65
65
|
return `${baseMatrix}\n\n## Template Guidance\n${hintLines}\n`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function findGitRoot(startDir) {
|
|
69
|
+
let current = resolve(startDir);
|
|
70
|
+
while (true) {
|
|
71
|
+
if (existsSync(join(current, '.git'))) {
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
const parent = dirname(current);
|
|
75
|
+
if (parent === current) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
current = parent;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
// ── Governed init ───────────────────────────────────────────────────────────
|
|
69
83
|
|
|
70
84
|
const GOVERNED_ROLES = {
|
|
@@ -950,6 +964,12 @@ async function initGoverned(opts) {
|
|
|
950
964
|
if (dir !== process.cwd()) {
|
|
951
965
|
console.log(` ${chalk.bold(`cd ${targetLabel}`)}`);
|
|
952
966
|
}
|
|
967
|
+
if (!findGitRoot(dir)) {
|
|
968
|
+
console.log(` ${chalk.bold('git init')} ${chalk.dim('# initialize the governed repo')}`);
|
|
969
|
+
}
|
|
970
|
+
console.log(` ${chalk.bold('agentxchain template validate')} ${chalk.dim('# prove the scaffold contract before the first turn')}`);
|
|
971
|
+
console.log(` ${chalk.bold('git add -A')} ${chalk.dim('# stage the governed scaffold')}`);
|
|
972
|
+
console.log(` ${chalk.bold('git commit -m "initial governed scaffold"')} ${chalk.dim('# checkpoint the starting state')}`);
|
|
953
973
|
console.log(` ${chalk.bold('agentxchain step')} ${chalk.dim('# run the first governed turn')}`);
|
|
954
974
|
console.log(` ${chalk.bold('agentxchain status')} ${chalk.dim('# inspect phase, gate, and turn state')}`);
|
|
955
975
|
console.log('');
|
package/src/commands/run.js
CHANGED
|
@@ -49,12 +49,18 @@ export async function runCommand(opts) {
|
|
|
49
49
|
process.exit(1);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const execution = await executeGovernedRun(context, opts);
|
|
53
|
+
process.exit(execution.exitCode);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function executeGovernedRun(context, opts = {}) {
|
|
52
57
|
const { root, config, rawConfig } = context;
|
|
58
|
+
const log = opts.log || console.log;
|
|
53
59
|
|
|
54
60
|
if (config.protocol_mode !== 'governed') {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
log(chalk.red('The run command is only available for governed projects.'));
|
|
62
|
+
log(chalk.dim('Legacy projects use: agentxchain start'));
|
|
63
|
+
return { exitCode: 1, result: null };
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
// ── Provenance flag validation ──────────────────────────────────────────
|
|
@@ -62,17 +68,21 @@ export async function runCommand(opts) {
|
|
|
62
68
|
const recoverFrom = opts.recoverFrom;
|
|
63
69
|
|
|
64
70
|
if (continueFrom && recoverFrom) {
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
log(chalk.red('Cannot specify both --continue-from and --recover-from'));
|
|
72
|
+
return { exitCode: 1, result: null };
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
let provenance =
|
|
75
|
+
let provenance = opts.provenance;
|
|
70
76
|
if (continueFrom || recoverFrom) {
|
|
77
|
+
if (provenance) {
|
|
78
|
+
log(chalk.red('Cannot combine internal provenance overrides with --continue-from or --recover-from'));
|
|
79
|
+
return { exitCode: 1, result: null };
|
|
80
|
+
}
|
|
71
81
|
const parentId = continueFrom || recoverFrom;
|
|
72
82
|
const validation = validateParentRun(root, parentId);
|
|
73
83
|
if (!validation.ok) {
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
log(chalk.red(validation.error));
|
|
85
|
+
return { exitCode: 1, result: null };
|
|
76
86
|
}
|
|
77
87
|
provenance = {
|
|
78
88
|
trigger: continueFrom ? 'continuation' : 'recovery',
|
|
@@ -89,21 +99,36 @@ export async function runCommand(opts) {
|
|
|
89
99
|
: null;
|
|
90
100
|
|
|
91
101
|
if (overrideResolution?.error) {
|
|
92
|
-
|
|
102
|
+
log(chalk.red(overrideResolution.error));
|
|
93
103
|
if (overrideResolution.availableRoles.length) {
|
|
94
|
-
|
|
104
|
+
log(chalk.dim(`Available roles: ${overrideResolution.availableRoles.join(', ')}`));
|
|
105
|
+
}
|
|
106
|
+
return { exitCode: 1, result: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (opts.requireFreshStart) {
|
|
110
|
+
const state = loadProjectState(root, config);
|
|
111
|
+
const allowedStatuses = new Set(opts.allowedFreshStatuses || ['idle', 'completed']);
|
|
112
|
+
const currentStatus = state?.status || 'missing';
|
|
113
|
+
if (currentStatus !== 'missing' && !allowedStatuses.has(currentStatus)) {
|
|
114
|
+
return {
|
|
115
|
+
exitCode: 0,
|
|
116
|
+
skipped: true,
|
|
117
|
+
skipReason: `state_${currentStatus}`,
|
|
118
|
+
state,
|
|
119
|
+
result: null,
|
|
120
|
+
};
|
|
95
121
|
}
|
|
96
|
-
process.exit(1);
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
// ── Dry run ───────────────────────────────────────────────────────────────
|
|
100
125
|
if (opts.dryRun) {
|
|
101
126
|
const dryRunState = loadProjectState(root, config);
|
|
102
127
|
const roleId = overrideResolution?.roleId || resolveRole(null, dryRunState, config);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
log(chalk.cyan('Dry run — no execution'));
|
|
129
|
+
log(` First role: ${roleId || chalk.dim('(unresolved)')}`);
|
|
130
|
+
log(` Max turns: ${maxTurns}`);
|
|
131
|
+
log(` Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`);
|
|
107
132
|
const roleIds = Object.keys(config.roles || {});
|
|
108
133
|
for (const rid of roleIds) {
|
|
109
134
|
const role = config.roles[rid];
|
|
@@ -111,9 +136,9 @@ export async function runCommand(opts) {
|
|
|
111
136
|
const rt = config.runtimes?.[rtId];
|
|
112
137
|
const rtType = rt?.type || role.runtime_class || 'manual';
|
|
113
138
|
const supported = rtType !== 'manual';
|
|
114
|
-
|
|
139
|
+
log(` ${supported ? chalk.green('✓') : chalk.red('✗')} ${rid} → ${rtType}${supported ? '' : ' (not supported in run mode)'}`);
|
|
115
140
|
}
|
|
116
|
-
|
|
141
|
+
return { exitCode: 0, result: null };
|
|
117
142
|
}
|
|
118
143
|
|
|
119
144
|
// ── SIGINT handling ─────────────────────────────────────────────────────
|
|
@@ -128,13 +153,13 @@ export async function runCommand(opts) {
|
|
|
128
153
|
}
|
|
129
154
|
aborted = true;
|
|
130
155
|
controller.abort();
|
|
131
|
-
|
|
156
|
+
log(chalk.yellow('\nSIGINT received — finishing current turn, then stopping.'));
|
|
132
157
|
});
|
|
133
158
|
|
|
134
159
|
// ── Run header ──────────────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
log(chalk.cyan.bold('agentxchain run'));
|
|
161
|
+
log(chalk.dim(` Max turns: ${maxTurns} Gate mode: ${autoApprove ? 'auto-approve' : 'interactive'}`));
|
|
162
|
+
log('');
|
|
138
163
|
|
|
139
164
|
// ── Track first-call for --role override ────────────────────────────────
|
|
140
165
|
let firstSelectRole = true;
|
|
@@ -165,7 +190,7 @@ export async function runCommand(opts) {
|
|
|
165
190
|
|
|
166
191
|
// Manual adapter is not supported in run mode
|
|
167
192
|
if (runtimeType === 'manual') {
|
|
168
|
-
|
|
193
|
+
log(chalk.yellow(`Skipping manual role "${roleId}" — use agentxchain step for manual dispatch.`));
|
|
169
194
|
return { accept: false, reason: 'manual adapter is not supported in run mode — use agentxchain step' };
|
|
170
195
|
}
|
|
171
196
|
|
|
@@ -204,7 +229,7 @@ export async function runCommand(opts) {
|
|
|
204
229
|
// ── Route to adapter ──────────────────────────────────────────────
|
|
205
230
|
const adapterOpts = {
|
|
206
231
|
signal: controller.signal,
|
|
207
|
-
onStatus: (msg) =>
|
|
232
|
+
onStatus: (msg) => log(chalk.dim(` ${msg}`)),
|
|
208
233
|
verifyManifest: true,
|
|
209
234
|
};
|
|
210
235
|
|
|
@@ -216,18 +241,18 @@ export async function runCommand(opts) {
|
|
|
216
241
|
let adapterResult;
|
|
217
242
|
|
|
218
243
|
if (runtimeType === 'api_proxy') {
|
|
219
|
-
|
|
244
|
+
log(chalk.dim(` Dispatching to API proxy: ${runtime?.provider || '?'} / ${runtime?.model || '?'}`));
|
|
220
245
|
adapterResult = await dispatchApiProxy(projectRoot, state, cfg, adapterOpts);
|
|
221
246
|
} else if (runtimeType === 'mcp') {
|
|
222
247
|
const transport = resolveMcpTransport(runtime);
|
|
223
|
-
|
|
248
|
+
log(chalk.dim(` Dispatching to MCP ${transport}: ${describeMcpRuntimeTarget(runtime)}`));
|
|
224
249
|
adapterResult = await dispatchMcp(projectRoot, state, cfg, adapterOpts);
|
|
225
250
|
} else if (runtimeType === 'local_cli') {
|
|
226
251
|
const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
|
|
227
|
-
|
|
252
|
+
log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
|
|
228
253
|
adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
|
|
229
254
|
} else if (runtimeType === 'remote_agent') {
|
|
230
|
-
|
|
255
|
+
log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
|
|
231
256
|
adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
|
|
232
257
|
} else {
|
|
233
258
|
return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
|
|
@@ -286,14 +311,14 @@ export async function runCommand(opts) {
|
|
|
286
311
|
|
|
287
312
|
async approveGate(gateType, state) {
|
|
288
313
|
if (autoApprove) {
|
|
289
|
-
|
|
314
|
+
log(chalk.yellow(` Auto-approved ${gateType} gate`));
|
|
290
315
|
return true;
|
|
291
316
|
}
|
|
292
317
|
|
|
293
318
|
// Non-TTY → fail-closed
|
|
294
319
|
if (!process.stdin.isTTY) {
|
|
295
|
-
|
|
296
|
-
|
|
320
|
+
log(chalk.yellow(` Gate pause: ${gateType} — stdin is not a TTY, failing closed.`));
|
|
321
|
+
log(chalk.dim(' Use --auto-approve for non-interactive mode.'));
|
|
297
322
|
return false;
|
|
298
323
|
}
|
|
299
324
|
|
|
@@ -301,9 +326,9 @@ export async function runCommand(opts) {
|
|
|
301
326
|
? state.pending_phase_transition?.target || '(next phase)'
|
|
302
327
|
: 'run completion';
|
|
303
328
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
329
|
+
log('');
|
|
330
|
+
log(chalk.yellow.bold(`Gate pause: ${gateType}`));
|
|
331
|
+
log(chalk.dim(` Phase: ${state.phase} → ${target}`));
|
|
307
332
|
|
|
308
333
|
const answer = await promptUser(` Approve? [y/N] `);
|
|
309
334
|
const approved = /^y(es)?$/i.test(answer.trim());
|
|
@@ -313,31 +338,31 @@ export async function runCommand(opts) {
|
|
|
313
338
|
onEvent(event) {
|
|
314
339
|
switch (event.type) {
|
|
315
340
|
case 'turn_assigned':
|
|
316
|
-
|
|
341
|
+
log(chalk.cyan(`Turn assigned: ${event.turn?.turn_id} → ${event.role}`));
|
|
317
342
|
break;
|
|
318
343
|
case 'turn_accepted':
|
|
319
|
-
|
|
344
|
+
log(chalk.green(`Turn accepted: ${event.turn?.turn_id}`));
|
|
320
345
|
break;
|
|
321
346
|
case 'turn_rejected':
|
|
322
|
-
|
|
347
|
+
log(chalk.yellow(`Turn rejected: ${event.turn?.turn_id} — ${event.reason || 'no reason'}`));
|
|
323
348
|
break;
|
|
324
349
|
case 'gate_paused':
|
|
325
|
-
|
|
350
|
+
log(chalk.yellow(`Gate paused: ${event.gateType}`));
|
|
326
351
|
break;
|
|
327
352
|
case 'gate_approved':
|
|
328
|
-
|
|
353
|
+
log(chalk.green(`Gate approved: ${event.gateType}`));
|
|
329
354
|
break;
|
|
330
355
|
case 'gate_held':
|
|
331
|
-
|
|
356
|
+
log(chalk.yellow(`Gate held: ${event.gateType} — run paused`));
|
|
332
357
|
break;
|
|
333
358
|
case 'blocked':
|
|
334
|
-
|
|
359
|
+
log(chalk.red(`Run blocked`));
|
|
335
360
|
break;
|
|
336
361
|
case 'completed':
|
|
337
|
-
|
|
362
|
+
log(chalk.green.bold('Run completed'));
|
|
338
363
|
break;
|
|
339
364
|
case 'caller_stopped':
|
|
340
|
-
|
|
365
|
+
log(chalk.yellow('Run stopped by caller'));
|
|
341
366
|
break;
|
|
342
367
|
}
|
|
343
368
|
},
|
|
@@ -347,38 +372,38 @@ export async function runCommand(opts) {
|
|
|
347
372
|
const runLoopOpts = {
|
|
348
373
|
maxTurns,
|
|
349
374
|
startNewRunFromCompleted: true,
|
|
350
|
-
startNewRunFromBlocked: Boolean(provenance),
|
|
375
|
+
startNewRunFromBlocked: opts.allowBlockedRestart ?? Boolean(provenance),
|
|
351
376
|
};
|
|
352
377
|
if (provenance) runLoopOpts.provenance = provenance;
|
|
353
378
|
const result = await runLoop(root, config, callbacks, runLoopOpts);
|
|
354
379
|
|
|
355
380
|
// ── Summary ─────────────────────────────────────────────────────────────
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
381
|
+
log('');
|
|
382
|
+
log(chalk.dim('─── Run Summary ───'));
|
|
383
|
+
log(` Status: ${result.ok ? chalk.green('completed') : chalk.yellow(result.stop_reason)}`);
|
|
384
|
+
log(` Turns: ${result.turns_executed}`);
|
|
385
|
+
log(` Gates: ${result.gates_approved} approved`);
|
|
386
|
+
log(` Errors: ${result.errors.length ? chalk.red(result.errors.length) : 'none'}`);
|
|
362
387
|
|
|
363
388
|
if (result.errors.length) {
|
|
364
389
|
for (const err of result.errors) {
|
|
365
|
-
|
|
390
|
+
log(chalk.red(` ${err}`));
|
|
366
391
|
}
|
|
367
392
|
}
|
|
368
393
|
|
|
369
394
|
if (qaMissingCredentialsFallback) {
|
|
370
|
-
printManualQaFallback();
|
|
395
|
+
printManualQaFallback(log);
|
|
371
396
|
}
|
|
372
397
|
|
|
373
398
|
// Recovery guidance for blocked/rejected states
|
|
374
399
|
if (result.state && (result.stop_reason === 'blocked' || result.stop_reason === 'reject_exhausted' || result.stop_reason === 'dispatch_error')) {
|
|
375
400
|
const recovery = deriveRecoveryDescriptor(result.state);
|
|
376
401
|
if (recovery) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
402
|
+
log('');
|
|
403
|
+
log(chalk.yellow(` Recovery: ${recovery.typed_reason}`));
|
|
404
|
+
log(chalk.dim(` Action: ${recovery.recovery_action}`));
|
|
380
405
|
if (recovery.detail) {
|
|
381
|
-
|
|
406
|
+
log(chalk.dim(` Detail: ${recovery.detail}`));
|
|
382
407
|
}
|
|
383
408
|
}
|
|
384
409
|
}
|
|
@@ -399,22 +424,25 @@ export async function runCommand(opts) {
|
|
|
399
424
|
const reportPath = join(reportsDir, `report-${runId}.md`);
|
|
400
425
|
writeFileSync(reportPath, formatGovernanceReportMarkdown(reportResult.report));
|
|
401
426
|
|
|
402
|
-
|
|
403
|
-
|
|
427
|
+
log('');
|
|
428
|
+
log(chalk.dim(` Governance report: .agentxchain/reports/report-${runId}.md`));
|
|
404
429
|
} else {
|
|
405
|
-
|
|
430
|
+
log(chalk.dim(` Governance report skipped: ${exportResult.error}`));
|
|
406
431
|
}
|
|
407
432
|
} catch (err) {
|
|
408
|
-
|
|
433
|
+
log(chalk.dim(` Governance report failed: ${err.message}`));
|
|
409
434
|
}
|
|
410
435
|
}
|
|
411
436
|
|
|
412
437
|
// ── Exit code ───────────────────────────────────────────────────────────
|
|
413
438
|
const successReasons = new Set(['completed', 'gate_held', 'caller_stopped', 'max_turns_reached']);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
439
|
+
return {
|
|
440
|
+
exitCode: result.ok || successReasons.has(result.stop_reason) ? 0 : 1,
|
|
441
|
+
result,
|
|
442
|
+
skipped: false,
|
|
443
|
+
skipReason: null,
|
|
444
|
+
provenance: provenance || null,
|
|
445
|
+
};
|
|
418
446
|
}
|
|
419
447
|
|
|
420
448
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
@@ -452,10 +480,10 @@ function shouldPrintManualQaFallback({ roleId, runtimeId, classified, rawConfig
|
|
|
452
480
|
&& rawConfig?.runtimes?.['manual-qa']?.type === 'manual';
|
|
453
481
|
}
|
|
454
482
|
|
|
455
|
-
function printManualQaFallback() {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
483
|
+
function printManualQaFallback(log = console.log) {
|
|
484
|
+
log('');
|
|
485
|
+
log(chalk.dim(' No-key QA fallback:'));
|
|
486
|
+
log(chalk.dim(' - Edit agentxchain.json and change roles.qa.runtime from "api-qa" to "manual-qa"'));
|
|
487
|
+
log(chalk.dim(' - Then recover the retained QA turn with: agentxchain step --resume'));
|
|
488
|
+
log(chalk.dim(' - Guide: https://agentxchain.dev/docs/getting-started'));
|
|
461
489
|
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadProjectContext } from '../lib/config.js';
|
|
3
|
+
import {
|
|
4
|
+
SCHEDULE_STATE_PATH,
|
|
5
|
+
DAEMON_STATE_PATH,
|
|
6
|
+
listSchedules,
|
|
7
|
+
updateScheduleState,
|
|
8
|
+
evaluateScheduleLaunchEligibility,
|
|
9
|
+
readDaemonState,
|
|
10
|
+
writeDaemonState,
|
|
11
|
+
updateDaemonHeartbeat,
|
|
12
|
+
createDaemonState,
|
|
13
|
+
evaluateDaemonStatus,
|
|
14
|
+
} from '../lib/run-schedule.js';
|
|
15
|
+
import { executeGovernedRun } from './run.js';
|
|
16
|
+
|
|
17
|
+
function loadScheduleContext() {
|
|
18
|
+
const context = loadProjectContext();
|
|
19
|
+
if (!context) {
|
|
20
|
+
console.error(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (context.config.protocol_mode !== 'governed') {
|
|
25
|
+
console.error(chalk.red('The schedule command is only available for governed projects.'));
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return context;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveScheduleEntries(context, scheduleId, at) {
|
|
33
|
+
const entries = listSchedules(context.root, context.config, { at });
|
|
34
|
+
if (!scheduleId) {
|
|
35
|
+
return { ok: true, entries };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const matched = entries.find((entry) => entry.id === scheduleId);
|
|
39
|
+
if (!matched) {
|
|
40
|
+
return { ok: false, error: `Unknown schedule: ${scheduleId}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { ok: true, entries: [matched] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function printScheduleTable(entries) {
|
|
47
|
+
if (entries.length === 0) {
|
|
48
|
+
console.log(chalk.dim('No schedules configured.'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const header = [
|
|
53
|
+
pad('Schedule', 24),
|
|
54
|
+
pad('Enabled', 8),
|
|
55
|
+
pad('Every', 8),
|
|
56
|
+
pad('Due', 6),
|
|
57
|
+
pad('Next Due', 24),
|
|
58
|
+
pad('Last Status', 18),
|
|
59
|
+
pad('Last Run', 14),
|
|
60
|
+
].join(' ');
|
|
61
|
+
console.log(chalk.bold(header));
|
|
62
|
+
console.log(chalk.dim('─'.repeat(header.length)));
|
|
63
|
+
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
console.log([
|
|
66
|
+
pad(entry.id, 24),
|
|
67
|
+
pad(entry.enabled ? 'yes' : 'no', 8),
|
|
68
|
+
pad(`${entry.every_minutes}m`, 8),
|
|
69
|
+
pad(entry.due ? 'yes' : 'no', 6),
|
|
70
|
+
pad(entry.next_due_at || '—', 24),
|
|
71
|
+
pad(entry.last_status || entry.last_skip_reason || '—', 18),
|
|
72
|
+
pad((entry.last_run_id || '—').slice(0, 12), 14),
|
|
73
|
+
].join(' '));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pad(value, width) {
|
|
78
|
+
return String(value || '').padEnd(width);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildScheduleProvenance(entry) {
|
|
82
|
+
return {
|
|
83
|
+
trigger: 'schedule',
|
|
84
|
+
created_by: 'operator',
|
|
85
|
+
trigger_reason: entry.trigger_reason || `schedule:${entry.id}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runDueSchedules(context, opts = {}) {
|
|
90
|
+
const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
|
|
91
|
+
if (!resolved.ok) {
|
|
92
|
+
return { ok: false, exitCode: 1, error: resolved.error, results: [] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const nowIso = opts.at || new Date().toISOString();
|
|
96
|
+
const results = [];
|
|
97
|
+
|
|
98
|
+
for (const entry of resolved.entries) {
|
|
99
|
+
if (!entry.enabled) {
|
|
100
|
+
results.push({ id: entry.id, action: 'disabled' });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!entry.due) {
|
|
104
|
+
results.push({ id: entry.id, action: 'not_due', next_due_at: entry.next_due_at });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const eligibility = evaluateScheduleLaunchEligibility(context.root, context.config);
|
|
109
|
+
if (!eligibility.ok) {
|
|
110
|
+
updateScheduleState(context.root, context.config, entry.id, (record) => ({
|
|
111
|
+
...record,
|
|
112
|
+
last_skip_at: nowIso,
|
|
113
|
+
last_skip_reason: eligibility.reason,
|
|
114
|
+
}));
|
|
115
|
+
results.push({
|
|
116
|
+
id: entry.id,
|
|
117
|
+
action: 'skipped',
|
|
118
|
+
reason: eligibility.reason,
|
|
119
|
+
project_status: eligibility.status,
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!opts.json) {
|
|
125
|
+
console.log(chalk.cyan(`Schedule due: ${entry.id}`));
|
|
126
|
+
}
|
|
127
|
+
const execution = await executeGovernedRun(context, {
|
|
128
|
+
provenance: buildScheduleProvenance(entry),
|
|
129
|
+
maxTurns: entry.max_turns,
|
|
130
|
+
autoApprove: entry.auto_approve,
|
|
131
|
+
role: entry.initial_role || undefined,
|
|
132
|
+
report: true,
|
|
133
|
+
allowBlockedRestart: false,
|
|
134
|
+
requireFreshStart: true,
|
|
135
|
+
allowedFreshStatuses: ['idle', 'completed'],
|
|
136
|
+
log: opts.json ? () => {} : console.log,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (execution.skipped) {
|
|
140
|
+
updateScheduleState(context.root, context.config, entry.id, (record) => ({
|
|
141
|
+
...record,
|
|
142
|
+
last_skip_at: nowIso,
|
|
143
|
+
last_skip_reason: execution.skipReason,
|
|
144
|
+
}));
|
|
145
|
+
results.push({
|
|
146
|
+
id: entry.id,
|
|
147
|
+
action: 'skipped',
|
|
148
|
+
reason: execution.skipReason,
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const runId = execution.result?.state?.run_id || null;
|
|
154
|
+
const startedAt = execution.result?.state?.created_at || nowIso;
|
|
155
|
+
updateScheduleState(context.root, context.config, entry.id, (record) => ({
|
|
156
|
+
...record,
|
|
157
|
+
last_started_at: startedAt,
|
|
158
|
+
last_finished_at: new Date().toISOString(),
|
|
159
|
+
last_run_id: runId,
|
|
160
|
+
last_status: execution.result?.stop_reason || (execution.exitCode === 0 ? 'completed' : 'launch_failed'),
|
|
161
|
+
last_skip_at: null,
|
|
162
|
+
last_skip_reason: null,
|
|
163
|
+
}));
|
|
164
|
+
results.push({
|
|
165
|
+
id: entry.id,
|
|
166
|
+
action: 'ran',
|
|
167
|
+
run_id: runId,
|
|
168
|
+
stop_reason: execution.result?.stop_reason || null,
|
|
169
|
+
exit_code: execution.exitCode,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (execution.exitCode !== 0) {
|
|
173
|
+
return { ok: false, exitCode: execution.exitCode, results };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { ok: true, exitCode: 0, results };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function scheduleListCommand(opts) {
|
|
181
|
+
const context = loadScheduleContext();
|
|
182
|
+
if (!context) return;
|
|
183
|
+
|
|
184
|
+
const resolved = resolveScheduleEntries(context, opts.schedule, opts.at);
|
|
185
|
+
if (!resolved.ok) {
|
|
186
|
+
console.error(chalk.red(resolved.error));
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (opts.json) {
|
|
192
|
+
console.log(JSON.stringify({
|
|
193
|
+
schedules: resolved.entries,
|
|
194
|
+
state_file: SCHEDULE_STATE_PATH,
|
|
195
|
+
}, null, 2));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
printScheduleTable(resolved.entries);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function scheduleRunDueCommand(opts) {
|
|
203
|
+
const context = loadScheduleContext();
|
|
204
|
+
if (!context) return;
|
|
205
|
+
|
|
206
|
+
const result = await runDueSchedules(context, opts);
|
|
207
|
+
if (opts.json) {
|
|
208
|
+
console.log(JSON.stringify(result, null, 2));
|
|
209
|
+
} else if (!result.ok) {
|
|
210
|
+
console.error(chalk.red(result.error || 'Scheduled run failed'));
|
|
211
|
+
} else if (result.results.length === 0) {
|
|
212
|
+
console.log(chalk.dim('No schedules configured.'));
|
|
213
|
+
} else {
|
|
214
|
+
for (const entry of result.results) {
|
|
215
|
+
if (entry.action === 'ran') {
|
|
216
|
+
console.log(chalk.green(`Schedule ran: ${entry.id} (${entry.run_id || 'no run id'})`));
|
|
217
|
+
} else if (entry.action === 'skipped') {
|
|
218
|
+
console.log(chalk.yellow(`Schedule skipped: ${entry.id} (${entry.reason})`));
|
|
219
|
+
} else if (entry.action === 'not_due') {
|
|
220
|
+
console.log(chalk.dim(`Schedule not due: ${entry.id}`));
|
|
221
|
+
} else if (entry.action === 'disabled') {
|
|
222
|
+
console.log(chalk.dim(`Schedule disabled: ${entry.id}`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
process.exitCode = result.exitCode;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function scheduleStatusCommand(opts) {
|
|
231
|
+
const context = loadScheduleContext();
|
|
232
|
+
if (!context) return;
|
|
233
|
+
|
|
234
|
+
const raw = readDaemonState(context.root);
|
|
235
|
+
const evaluation = evaluateDaemonStatus(raw);
|
|
236
|
+
|
|
237
|
+
if (opts.json) {
|
|
238
|
+
const output = {
|
|
239
|
+
ok: evaluation.status === 'running' || evaluation.status === 'never_started',
|
|
240
|
+
state_file: DAEMON_STATE_PATH,
|
|
241
|
+
daemon: {
|
|
242
|
+
status: evaluation.status,
|
|
243
|
+
pid: raw?.pid ?? null,
|
|
244
|
+
started_at: raw?.started_at ?? null,
|
|
245
|
+
last_heartbeat_at: raw?.last_heartbeat_at ?? null,
|
|
246
|
+
last_cycle_result: raw?.last_cycle_result ?? null,
|
|
247
|
+
poll_seconds: raw?.poll_seconds ?? null,
|
|
248
|
+
stale_after_seconds: evaluation.stale_after_seconds ?? null,
|
|
249
|
+
last_error: raw?.last_error ?? null,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
if (evaluation.warning) output.daemon.warning = evaluation.warning;
|
|
253
|
+
console.log(JSON.stringify(output, null, 2));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Human-readable output
|
|
258
|
+
const statusColors = {
|
|
259
|
+
running: chalk.green,
|
|
260
|
+
stale: chalk.yellow,
|
|
261
|
+
not_running: chalk.red,
|
|
262
|
+
never_started: chalk.dim,
|
|
263
|
+
};
|
|
264
|
+
const colorFn = statusColors[evaluation.status] || chalk.white;
|
|
265
|
+
|
|
266
|
+
console.log(chalk.bold('Schedule Daemon Status'));
|
|
267
|
+
console.log(` State: ${colorFn(evaluation.status)}`);
|
|
268
|
+
|
|
269
|
+
if (evaluation.status === 'never_started') {
|
|
270
|
+
console.log(chalk.dim(' No daemon state file found. Run `agentxchain schedule daemon` to start.'));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (evaluation.warning) {
|
|
275
|
+
console.log(chalk.yellow(` Warning: ${evaluation.warning}`));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (raw?.pid != null) {
|
|
279
|
+
console.log(` PID: ${raw.pid}`);
|
|
280
|
+
}
|
|
281
|
+
if (raw?.started_at) {
|
|
282
|
+
console.log(` Started: ${raw.started_at}`);
|
|
283
|
+
}
|
|
284
|
+
if (raw?.last_heartbeat_at) {
|
|
285
|
+
console.log(` Heartbeat: ${raw.last_heartbeat_at}`);
|
|
286
|
+
}
|
|
287
|
+
if (raw?.last_cycle_result) {
|
|
288
|
+
const resultColor = raw.last_cycle_result === 'ok' ? chalk.green : chalk.red;
|
|
289
|
+
console.log(` Last cycle: ${resultColor(raw.last_cycle_result)}`);
|
|
290
|
+
}
|
|
291
|
+
if (raw?.poll_seconds != null) {
|
|
292
|
+
console.log(` Poll: ${raw.poll_seconds}s`);
|
|
293
|
+
}
|
|
294
|
+
if (evaluation.status === 'stale') {
|
|
295
|
+
console.log(chalk.yellow(` ⚠ Heartbeat is ${evaluation.heartbeat_age_seconds}s old (stale after ${evaluation.stale_after_seconds}s)`));
|
|
296
|
+
}
|
|
297
|
+
if (raw?.last_error) {
|
|
298
|
+
console.log(chalk.red(` Last error: ${raw.last_error}`));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function scheduleDaemonCommand(opts) {
|
|
303
|
+
const context = loadScheduleContext();
|
|
304
|
+
if (!context) return;
|
|
305
|
+
|
|
306
|
+
const pollSeconds = Number.parseInt(opts.pollSeconds ?? '60', 10);
|
|
307
|
+
const maxCycles = opts.maxCycles != null ? Number.parseInt(opts.maxCycles, 10) : null;
|
|
308
|
+
if (!Number.isInteger(pollSeconds) || pollSeconds < 1) {
|
|
309
|
+
console.error(chalk.red('--poll-seconds must be an integer >= 1'));
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (maxCycles !== null && (!Number.isInteger(maxCycles) || maxCycles < 1)) {
|
|
314
|
+
console.error(chalk.red('--max-cycles must be an integer >= 1'));
|
|
315
|
+
process.exitCode = 1;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let cycle = 0;
|
|
320
|
+
const daemonState = createDaemonState(process.pid, pollSeconds, opts.schedule || null, maxCycles);
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
writeDaemonState(context.root, daemonState);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
console.error(chalk.red(`Cannot write daemon state: ${err.message}`));
|
|
326
|
+
process.exitCode = 1;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!opts.json) {
|
|
331
|
+
console.log(chalk.bold('AgentXchain Schedule Daemon'));
|
|
332
|
+
console.log(chalk.dim(` Poll: ${pollSeconds}s`));
|
|
333
|
+
console.log(chalk.dim(` State: ${SCHEDULE_STATE_PATH}`));
|
|
334
|
+
console.log(chalk.dim(` Health: ${DAEMON_STATE_PATH}`));
|
|
335
|
+
console.log('');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
while (true) {
|
|
339
|
+
cycle += 1;
|
|
340
|
+
daemonState.last_cycle_started_at = new Date().toISOString();
|
|
341
|
+
const result = await runDueSchedules(context, opts);
|
|
342
|
+
|
|
343
|
+
updateDaemonHeartbeat(context.root, daemonState, result);
|
|
344
|
+
|
|
345
|
+
if (opts.json) {
|
|
346
|
+
console.log(JSON.stringify({ cycle, ...result }));
|
|
347
|
+
}
|
|
348
|
+
if (!result.ok) {
|
|
349
|
+
process.exitCode = result.exitCode;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (maxCycles !== null && cycle >= maxCycles) {
|
|
353
|
+
process.exitCode = 0;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000));
|
|
357
|
+
}
|
|
358
|
+
}
|
package/src/lib/export.js
CHANGED
|
@@ -32,6 +32,8 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
|
32
32
|
'.agentxchain/notification-audit.jsonl',
|
|
33
33
|
'.agentxchain/run-history.jsonl',
|
|
34
34
|
'.agentxchain/events.jsonl',
|
|
35
|
+
'.agentxchain/schedule-state.json',
|
|
36
|
+
'.agentxchain/schedule-daemon.json',
|
|
35
37
|
'.agentxchain/dispatch',
|
|
36
38
|
'.agentxchain/staging',
|
|
37
39
|
'.agentxchain/transactions/accept',
|
|
@@ -55,6 +57,8 @@ export const RUN_RESTORE_ROOTS = [
|
|
|
55
57
|
'.agentxchain/notification-audit.jsonl',
|
|
56
58
|
'.agentxchain/run-history.jsonl',
|
|
57
59
|
'.agentxchain/events.jsonl',
|
|
60
|
+
'.agentxchain/schedule-state.json',
|
|
61
|
+
'.agentxchain/schedule-daemon.json',
|
|
58
62
|
'.agentxchain/dispatch',
|
|
59
63
|
'.agentxchain/staging',
|
|
60
64
|
'.agentxchain/transactions/accept',
|
|
@@ -34,6 +34,7 @@ const DEFAULT_PHASES = ['planning', 'implementation', 'qa'];
|
|
|
34
34
|
export { DEFAULT_PHASES };
|
|
35
35
|
const VALID_PHASE_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
36
36
|
const VALID_SEMANTIC_IDS = ['pm_signoff', 'system_spec', 'implementation_notes', 'acceptance_matrix', 'ship_verdict', 'release_notes', 'section_check'];
|
|
37
|
+
const VALID_SCHEDULE_ID = /^[a-z0-9_-]+$/;
|
|
37
38
|
|
|
38
39
|
const VALID_API_PROXY_RETRY_JITTER = ['none', 'full'];
|
|
39
40
|
const VALID_API_PROXY_RETRY_CLASSES = [
|
|
@@ -508,6 +509,12 @@ export function validateV4Config(data, projectRoot) {
|
|
|
508
509
|
errors.push(...notificationValidation.errors);
|
|
509
510
|
}
|
|
510
511
|
|
|
512
|
+
// Schedules (optional but validated if present)
|
|
513
|
+
if (data.schedules !== undefined) {
|
|
514
|
+
const scheduleValidation = validateSchedulesConfig(data.schedules, data.roles);
|
|
515
|
+
errors.push(...scheduleValidation.errors);
|
|
516
|
+
}
|
|
517
|
+
|
|
511
518
|
// Workflow Kit (optional but validated if present)
|
|
512
519
|
if (data.workflow_kit !== undefined) {
|
|
513
520
|
const wkValidation = validateWorkflowKitConfig(data.workflow_kit, data.routing, data.roles);
|
|
@@ -534,6 +541,57 @@ export function validateV4Config(data, projectRoot) {
|
|
|
534
541
|
return { ok: errors.length === 0, errors };
|
|
535
542
|
}
|
|
536
543
|
|
|
544
|
+
export function validateSchedulesConfig(schedules, roles) {
|
|
545
|
+
const errors = [];
|
|
546
|
+
|
|
547
|
+
if (!schedules || typeof schedules !== 'object' || Array.isArray(schedules)) {
|
|
548
|
+
errors.push('schedules must be an object');
|
|
549
|
+
return { ok: false, errors };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const [scheduleId, schedule] of Object.entries(schedules)) {
|
|
553
|
+
if (!VALID_SCHEDULE_ID.test(scheduleId)) {
|
|
554
|
+
errors.push(`Schedule "${scheduleId}" must use lowercase alphanumeric, underscore, or hyphen characters only`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!schedule || typeof schedule !== 'object' || Array.isArray(schedule)) {
|
|
559
|
+
errors.push(`Schedule "${scheduleId}" must be an object`);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!Number.isInteger(schedule.every_minutes) || schedule.every_minutes < 1) {
|
|
564
|
+
errors.push(`Schedule "${scheduleId}": every_minutes must be an integer >= 1`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if ('enabled' in schedule && typeof schedule.enabled !== 'boolean') {
|
|
568
|
+
errors.push(`Schedule "${scheduleId}": enabled must be a boolean`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if ('auto_approve' in schedule && typeof schedule.auto_approve !== 'boolean') {
|
|
572
|
+
errors.push(`Schedule "${scheduleId}": auto_approve must be a boolean`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if ('max_turns' in schedule && (!Number.isInteger(schedule.max_turns) || schedule.max_turns < 1)) {
|
|
576
|
+
errors.push(`Schedule "${scheduleId}": max_turns must be an integer >= 1`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if ('trigger_reason' in schedule && (typeof schedule.trigger_reason !== 'string' || !schedule.trigger_reason.trim())) {
|
|
580
|
+
errors.push(`Schedule "${scheduleId}": trigger_reason must be a non-empty string when provided`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if ('initial_role' in schedule) {
|
|
584
|
+
if (typeof schedule.initial_role !== 'string' || !schedule.initial_role.trim()) {
|
|
585
|
+
errors.push(`Schedule "${scheduleId}": initial_role must be a non-empty string when provided`);
|
|
586
|
+
} else if (roles && !roles[schedule.initial_role]) {
|
|
587
|
+
errors.push(`Schedule "${scheduleId}": initial_role "${schedule.initial_role}" is not a defined role`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return { ok: errors.length === 0, errors };
|
|
593
|
+
}
|
|
594
|
+
|
|
537
595
|
/**
|
|
538
596
|
* Validate the workflow_kit config section.
|
|
539
597
|
* Returns { ok, errors, warnings }.
|
|
@@ -850,6 +908,7 @@ export function normalizeV3(raw) {
|
|
|
850
908
|
gates: {},
|
|
851
909
|
hooks: {},
|
|
852
910
|
notifications: {},
|
|
911
|
+
schedules: {},
|
|
853
912
|
budget: null,
|
|
854
913
|
policies: [],
|
|
855
914
|
approval_policy: null,
|
|
@@ -917,6 +976,7 @@ export function normalizeV4(raw) {
|
|
|
917
976
|
gates: raw.gates || {},
|
|
918
977
|
hooks: raw.hooks || {},
|
|
919
978
|
notifications: raw.notifications || {},
|
|
979
|
+
schedules: normalizeSchedules(raw.schedules),
|
|
920
980
|
budget: raw.budget || null,
|
|
921
981
|
policies: normalizePolicies(raw.policies),
|
|
922
982
|
approval_policy: raw.approval_policy || null,
|
|
@@ -949,6 +1009,26 @@ export function normalizeV4(raw) {
|
|
|
949
1009
|
};
|
|
950
1010
|
}
|
|
951
1011
|
|
|
1012
|
+
function normalizeSchedules(rawSchedules) {
|
|
1013
|
+
if (!rawSchedules || typeof rawSchedules !== 'object' || Array.isArray(rawSchedules)) {
|
|
1014
|
+
return {};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return Object.fromEntries(
|
|
1018
|
+
Object.entries(rawSchedules).map(([scheduleId, schedule]) => [
|
|
1019
|
+
scheduleId,
|
|
1020
|
+
{
|
|
1021
|
+
enabled: schedule?.enabled !== false,
|
|
1022
|
+
every_minutes: schedule?.every_minutes,
|
|
1023
|
+
auto_approve: schedule?.auto_approve !== false,
|
|
1024
|
+
max_turns: schedule?.max_turns ?? 50,
|
|
1025
|
+
initial_role: schedule?.initial_role || null,
|
|
1026
|
+
trigger_reason: schedule?.trigger_reason?.trim() || `schedule:${scheduleId}`,
|
|
1027
|
+
},
|
|
1028
|
+
]),
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
952
1032
|
/**
|
|
953
1033
|
* Load and normalize a config from raw JSON.
|
|
954
1034
|
* Returns { ok, normalized, errors, version }.
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { safeWriteJson } from './safe-write.js';
|
|
4
|
+
import { loadProjectState } from './config.js';
|
|
5
|
+
|
|
6
|
+
export const SCHEDULE_STATE_PATH = '.agentxchain/schedule-state.json';
|
|
7
|
+
export const DAEMON_STATE_PATH = '.agentxchain/schedule-daemon.json';
|
|
8
|
+
const SCHEDULE_STATE_SCHEMA_VERSION = '0.1';
|
|
9
|
+
const DAEMON_STATE_SCHEMA_VERSION = '0.1';
|
|
10
|
+
|
|
11
|
+
function parseIsoTime(value) {
|
|
12
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
13
|
+
const ts = Date.parse(value);
|
|
14
|
+
return Number.isFinite(ts) ? ts : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toIso(value) {
|
|
18
|
+
return new Date(value).toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeScheduleStateRecord(value) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
23
|
+
return {
|
|
24
|
+
last_started_at: null,
|
|
25
|
+
last_finished_at: null,
|
|
26
|
+
last_run_id: null,
|
|
27
|
+
last_status: null,
|
|
28
|
+
last_skip_at: null,
|
|
29
|
+
last_skip_reason: null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
last_started_at: typeof value.last_started_at === 'string' ? value.last_started_at : null,
|
|
35
|
+
last_finished_at: typeof value.last_finished_at === 'string' ? value.last_finished_at : null,
|
|
36
|
+
last_run_id: typeof value.last_run_id === 'string' ? value.last_run_id : null,
|
|
37
|
+
last_status: typeof value.last_status === 'string' ? value.last_status : null,
|
|
38
|
+
last_skip_at: typeof value.last_skip_at === 'string' ? value.last_skip_at : null,
|
|
39
|
+
last_skip_reason: typeof value.last_skip_reason === 'string' ? value.last_skip_reason : null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeScheduleState(value, config) {
|
|
44
|
+
const schedules = {};
|
|
45
|
+
const configuredIds = Object.keys(config?.schedules || {});
|
|
46
|
+
for (const scheduleId of configuredIds) {
|
|
47
|
+
schedules[scheduleId] = normalizeScheduleStateRecord(value?.schedules?.[scheduleId]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
schema_version: SCHEDULE_STATE_SCHEMA_VERSION,
|
|
52
|
+
schedules,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readScheduleState(root, config) {
|
|
57
|
+
const absPath = join(root, SCHEDULE_STATE_PATH);
|
|
58
|
+
if (!existsSync(absPath)) {
|
|
59
|
+
return normalizeScheduleState(null, config);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(readFileSync(absPath, 'utf8'));
|
|
64
|
+
return normalizeScheduleState(parsed, config);
|
|
65
|
+
} catch {
|
|
66
|
+
return normalizeScheduleState(null, config);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function writeScheduleState(root, state) {
|
|
71
|
+
const absPath = join(root, SCHEDULE_STATE_PATH);
|
|
72
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
73
|
+
safeWriteJson(absPath, state);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function updateScheduleState(root, config, scheduleId, updater) {
|
|
77
|
+
const state = readScheduleState(root, config);
|
|
78
|
+
const current = normalizeScheduleStateRecord(state.schedules[scheduleId]);
|
|
79
|
+
state.schedules[scheduleId] = normalizeScheduleStateRecord(updater(current));
|
|
80
|
+
writeScheduleState(root, state);
|
|
81
|
+
return state.schedules[scheduleId];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function computeScheduleStatus(schedule, record, now = Date.now()) {
|
|
85
|
+
if (schedule.enabled === false) {
|
|
86
|
+
return {
|
|
87
|
+
due: false,
|
|
88
|
+
next_due_at: null,
|
|
89
|
+
due_reason: 'disabled',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lastStartedAt = parseIsoTime(record.last_started_at);
|
|
94
|
+
if (lastStartedAt === null) {
|
|
95
|
+
return {
|
|
96
|
+
due: true,
|
|
97
|
+
next_due_at: toIso(now),
|
|
98
|
+
due_reason: 'never_started',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nextDueTs = lastStartedAt + (schedule.every_minutes * 60 * 1000);
|
|
103
|
+
return {
|
|
104
|
+
due: now >= nextDueTs,
|
|
105
|
+
next_due_at: toIso(nextDueTs),
|
|
106
|
+
due_reason: now >= nextDueTs ? 'interval_elapsed' : 'waiting_interval',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function listSchedules(root, config, { at } = {}) {
|
|
111
|
+
const now = at ? Date.parse(at) : Date.now();
|
|
112
|
+
const scheduleState = readScheduleState(root, config);
|
|
113
|
+
const projectState = loadProjectState(root, config);
|
|
114
|
+
const projectStatus = projectState?.status || 'missing';
|
|
115
|
+
|
|
116
|
+
return Object.entries(config?.schedules || {}).map(([scheduleId, schedule]) => {
|
|
117
|
+
const record = scheduleState.schedules[scheduleId] || normalizeScheduleStateRecord(null);
|
|
118
|
+
const status = computeScheduleStatus(schedule, record, now);
|
|
119
|
+
return {
|
|
120
|
+
id: scheduleId,
|
|
121
|
+
enabled: schedule.enabled !== false,
|
|
122
|
+
every_minutes: schedule.every_minutes,
|
|
123
|
+
auto_approve: schedule.auto_approve !== false,
|
|
124
|
+
max_turns: schedule.max_turns ?? 50,
|
|
125
|
+
initial_role: schedule.initial_role || null,
|
|
126
|
+
trigger_reason: schedule.trigger_reason || `schedule:${scheduleId}`,
|
|
127
|
+
due: status.due,
|
|
128
|
+
due_reason: status.due_reason,
|
|
129
|
+
next_due_at: status.next_due_at,
|
|
130
|
+
project_status: projectStatus,
|
|
131
|
+
last_started_at: record.last_started_at,
|
|
132
|
+
last_finished_at: record.last_finished_at,
|
|
133
|
+
last_run_id: record.last_run_id,
|
|
134
|
+
last_status: record.last_status,
|
|
135
|
+
last_skip_at: record.last_skip_at,
|
|
136
|
+
last_skip_reason: record.last_skip_reason,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function evaluateScheduleLaunchEligibility(root, config) {
|
|
142
|
+
const projectState = loadProjectState(root, config);
|
|
143
|
+
const status = projectState?.status || 'missing';
|
|
144
|
+
|
|
145
|
+
if (status === 'missing' || status === 'idle' || status === 'completed') {
|
|
146
|
+
return { ok: true, status };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (status === 'blocked') {
|
|
150
|
+
return { ok: false, status, reason: 'run_blocked' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (status === 'active') {
|
|
154
|
+
return { ok: false, status, reason: 'run_active' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (status === 'paused') {
|
|
158
|
+
return { ok: false, status, reason: 'run_paused' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { ok: false, status, reason: `run_${status}` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Daemon Health State ─────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export function readDaemonState(root) {
|
|
167
|
+
const absPath = join(root, DAEMON_STATE_PATH);
|
|
168
|
+
if (!existsSync(absPath)) return null;
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(readFileSync(absPath, 'utf8'));
|
|
171
|
+
} catch {
|
|
172
|
+
return { _parse_error: true };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function writeDaemonState(root, state) {
|
|
177
|
+
const absPath = join(root, DAEMON_STATE_PATH);
|
|
178
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
179
|
+
safeWriteJson(absPath, { schema_version: DAEMON_STATE_SCHEMA_VERSION, ...state });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function updateDaemonHeartbeat(root, daemonState, cycleResult) {
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
const updated = {
|
|
185
|
+
...daemonState,
|
|
186
|
+
last_heartbeat_at: now,
|
|
187
|
+
last_cycle_finished_at: now,
|
|
188
|
+
last_cycle_result: cycleResult.ok ? 'ok' : 'error',
|
|
189
|
+
last_error: cycleResult.ok ? null : (cycleResult.error || 'cycle failed'),
|
|
190
|
+
};
|
|
191
|
+
writeDaemonState(root, updated);
|
|
192
|
+
return updated;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createDaemonState(pid, pollSeconds, scheduleId, maxCycles) {
|
|
196
|
+
const now = new Date().toISOString();
|
|
197
|
+
return {
|
|
198
|
+
pid,
|
|
199
|
+
started_at: now,
|
|
200
|
+
last_heartbeat_at: now,
|
|
201
|
+
last_cycle_started_at: null,
|
|
202
|
+
last_cycle_finished_at: null,
|
|
203
|
+
last_cycle_result: null,
|
|
204
|
+
poll_seconds: pollSeconds,
|
|
205
|
+
schedule_id: scheduleId || null,
|
|
206
|
+
max_cycles: maxCycles,
|
|
207
|
+
last_error: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function evaluateDaemonStatus(daemonState, now = Date.now()) {
|
|
212
|
+
if (!daemonState) return { status: 'never_started' };
|
|
213
|
+
if (daemonState._parse_error) return { status: 'not_running', warning: 'state file is malformed' };
|
|
214
|
+
|
|
215
|
+
const heartbeat = parseIsoTime(daemonState.last_heartbeat_at);
|
|
216
|
+
if (heartbeat === null) return { status: 'not_running', warning: 'no heartbeat recorded' };
|
|
217
|
+
|
|
218
|
+
const pollSeconds = typeof daemonState.poll_seconds === 'number' ? daemonState.poll_seconds : 60;
|
|
219
|
+
const staleAfterSeconds = Math.max(pollSeconds * 3, 30);
|
|
220
|
+
const ageSeconds = (now - heartbeat) / 1000;
|
|
221
|
+
|
|
222
|
+
if (ageSeconds > staleAfterSeconds) {
|
|
223
|
+
return { status: 'stale', stale_after_seconds: staleAfterSeconds, heartbeat_age_seconds: Math.round(ageSeconds) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { status: 'running', stale_after_seconds: staleAfterSeconds, heartbeat_age_seconds: Math.round(ageSeconds) };
|
|
227
|
+
}
|