agentxchain 2.49.0 → 2.51.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 +7 -1
- package/package.json +1 -1
- package/scripts/release-bump.sh +73 -5
- package/scripts/sync-homebrew.sh +26 -3
- package/src/commands/init.js +20 -0
- package/src/commands/schedule.js +93 -0
- package/src/lib/export.js +2 -0
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/run-schedule.js +67 -0
package/bin/agentxchain.js
CHANGED
|
@@ -107,7 +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 } from '../src/commands/schedule.js';
|
|
110
|
+
import { scheduleDaemonCommand, scheduleListCommand, scheduleRunDueCommand, scheduleStatusCommand } from '../src/commands/schedule.js';
|
|
111
111
|
|
|
112
112
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
113
113
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -288,6 +288,12 @@ scheduleCmd
|
|
|
288
288
|
.option('-j, --json', 'Output as JSON')
|
|
289
289
|
.action(scheduleDaemonCommand);
|
|
290
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
|
+
|
|
291
297
|
program
|
|
292
298
|
.command('history')
|
|
293
299
|
.description('Show cross-run history of governed runs in this project')
|
package/package.json
CHANGED
package/scripts/release-bump.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Release identity creation — replaces raw `npm version <semver>`.
|
|
3
3
|
# Creates version bump commit + annotated tag with fail-closed verification.
|
|
4
|
-
# Usage: bash scripts/release-bump.sh --target-version <semver>
|
|
4
|
+
# Usage: bash scripts/release-bump.sh --target-version <semver> [--skip-preflight]
|
|
5
5
|
set -euo pipefail
|
|
6
6
|
|
|
7
7
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -10,9 +10,10 @@ REPO_ROOT="$(cd "${CLI_DIR}/.." && pwd)"
|
|
|
10
10
|
cd "$CLI_DIR"
|
|
11
11
|
|
|
12
12
|
TARGET_VERSION=""
|
|
13
|
+
SKIP_PREFLIGHT=0
|
|
13
14
|
|
|
14
15
|
usage() {
|
|
15
|
-
echo "Usage: bash scripts/release-bump.sh --target-version <semver>" >&2
|
|
16
|
+
echo "Usage: bash scripts/release-bump.sh --target-version <semver> [--skip-preflight]" >&2
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
while [[ $# -gt 0 ]]; do
|
|
@@ -31,6 +32,10 @@ while [[ $# -gt 0 ]]; do
|
|
|
31
32
|
TARGET_VERSION="$2"
|
|
32
33
|
shift 2
|
|
33
34
|
;;
|
|
35
|
+
--skip-preflight)
|
|
36
|
+
SKIP_PREFLIGHT=1
|
|
37
|
+
shift
|
|
38
|
+
;;
|
|
34
39
|
*)
|
|
35
40
|
usage
|
|
36
41
|
exit 1
|
|
@@ -250,8 +255,67 @@ if [[ "$COMMIT_MSG" != "$TARGET_VERSION" ]]; then
|
|
|
250
255
|
fi
|
|
251
256
|
echo " OK: commit ${RELEASE_SHA:0:7} with message '${TARGET_VERSION}'"
|
|
252
257
|
|
|
258
|
+
# 8.5. Inline preflight gate — tests, pack, and docs build must pass before tag
|
|
259
|
+
if [[ "$SKIP_PREFLIGHT" -eq 1 ]]; then
|
|
260
|
+
echo ""
|
|
261
|
+
echo "[8.5/10] Inline preflight gate SKIPPED (--skip-preflight)"
|
|
262
|
+
else
|
|
263
|
+
echo ""
|
|
264
|
+
echo "[8.5/10] Running inline preflight gate..."
|
|
265
|
+
echo " Running test suite..."
|
|
266
|
+
|
|
267
|
+
# Install MCP example deps if needed (same as release-preflight.sh)
|
|
268
|
+
for example_dir in "${CLI_DIR}/../examples/mcp-echo-agent" "${CLI_DIR}/../examples/mcp-http-echo-agent"; do
|
|
269
|
+
if [[ -f "${example_dir}/package.json" && ! -d "${example_dir}/node_modules" ]]; then
|
|
270
|
+
echo " Installing deps for $(basename "$example_dir")..."
|
|
271
|
+
(cd "$example_dir" && env -u NODE_AUTH_TOKEN -u NPM_CONFIG_USERCONFIG npm install --ignore-scripts --userconfig /dev/null 2>&1) || true
|
|
272
|
+
fi
|
|
273
|
+
done
|
|
274
|
+
|
|
275
|
+
PREFLIGHT_FAILED=0
|
|
276
|
+
|
|
277
|
+
# 8.5a. Full test suite with release env vars
|
|
278
|
+
if env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test >/dev/null 2>&1; then
|
|
279
|
+
echo " OK: test suite passed"
|
|
280
|
+
else
|
|
281
|
+
echo " FAIL: test suite failed" >&2
|
|
282
|
+
echo " Re-running with output for diagnostics..." >&2
|
|
283
|
+
env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test 2>&1 | tail -30 >&2
|
|
284
|
+
PREFLIGHT_FAILED=1
|
|
285
|
+
fi
|
|
286
|
+
|
|
287
|
+
# 8.5b. npm pack dry-run
|
|
288
|
+
if npm pack --dry-run >/dev/null 2>&1; then
|
|
289
|
+
echo " OK: npm pack --dry-run passed"
|
|
290
|
+
else
|
|
291
|
+
echo " FAIL: npm pack --dry-run failed" >&2
|
|
292
|
+
PREFLIGHT_FAILED=1
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
# 8.5c. Docs build
|
|
296
|
+
if (cd "${REPO_ROOT}/website-v2" && npm run build >/dev/null 2>&1); then
|
|
297
|
+
echo " OK: docs build passed"
|
|
298
|
+
else
|
|
299
|
+
echo " FAIL: docs build failed" >&2
|
|
300
|
+
PREFLIGHT_FAILED=1
|
|
301
|
+
fi
|
|
302
|
+
|
|
303
|
+
if [[ "$PREFLIGHT_FAILED" -eq 1 ]]; then
|
|
304
|
+
echo "" >&2
|
|
305
|
+
echo "PREFLIGHT FAILED — release commit created but NOT tagged." >&2
|
|
306
|
+
echo " Commit: ${RELEASE_SHA:0:7}" >&2
|
|
307
|
+
echo " Fix the failures, amend the commit, and re-run:" >&2
|
|
308
|
+
echo " bash scripts/release-bump.sh --target-version ${TARGET_VERSION}" >&2
|
|
309
|
+
echo " Or skip preflight if already verified:" >&2
|
|
310
|
+
echo " bash scripts/release-bump.sh --target-version ${TARGET_VERSION} --skip-preflight" >&2
|
|
311
|
+
exit 1
|
|
312
|
+
fi
|
|
313
|
+
|
|
314
|
+
echo " Inline preflight gate passed — proceeding to tag"
|
|
315
|
+
fi
|
|
316
|
+
|
|
253
317
|
# 9. Create annotated tag
|
|
254
|
-
echo "[9/
|
|
318
|
+
echo "[9/10] Creating annotated tag..."
|
|
255
319
|
git tag -a "v${TARGET_VERSION}" -m "v${TARGET_VERSION}"
|
|
256
320
|
TAG_SHA=$(git rev-parse "v${TARGET_VERSION}")
|
|
257
321
|
if [[ -z "$TAG_SHA" ]]; then
|
|
@@ -276,6 +340,10 @@ echo "Release identity created successfully."
|
|
|
276
340
|
echo " Version: ${TARGET_VERSION}"
|
|
277
341
|
echo " Commit: ${RELEASE_SHA:0:7}"
|
|
278
342
|
echo " Tag: v${TARGET_VERSION}"
|
|
343
|
+
if [[ "$SKIP_PREFLIGHT" -eq 1 ]]; then
|
|
344
|
+
echo ""
|
|
345
|
+
echo "WARNING: Inline preflight was skipped. Verify before pushing:"
|
|
346
|
+
echo " npm run preflight:release:strict -- --target-version ${TARGET_VERSION}"
|
|
347
|
+
fi
|
|
279
348
|
echo ""
|
|
280
|
-
echo "Next:
|
|
281
|
-
echo "Then: git push origin main --follow-tags"
|
|
349
|
+
echo "Next: git push origin main --follow-tags"
|
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/schedule.js
CHANGED
|
@@ -2,9 +2,15 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { loadProjectContext } from '../lib/config.js';
|
|
3
3
|
import {
|
|
4
4
|
SCHEDULE_STATE_PATH,
|
|
5
|
+
DAEMON_STATE_PATH,
|
|
5
6
|
listSchedules,
|
|
6
7
|
updateScheduleState,
|
|
7
8
|
evaluateScheduleLaunchEligibility,
|
|
9
|
+
readDaemonState,
|
|
10
|
+
writeDaemonState,
|
|
11
|
+
updateDaemonHeartbeat,
|
|
12
|
+
createDaemonState,
|
|
13
|
+
evaluateDaemonStatus,
|
|
8
14
|
} from '../lib/run-schedule.js';
|
|
9
15
|
import { executeGovernedRun } from './run.js';
|
|
10
16
|
|
|
@@ -221,6 +227,78 @@ export async function scheduleRunDueCommand(opts) {
|
|
|
221
227
|
process.exitCode = result.exitCode;
|
|
222
228
|
}
|
|
223
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
|
+
|
|
224
302
|
export async function scheduleDaemonCommand(opts) {
|
|
225
303
|
const context = loadScheduleContext();
|
|
226
304
|
if (!context) return;
|
|
@@ -239,16 +317,31 @@ export async function scheduleDaemonCommand(opts) {
|
|
|
239
317
|
}
|
|
240
318
|
|
|
241
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
|
+
|
|
242
330
|
if (!opts.json) {
|
|
243
331
|
console.log(chalk.bold('AgentXchain Schedule Daemon'));
|
|
244
332
|
console.log(chalk.dim(` Poll: ${pollSeconds}s`));
|
|
245
333
|
console.log(chalk.dim(` State: ${SCHEDULE_STATE_PATH}`));
|
|
334
|
+
console.log(chalk.dim(` Health: ${DAEMON_STATE_PATH}`));
|
|
246
335
|
console.log('');
|
|
247
336
|
}
|
|
248
337
|
|
|
249
338
|
while (true) {
|
|
250
339
|
cycle += 1;
|
|
340
|
+
daemonState.last_cycle_started_at = new Date().toISOString();
|
|
251
341
|
const result = await runDueSchedules(context, opts);
|
|
342
|
+
|
|
343
|
+
updateDaemonHeartbeat(context.root, daemonState, result);
|
|
344
|
+
|
|
252
345
|
if (opts.json) {
|
|
253
346
|
console.log(JSON.stringify({ cycle, ...result }));
|
|
254
347
|
}
|
package/src/lib/export.js
CHANGED
|
@@ -33,6 +33,7 @@ export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
|
33
33
|
'.agentxchain/run-history.jsonl',
|
|
34
34
|
'.agentxchain/events.jsonl',
|
|
35
35
|
'.agentxchain/schedule-state.json',
|
|
36
|
+
'.agentxchain/schedule-daemon.json',
|
|
36
37
|
'.agentxchain/dispatch',
|
|
37
38
|
'.agentxchain/staging',
|
|
38
39
|
'.agentxchain/transactions/accept',
|
|
@@ -57,6 +58,7 @@ export const RUN_RESTORE_ROOTS = [
|
|
|
57
58
|
'.agentxchain/run-history.jsonl',
|
|
58
59
|
'.agentxchain/events.jsonl',
|
|
59
60
|
'.agentxchain/schedule-state.json',
|
|
61
|
+
'.agentxchain/schedule-daemon.json',
|
|
60
62
|
'.agentxchain/dispatch',
|
|
61
63
|
'.agentxchain/staging',
|
|
62
64
|
'.agentxchain/transactions/accept',
|
package/src/lib/repo-observer.js
CHANGED
package/src/lib/run-schedule.js
CHANGED
|
@@ -4,7 +4,9 @@ import { safeWriteJson } from './safe-write.js';
|
|
|
4
4
|
import { loadProjectState } from './config.js';
|
|
5
5
|
|
|
6
6
|
export const SCHEDULE_STATE_PATH = '.agentxchain/schedule-state.json';
|
|
7
|
+
export const DAEMON_STATE_PATH = '.agentxchain/schedule-daemon.json';
|
|
7
8
|
const SCHEDULE_STATE_SCHEMA_VERSION = '0.1';
|
|
9
|
+
const DAEMON_STATE_SCHEMA_VERSION = '0.1';
|
|
8
10
|
|
|
9
11
|
function parseIsoTime(value) {
|
|
10
12
|
if (typeof value !== 'string' || !value.trim()) return null;
|
|
@@ -158,3 +160,68 @@ export function evaluateScheduleLaunchEligibility(root, config) {
|
|
|
158
160
|
|
|
159
161
|
return { ok: false, status, reason: `run_${status}` };
|
|
160
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
|
+
}
|