// lib/util/autonomy-gate.mjs // Autonomy-gate state machine for /ultraexecute-local + /ultraplan-local // (plan-v2 Step 4 — drives the --gates flag). // // States: // idle — not yet started // gates_on — gates enabled, between phases // auto_running — running phases continuously without pausing // paused_for_gate — stopped at a phase boundary; awaiting `resume` // completed — terminal // // Events: // start — begin a run (gates flag chooses route) // phase_boundary — a phase finished // resume — operator confirmed; leave the gate // finish — pipeline reached its end // // CLI shim: // node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false] // → JSON: { ok: true, next_state: "..." } (success) // → JSON: { ok: false, error: "..." } (invalid transition; exit 1) // // Pure data; no I/O. Re-entry to `completed` is idempotent. export const STATES = Object.freeze({ IDLE: 'idle', GATES_ON: 'gates_on', AUTO_RUNNING: 'auto_running', PAUSED_FOR_GATE: 'paused_for_gate', COMPLETED: 'completed', }); export const EVENTS = Object.freeze({ START: 'start', PHASE_BOUNDARY: 'phase_boundary', RESUME: 'resume', FINISH: 'finish', }); const STATE_SET = new Set(Object.values(STATES)); const EVENT_SET = new Set(Object.values(EVENTS)); /** * Compute the next state given the current state, event, and (optional) * gates-flag intent (only consulted on `start` from `idle`). * * @param {string} state * @param {string} event * @param {{ gates?: boolean }} [opts] * @returns {{ ok: true, next_state: string } | { ok: false, error: string }} */ export function transition(state, event, opts = {}) { if (!STATE_SET.has(state)) { return { ok: false, error: `unknown state: ${state}` }; } if (!EVENT_SET.has(event)) { return { ok: false, error: `unknown event: ${event}` }; } // completed is terminal & idempotent if (state === STATES.COMPLETED) { return { ok: true, next_state: STATES.COMPLETED }; } if (state === STATES.IDLE) { if (event === EVENTS.START) { const gates = opts.gates === true; return { ok: true, next_state: gates ? STATES.GATES_ON : STATES.AUTO_RUNNING }; } return { ok: false, error: `invalid transition: idle + ${event} (only \`start\` allowed from idle)` }; } if (state === STATES.GATES_ON) { if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.PAUSED_FOR_GATE }; if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; return { ok: false, error: `invalid transition: gates_on + ${event}` }; } if (state === STATES.AUTO_RUNNING) { if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.AUTO_RUNNING }; if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; return { ok: false, error: `invalid transition: auto_running + ${event}` }; } if (state === STATES.PAUSED_FOR_GATE) { if (event === EVENTS.RESUME) return { ok: true, next_state: STATES.GATES_ON }; if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED }; return { ok: false, error: `invalid transition: paused_for_gate + ${event}` }; } return { ok: false, error: `unhandled state: ${state}` }; } /** * Convenience: is this state terminal? */ export function isTerminal(state) { return state === STATES.COMPLETED; } // ---- CLI shim ---------------------------------------------------------------- function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--state') out.state = argv[++i]; else if (a === '--event') out.event = argv[++i]; else if (a === '--gates') { const v = argv[++i]; out.gates = v === 'true'; } } return out; } if (import.meta.url === `file://${process.argv[1]}`) { const args = parseArgs(process.argv.slice(2)); if (!args.state || !args.event) { process.stdout.write(JSON.stringify({ ok: false, error: 'usage: autonomy-gate.mjs --state --event [--gates true|false]', }) + '\n'); process.exit(1); } const result = transition(args.state, args.event, { gates: args.gates }); process.stdout.write(JSON.stringify(result) + '\n'); process.exit(result.ok ? 0 : 1); }