From 79d6d1f776bdcb376f0d5e75cf341afbedfb378e Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 4 May 2026 02:34:27 +0200 Subject: [PATCH] Honor after/unless/abortOn gates in the orca engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of v1 features. With this, the `provides`/`emits` token infrastructure that has lived in OrcaAction since v0 finally has control-flow consequences: tokens emitted by one action gate the acceptance of subsequent actions in the same run. Gate semantics: - `unless: T[]` — declared on actions that should not run again if a sibling action already produced their precondition (idempotency guard). When any token in `unless` is present, the action is SKIPPED with reason `unless-triggered:`. - `abortOn: T[]` — declared on actions that must halt when an upstream flagged danger. When any token in `abortOn` is present, the action is BLOCKED (distinct from skipped) with reason `abort-on-triggered:`. - `after: T[]` — declared on actions whose work depends on tokens emitted by upstream actions. When any required token is missing, the action is SKIPPED with reason `after-not-met:,,…`. Evaluation order is deliberate: unless first (idempotency), abortOn second (halt signal), after third (weakest reason to skip). The first gate that fires short-circuits the action; later gates are not evaluated. The FINALLY stage bypasses all gates so cleanup work runs unconditionally. Engine changes: - New evaluateGates(action, tokens, now) helper produces a synthesized OrcaActionRun when a gate fires, or null when the action should proceed. - executeRun calls evaluateGates() right after the trace-aborted short-circuit and before the per-action AbortController is set up. Gated actions emit either ACTION_SKIPPED or ACTION_BLOCKED diagnostics and never reach runAction(). - The "run aborted between stages" path now also emits an ACTION_BLOCKED diagnostic with reason 'run-aborted', so blocked actions are observable in logs regardless of cause. - OrcaActionRun.interruptedReason renamed to OrcaActionRun.reason — the field carries gate, reentry, or authored reasons uniformly across SKIPPED, BLOCKED, INTERRUPTED. The status determines what kind of reason it is. New constants exported from $orca: - ORCA_GATE_REASON_AFTER_NOT_MET - ORCA_GATE_REASON_UNLESS_TRIGGERED - ORCA_GATE_REASON_ABORT_ON_TRIGGERED - ORCA_GATE_REASON_RUN_ABORTED - ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED Tests (+11): v1 — after gate (3): - skips an action whose `after` token is missing - runs the action when every `after` token has been emitted - reports every missing token in the reason when partially met v1 — unless gate (2): - skips an action when any `unless` token is present - runs the action when no `unless` token is present v1 — abortOn gate (3): - blocks an action when an abortOn token is present - runs the action when no abortOn token is present - emits orca.action.blocked diagnostic with the abortOn reason v1 — gate precedence and FINALLY bypass (3): - unless wins over after when both would short-circuit - abortOn wins over after when both would short-circuit - FINALLY stage bypasses gates so cleanup always runs The legacy "accepts after/unless/abortOn without enforcing" forward- compat tests from the v0 ignored-fields block are removed; v0 promise is now v1 reality. The orcaInterrupted-reason test was updated to read the renamed `reason` field. Verification: 1381/1381 tests pass (71 in orca, +11 from this commit on top of 63 from the previous v1 commits). README updated: gates moved from "Roadmap v1" to "Ya en el motor (de v1)". Remaining v1 items: compensate, commit/replace queue policies, transaction groups, parallel, payload-bearing tokens, fan-in, bus interception (Option B), validate()/commit() static graph validation, and createActiveOrca() reactive wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/arts/orca/README.md | 19 +- src/arts/orca/consts.ts | 18 ++ src/arts/orca/diagnostics.ts | 5 + src/arts/orca/engine-orca.ts | 110 ++++++- src/arts/orca/index.ts | 4 + src/arts/orca/test/engine-orca.test.ts | 388 ++++++++++++++++++++----- src/arts/orca/types.ts | 12 +- 7 files changed, 476 insertions(+), 80 deletions(-) diff --git a/src/arts/orca/README.md b/src/arts/orca/README.md index 8e2bd3a..aacd1ca 100644 --- a/src/arts/orca/README.md +++ b/src/arts/orca/README.md @@ -25,7 +25,7 @@ que el acoplamiento inter-modulo quede escondido en `buss`, `connection` o ## Estado Del Documento Este README es la referencia de diseño de orca. La **v0-kernel** está -implementada y testeada (114 archivos / 1373 tests pasan, 63 de ellos +implementada y testeada (114 archivos / 1381 tests pasan, 71 de ellos sobre orca). El kernel expone: - `createEngineOrca({ bus, timers, logger?, maxRuns?, reentry? })` @@ -46,9 +46,14 @@ sobre orca). El kernel expone: `onError` (precedencia: `FATAL > TIMEOUT > ABORTED > INTERRUPTED > PARTIAL > SUCCESS`). El stage `FINALLY` sigue ejecutándose tras fatal. +- **Gates `after` / `unless` / `abortOn`:** declaras tokens en cada + acción; el motor los evalúa contra los emitidos en el run actual. + `unless` (idempotencia) gana sobre `abortOn` (halt) gana sobre + `after` (dependencia faltante). El stage `FINALLY` bypasa los + gates — la limpieza siempre corre. - Diagnostics estructurados (`orca.run.*`, `orca.action.*`, `orca.event.emitted`, `orca.reentry.blocked`, `orca.trace.aborted`, - `orca.action.fatal`, `orca.action.timeout`) + `orca.action.fatal`, `orca.action.timeout`, `orca.action.blocked`) - `applyStandardOrca(App)` agregador de presets en `arts/active-app/presets/` @@ -1330,7 +1335,6 @@ Marcado como `@v1+` en el código fuente: | Pieza | Qué falta para v1 | |---|---| | `compensate` | Invocar la función compensatoria cuando el run aborta tras un éxito previo. | -| `after` / `unless` / `abortOn` | Honrar los gates de tokens entre acciones. | | `commit()` / `replace` | Políticas de cola por evento (drop-prev / replace / parallel). | | `transaction` (atómico) | Grupos atómicos cuyo fallo lanza compensaciones en orden inverso. | | `parallel` | Ejecución concurrente dentro de un mismo stage cuando no hay `after`. | @@ -1349,3 +1353,12 @@ Marcado como `@v1+` en el código fuente: `ORCA_ACTION_STATUS_FATAL` y aborta el run inmediatamente sin consultar `onError`. El stage `FINALLY` se ejecuta de todas formas. Run status `ORCA_RUN_FATAL` con precedencia sobre cualquier otro. +- ✅ **Gates `after` / `unless` / `abortOn`** — el motor evalúa los + tokens declarados en cada acción contra los emitidos hasta ese + punto del run. `unless` se prueba primero (idempotencia: skip si + alguno está presente), luego `abortOn` (halt: blocked si alguno + está presente), luego `after` (dependencia: skip si alguno falta). + La razón se serializa en `OrcaActionRun.reason` como + `unless-triggered:` / `abort-on-triggered:` / + `after-not-met:,,…`. El stage `FINALLY` los bypasa + siempre. diff --git a/src/arts/orca/consts.ts b/src/arts/orca/consts.ts index 45eebc4..6131f9f 100644 --- a/src/arts/orca/consts.ts +++ b/src/arts/orca/consts.ts @@ -89,6 +89,22 @@ export const ORCA_REENTRY_REASON_TRACE_ABORTED = 'trace-aborted' as const; export const ORCA_REENTRY_REASON_RUN_ABORTED = 'run-aborted' as const; export const ORCA_REENTRY_REASON_DISPOSED = 'disposed' as const; +// ── Gate reasons ─────────────────────────────────────────────────────── +// +// Carried on `OrcaActionRun.reason` when an action is short-circuited by +// one of its declared gates (`after` / `unless` / `abortOn`). Distinct +// from reentry reasons because gates fire inside a single run while +// reentry guards fire across a trace. + +/** Action declared `after: [...]` and at least one token was missing. */ +export const ORCA_GATE_REASON_AFTER_NOT_MET = 'after-not-met' as const; +/** Action declared `unless: [...]` and one of its tokens was present. */ +export const ORCA_GATE_REASON_UNLESS_TRIGGERED = 'unless-triggered' as const; +/** Action declared `abortOn: [...]` and one of its tokens was present. */ +export const ORCA_GATE_REASON_ABORT_ON_TRIGGERED = 'abort-on-triggered' as const; +/** The action's signal was already aborted when it would have run. */ +export const ORCA_GATE_REASON_RUN_ABORTED = 'run-aborted' as const; + // ── Error policies ────────────────────────────────────────────────────── // v0.0 implements CONTINUE and ABORT_RUN. The remaining policies are // accepted in the type and treated as CONTINUE. @@ -112,6 +128,7 @@ export const ORCA_DIAGNOSTIC_EVENTS = { ACTION_FATAL: 'orca.action.fatal', ACTION_TIMEOUT: 'orca.action.timeout', ACTION_SKIPPED: 'orca.action.skipped', + ACTION_BLOCKED: 'orca.action.blocked', ACTION_INTERRUPTED: 'orca.action.interrupted', EVENT_EMITTED: 'orca.event.emitted', REENTRY_BLOCKED: 'orca.reentry.blocked', @@ -130,6 +147,7 @@ export const ORCA_LOG_MSG_ACTION_FAILED = 'orca action failed'; export const ORCA_LOG_MSG_ACTION_FATAL = 'orca action fatal — run aborted'; export const ORCA_LOG_MSG_ACTION_TIMEOUT = 'orca action exceeded actionTimeoutMs'; export const ORCA_LOG_MSG_ACTION_SKIPPED = 'orca action skipped'; +export const ORCA_LOG_MSG_ACTION_BLOCKED = 'orca action blocked by gate'; export const ORCA_LOG_MSG_ACTION_INTERRUPTED = 'orca action interrupted'; export const ORCA_LOG_MSG_EVENT_EMITTED = 'orca derived event emitted'; export const ORCA_LOG_MSG_REENTRY_BLOCKED = 'orca reentry guard blocked an event'; diff --git a/src/arts/orca/diagnostics.ts b/src/arts/orca/diagnostics.ts index 1168105..1f14f7f 100644 --- a/src/arts/orca/diagnostics.ts +++ b/src/arts/orca/diagnostics.ts @@ -12,6 +12,7 @@ import { ORCA_LOG_MSG_ACTION_FAILED, ORCA_LOG_MSG_ACTION_FATAL, ORCA_LOG_MSG_ACTION_INTERRUPTED, + ORCA_LOG_MSG_ACTION_BLOCKED, ORCA_LOG_MSG_ACTION_SKIPPED, ORCA_LOG_MSG_ACTION_STARTED, ORCA_LOG_MSG_ACTION_TIMEOUT, @@ -157,6 +158,10 @@ const ORCA_DIAGNOSTIC_LOGS: DiagnosticCatalog = { level: LogLevel.DEBUG, message: ORCA_LOG_MSG_ACTION_SKIPPED }, + [ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED]: { + level: LogLevel.DEBUG, + message: ORCA_LOG_MSG_ACTION_BLOCKED + }, [ORCA_DIAGNOSTIC_EVENTS.ACTION_INTERRUPTED]: { level: LogLevel.WARN, message: ORCA_LOG_MSG_ACTION_INTERRUPTED diff --git a/src/arts/orca/engine-orca.ts b/src/arts/orca/engine-orca.ts index bcd0f40..9154c31 100644 --- a/src/arts/orca/engine-orca.ts +++ b/src/arts/orca/engine-orca.ts @@ -40,6 +40,10 @@ import { ORCA_DEFAULT_MAX_RUNS, ORCA_DEFAULT_REPEATED_EVENT_LIMIT, ORCA_DIAGNOSTIC_EVENTS, + ORCA_GATE_REASON_ABORT_ON_TRIGGERED, + ORCA_GATE_REASON_AFTER_NOT_MET, + ORCA_GATE_REASON_RUN_ABORTED, + ORCA_GATE_REASON_UNLESS_TRIGGERED, ORCA_ON_ERROR_ABORT_RUN, ORCA_ON_ERROR_CONTINUE, ORCA_REENTRY_ABORT_TRACE, @@ -430,6 +434,30 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { continue; } + // Gate evaluation: short-circuit when after/unless/abortOn + // have something to say about the current token set. The + // finally stage bypasses gates because cleanup must run + // regardless of upstream success/failure. + if (stage !== ORCA_STAGE_FINALLY) { + const gated = evaluateGates(action, tokens, timers.clock.now()); + if (gated !== null) { + actionRuns.push(gated); + const eventName = + gated.status === ORCA_ACTION_STATUS_BLOCKED + ? ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED + : ORCA_DIAGNOSTIC_EVENTS.ACTION_SKIPPED; + emitOrcaDiagnostic(diagnostics, eventName, { + runId, + actionId: action.id, + eventId: envelope.meta.eventId, + traceId: envelope.meta.traceId, + depth: envelope.meta.depth, + reason: gated.reason + }); + continue; + } + } + // Per-action AbortController, chained to the run-level // controller. Lets `actionTimeoutMs` abort just this // action without aborting the whole run. @@ -646,6 +674,14 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { // If the run is already aborted (e.g. between stages, only finally // remains), and we are not in finally, mark blocked. if (context.signal.aborted && context.stage !== ORCA_STAGE_FINALLY) { + emitOrcaDiagnostic(diagnostics, ORCA_DIAGNOSTIC_EVENTS.ACTION_BLOCKED, { + runId: context.runId, + actionId: action.id, + eventId: context.eventId, + traceId: context.traceId, + depth: context.depth, + reason: ORCA_GATE_REASON_RUN_ABORTED + }); return { id: action.id, stage: action.stage, @@ -653,7 +689,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { startedAt, endedAt: startedAt, durationMs: 0, - emitted: [] + emitted: [], + reason: ORCA_GATE_REASON_RUN_ABORTED }; } @@ -735,8 +772,9 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { status === ORCA_ACTION_STATUS_FATAL ? (result as { error?: unknown }).error : undefined, - interruptedReason: - status === ORCA_ACTION_STATUS_INTERRUPTED + reason: + status === ORCA_ACTION_STATUS_INTERRUPTED || + status === ORCA_ACTION_STATUS_SKIPPED ? (result as { reason?: string }).reason : undefined }; @@ -888,10 +926,74 @@ function interruptedActionRun( endedAt: now, durationMs: 0, emitted: [], - interruptedReason: reason + reason }; } +/** + * Evaluate the action's `unless` / `abortOn` / `after` gates against + * the tokens emitted so far in the run. Returns either a synthesized + * action-run record describing the gate outcome, or `null` when the + * action should proceed normally. + * + * Order matters: `unless` (already-done semantics) is checked first + * because it represents an idempotency guard; `abortOn` next, because + * it represents a halt signal; `after` last, because absence of a + * dependency is the weakest reason to skip. + */ +function evaluateGates( + action: OrcaAction, + tokens: ReadonlySet, + now: number +): OrcaActionRun | null { + if (action.unless && action.unless.length > 0) { + const matched = action.unless.find((t) => tokens.has(t)); + if (matched !== undefined) { + return { + id: action.id, + stage: action.stage, + status: ORCA_ACTION_STATUS_SKIPPED, + startedAt: now, + endedAt: now, + durationMs: 0, + emitted: [], + reason: `${ORCA_GATE_REASON_UNLESS_TRIGGERED}:${matched}` + }; + } + } + if (action.abortOn && action.abortOn.length > 0) { + const matched = action.abortOn.find((t) => tokens.has(t)); + if (matched !== undefined) { + return { + id: action.id, + stage: action.stage, + status: ORCA_ACTION_STATUS_BLOCKED, + startedAt: now, + endedAt: now, + durationMs: 0, + emitted: [], + reason: `${ORCA_GATE_REASON_ABORT_ON_TRIGGERED}:${matched}` + }; + } + } + if (action.after && action.after.length > 0) { + const missing = action.after.filter((t) => !tokens.has(t)); + if (missing.length > 0) { + return { + id: action.id, + stage: action.stage, + status: ORCA_ACTION_STATUS_SKIPPED, + startedAt: now, + endedAt: now, + durationMs: 0, + emitted: [], + reason: `${ORCA_GATE_REASON_AFTER_NOT_MET}:${missing.join(',')}` + }; + } + } + return null; +} + function generateRunId(): OrcaRunId { return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } diff --git a/src/arts/orca/index.ts b/src/arts/orca/index.ts index 8bb4200..8140498 100644 --- a/src/arts/orca/index.ts +++ b/src/arts/orca/index.ts @@ -53,6 +53,10 @@ export { ORCA_REENTRY_REASON_TRACE_ABORTED, ORCA_REENTRY_REASON_RUN_ABORTED, ORCA_REENTRY_REASON_DISPOSED, + ORCA_GATE_REASON_AFTER_NOT_MET, + ORCA_GATE_REASON_UNLESS_TRIGGERED, + ORCA_GATE_REASON_ABORT_ON_TRIGGERED, + ORCA_GATE_REASON_RUN_ABORTED, ORCA_DIAGNOSTIC_EVENTS, ORCA_DEFAULT_MAX_RUNS, ORCA_DEFAULT_MAX_DEPTH, diff --git a/src/arts/orca/test/engine-orca.test.ts b/src/arts/orca/test/engine-orca.test.ts index a07bbef..27d977f 100644 --- a/src/arts/orca/test/engine-orca.test.ts +++ b/src/arts/orca/test/engine-orca.test.ts @@ -752,76 +752,6 @@ describe('EngineOrca v0.0 — accepted-but-ignored fields (forward-compat)', () timers = createFakeTimers(); }); - it('accepts after without waiting for tokens', async () => { - const orca = createEngineOrca({ bus, timers }); - const calls: string[] = []; - - orca.onEvent('e1', { - id: 'declares-after', - stage: ORCA_STAGE_MAIN, - after: ['nonexistent-token'], - action: () => { - calls.push('ran'); - return orcaSuccess(); - } - }); - - bus.publish('e1', null); - await flush(); - - expect(calls).toEqual(['ran']); // ran despite missing token - }); - - it('accepts unless without skipping', async () => { - const orca = createEngineOrca({ bus, timers }); - const calls: string[] = []; - - orca.onEvent('e1', { - id: 'producer', - stage: ORCA_STAGE_MAIN, - action: () => orcaSuccess({ emits: ['present'] }) - }); - orca.onEvent('e1', { - id: 'declares-unless', - stage: ORCA_STAGE_POST, - unless: ['present'], - action: () => { - calls.push('ran'); - return orcaSuccess(); - } - }); - - bus.publish('e1', null); - await flush(); - - expect(calls).toEqual(['ran']); // ran despite token being present - }); - - it('accepts abortOn without blocking', async () => { - const orca = createEngineOrca({ bus, timers }); - const calls: string[] = []; - - orca.onEvent('e1', { - id: 'producer', - stage: ORCA_STAGE_MAIN, - action: () => orcaSuccess({ emits: ['danger'] }) - }); - orca.onEvent('e1', { - id: 'declares-abortOn', - stage: ORCA_STAGE_POST, - abortOn: ['danger'], - action: () => { - calls.push('ran'); - return orcaSuccess(); - } - }); - - bus.publish('e1', null); - await flush(); - - expect(calls).toEqual(['ran']); - }); - it('accepts compensate without invoking it', async () => { const orca = createEngineOrca({ bus, timers }); const compensate = vi.fn(() => orcaSuccess()); @@ -1180,7 +1110,7 @@ describe('EngineOrca v0 — reentry guards', () => { expect(runs).toHaveLength(1); expect(runs[0].status).toBe(ORCA_RUN_INTERRUPTED); expect(runs[0].actions[0].status).toBe(ORCA_ACTION_STATUS_INTERRUPTED); - expect(runs[0].actions[0].interruptedReason).toBe('aborted-by-author'); + expect(runs[0].actions[0].reason).toBe('aborted-by-author'); }); it('blocks the (N+1)th event in a trace when N = maxEventsPerTrace', async () => { @@ -1719,3 +1649,319 @@ describe('EngineOrca v1 — OrcaFatal', () => { }); }); +// ── v1: after / unless / abortOn gates ──────────────────────────────── + +describe('EngineOrca v1 — after gate', () => { + let bus: FakeBus; + let timers: ReturnType; + + beforeEach(() => { + bus = createFakeBus(); + timers = createFakeTimers(); + }); + + it('skips an action whose `after` token is missing', async () => { + const orca = createEngineOrca({ bus, timers }); + const ran: string[] = []; + + orca.onEvent('e', { + id: 'gated', + stage: ORCA_STAGE_MAIN, + after: ['producer:done'], + action: () => { + ran.push('gated'); + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(ran).toEqual([]); + const runs = orca.recentRuns(); + expect(runs[0].actions[0].status).toBe(ORCA_ACTION_STATUS_SKIPPED); + expect(runs[0].actions[0].reason).toMatch(/^after-not-met:producer:done$/); + }); + + it('runs the action when every `after` token has been emitted', async () => { + const orca = createEngineOrca({ bus, timers }); + const ran: string[] = []; + + orca.onEvent('e', { + id: 'producer', + stage: ORCA_STAGE_PRE, + provides: ['ready'], + action: () => orcaSuccess({ emits: ['ready'] }) + }); + orca.onEvent('e', { + id: 'gated', + stage: ORCA_STAGE_MAIN, + after: ['ready'], + action: () => { + ran.push('gated'); + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(ran).toEqual(['gated']); + const runs = orca.recentRuns(); + expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_SUCCESS); + }); + + it('reports every missing token in the reason when partially met', async () => { + const orca = createEngineOrca({ bus, timers }); + + orca.onEvent('e', { + id: 'producer', + stage: ORCA_STAGE_PRE, + action: () => orcaSuccess({ emits: ['t1'] }) + }); + orca.onEvent('e', { + id: 'gated', + stage: ORCA_STAGE_MAIN, + after: ['t1', 't2', 't3'], + action: () => orcaSuccess() + }); + + bus.publish('e', null); + await flush(); + + const runs = orca.recentRuns(); + expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_SKIPPED); + expect(runs[0].actions[1].reason).toBe('after-not-met:t2,t3'); + }); +}); + +describe('EngineOrca v1 — unless gate', () => { + let bus: FakeBus; + let timers: ReturnType; + + beforeEach(() => { + bus = createFakeBus(); + timers = createFakeTimers(); + }); + + it('skips an action when any `unless` token is present', async () => { + const orca = createEngineOrca({ bus, timers }); + const ran: string[] = []; + + orca.onEvent('e', { + id: 'producer', + stage: ORCA_STAGE_PRE, + action: () => orcaSuccess({ emits: ['already-done'] }) + }); + orca.onEvent('e', { + id: 'idempotent', + stage: ORCA_STAGE_MAIN, + unless: ['already-done'], + action: () => { + ran.push('idempotent'); + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(ran).toEqual([]); + const runs = orca.recentRuns(); + expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_SKIPPED); + expect(runs[0].actions[1].reason).toBe('unless-triggered:already-done'); + }); + + it('runs the action when no `unless` token is present', async () => { + const orca = createEngineOrca({ bus, timers }); + const ran: string[] = []; + + orca.onEvent('e', { + id: 'idempotent', + stage: ORCA_STAGE_MAIN, + unless: ['was-done'], + action: () => { + ran.push('idempotent'); + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(ran).toEqual(['idempotent']); + }); +}); + +describe('EngineOrca v1 — abortOn gate', () => { + let bus: FakeBus; + let timers: ReturnType; + + beforeEach(() => { + bus = createFakeBus(); + timers = createFakeTimers(); + }); + + it('blocks an action when an abortOn token is present', async () => { + const orca = createEngineOrca({ bus, timers }); + const ran: string[] = []; + + orca.onEvent('e', { + id: 'flag-danger', + stage: ORCA_STAGE_PRE, + action: () => orcaSuccess({ emits: ['danger'] }) + }); + orca.onEvent('e', { + id: 'risky', + stage: ORCA_STAGE_MAIN, + abortOn: ['danger'], + action: () => { + ran.push('risky'); + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(ran).toEqual([]); + const runs = orca.recentRuns(); + expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_BLOCKED); + expect(runs[0].actions[1].reason).toBe('abort-on-triggered:danger'); + }); + + it('runs the action when no abortOn token is present', async () => { + const orca = createEngineOrca({ bus, timers }); + const ran: string[] = []; + + orca.onEvent('e', { + id: 'risky', + stage: ORCA_STAGE_MAIN, + abortOn: ['danger'], + action: () => { + ran.push('risky'); + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(ran).toEqual(['risky']); + }); + + it('emits orca.action.blocked diagnostic with the abortOn reason', async () => { + const entries: DiagnosticEntry[] = []; + const orca = createEngineOrca({ + bus, + timers, + logger: createCapturingLogger(entries) as Parameters< + typeof createEngineOrca + >[0]['logger'] + }); + + orca.onEvent('e', { + id: 'flag-danger', + stage: ORCA_STAGE_PRE, + action: () => orcaSuccess({ emits: ['danger'] }) + }); + orca.onEvent('e', { + id: 'risky', + stage: ORCA_STAGE_MAIN, + abortOn: ['danger'], + action: () => orcaSuccess() + }); + + bus.publish('e', null); + await flush(); + + const blocked = entries.find((e) => e.type === 'orca.action.blocked'); + expect(blocked).toBeDefined(); + expect(blocked?.meta.actionId).toBe('risky'); + expect(blocked?.meta.reason).toBe('abort-on-triggered:danger'); + }); +}); + +describe('EngineOrca v1 — gate precedence and FINALLY bypass', () => { + let bus: FakeBus; + let timers: ReturnType; + + beforeEach(() => { + bus = createFakeBus(); + timers = createFakeTimers(); + }); + + it('unless wins over after when both would short-circuit (unless evaluated first)', async () => { + const orca = createEngineOrca({ bus, timers }); + + orca.onEvent('e', { + id: 'producer', + stage: ORCA_STAGE_PRE, + action: () => orcaSuccess({ emits: ['already-done'] }) + }); + orca.onEvent('e', { + id: 'gated', + stage: ORCA_STAGE_MAIN, + after: ['unmet-token'], + unless: ['already-done'], + action: () => orcaSuccess() + }); + + bus.publish('e', null); + await flush(); + + const runs = orca.recentRuns(); + expect(runs[0].actions[1].reason).toBe('unless-triggered:already-done'); + }); + + it('abortOn wins over after when both would short-circuit', async () => { + const orca = createEngineOrca({ bus, timers }); + + orca.onEvent('e', { + id: 'producer', + stage: ORCA_STAGE_PRE, + action: () => orcaSuccess({ emits: ['danger'] }) + }); + orca.onEvent('e', { + id: 'gated', + stage: ORCA_STAGE_MAIN, + after: ['unmet-token'], + abortOn: ['danger'], + action: () => orcaSuccess() + }); + + bus.publish('e', null); + await flush(); + + const runs = orca.recentRuns(); + expect(runs[0].actions[1].status).toBe(ORCA_ACTION_STATUS_BLOCKED); + expect(runs[0].actions[1].reason).toBe('abort-on-triggered:danger'); + }); + + it('FINALLY stage bypasses gates so cleanup always runs', async () => { + const orca = createEngineOrca({ bus, timers }); + let cleanupRan = false; + + orca.onEvent('e', { + id: 'producer', + stage: ORCA_STAGE_PRE, + action: () => orcaSuccess({ emits: ['danger'] }) + }); + orca.onEvent('e', { + id: 'cleanup', + stage: ORCA_STAGE_FINALLY, + abortOn: ['danger'], // would normally block this action + after: ['nonexistent'], // and this would skip it + action: () => { + cleanupRan = true; + return orcaSuccess(); + } + }); + + bus.publish('e', null); + await flush(); + + expect(cleanupRan).toBe(true); + }); +}); + diff --git a/src/arts/orca/types.ts b/src/arts/orca/types.ts index 448339e..0090bf5 100644 --- a/src/arts/orca/types.ts +++ b/src/arts/orca/types.ts @@ -367,8 +367,16 @@ export interface OrcaActionRun { readonly durationMs: number; readonly emitted: readonly OrcaToken[]; readonly error?: unknown; - /** Populated when status === 'interrupted'. */ - readonly interruptedReason?: OrcaReentryReason | string; + /** + * Short tag describing why the action ended in this status. + * Populated for SKIPPED (gate / authored skipped), BLOCKED (gate / + * run-aborted), INTERRUPTED (trace-aborted / authored interrupted). + * Carries one of the `ORCA_GATE_REASON_*` or `ORCA_REENTRY_REASON_*` + * constants when the engine produced the status, or the string + * passed by the action when it returned `orcaSkipped(reason)` / + * `orcaInterrupted(reason)`. + */ + readonly reason?: OrcaReentryReason | string; } export interface OrcaRunResult {