mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
feat(daemon,cli): PL-004 Phase D — daemon-native Workflow Runtime + workflow-keepalive
Adds the Workflow Runtime primitive from PL-004 Phase D:
Migrations (033-036):
- 033_workflow_specs: cached read-through from markdown/YAML spec
files. UNIQUE on (name, version); source_hash content-invalidation;
workspace-surface reconciliation (operator edits win at next read).
- 034_workflow_instances: live instance state. status enum
(active/waiting/completed/failed); current_frontier_json; hop_count;
last_continuation_decision_json. Survives daemon restart from SQLite.
- 035_workflow_step_trails: append-only history. FK to workflow_instances
and queue_items (next_qitem_id, prior_qitem_id). Append-only at
API surface (no update/delete methods).
- 036_watchdog_policy_enum_extension: documenting no-op. Phase C uses
application-layer enum enforcement (PHASE_D_POLICIES array), so
extending the enum to include workflow-keepalive needs no DDL.
Migration exists for audit-trail integrity per IMPL row 17.
Domain (8 new files; load-bearing transactional-scribe):
- WorkflowSpecCache: read-through cache via the `yaml` package.
Re-caches in place on hash miss (UPDATE same spec_id).
- WorkflowValidator: role resolution + step uniqueness + allowed-exits
+ optional seat liveness. Returns structured ValidationResult.
- WorkflowInstanceStore: CRUD on workflow_instances; updateFrontier
composes inside outer caller-managed transactions.
- WorkflowStepTrailLog: append-only writer; pattern mirrors Phase A
queue-transition-log + Phase C watchdog-history-log.
- WorkflowProjector: LOAD-BEARING. Implements transactional-scribe.
A single db.transaction holds: closing the current packet, creating
the next-step packet (via Phase A QueueRepository extension point),
appending the trail entry, updating instance frontier+status, and
persisting workflow events. Either everything commits or everything
rolls back. Lost handoffs are impossible by design.
- WorkflowRuntime: facade over the above; operations validate +
instantiate (creates instance + entry qitem in one transaction) +
project (delegates to projector) + continue (idempotent inspector).
- policies/workflow-keepalive: TypeScript port of POC
`lib/policies/workflow-keepalive.mjs`. SOURCE-OF-TRUTH SWITCHED
from markdown to SQLite per audit row 18 (LOAD-BEARING). Reads
workflow_instances directly; resolves frontier owners via queue_items.
Phase A surface extension (orch-ratified per IMPL § Driver Handoff):
- QueueRepository.createWithinTransaction(): smallest-honest extension
that lets workflow-projector compose queue creation inside its outer
transaction without breaking notify/nudge post-commit semantics.
create() now factored through createInTransactionalContext() helper;
behavior unchanged for existing callers.
- QueueRepository.maybeNudge(): visibility raised to public so
workflow-projector can fire nudges after its outer transaction
commits.
Phase C surface extension (orch-ratified per IMPL § Write Set):
- WatchdogJobsRepository: PHASE_C_POLICIES → PHASE_D_POLICIES (extends
enum with workflow-keepalive); policy_deferred_to_phase_d rejection
removed. PHASE_C_POLICIES retained as deprecated alias.
- WatchdogPolicyEngine: instance-level policy registry +
additionalPolicies in deps (was static module-level Map). Allows
workflow-keepalive registration at startup with db handle injected.
Phase D events (6 new RigEvent types):
- workflow.instantiated, workflow.step_closed,
workflow.next_qitem_projected, workflow.completed,
workflow.failed, workflow.routing_table_changed
- 20 existing PL-004 events untouched.
Routes (1 new file, route-order discipline preserved):
- POST /api/workflow/{validate,instantiate,project}
- GET /api/workflow/list
- GET /api/workflow/sse + /watch (mounted BEFORE /:instance_id)
- GET /api/workflow/:instance_id and :/:instance_id/trace
- POST /api/workflow/:instance_id/continue
CLI:
- rig workflow validate/instantiate/project/list/show/trace/continue
- 7 subcommands; matches POC `bin/rig-workflow` shape.
Wiring:
- startup.ts: WorkflowRuntime constructed from queueRepo + db + bus;
workflow-keepalive policy injected into WatchdogPolicyEngine via
additionalPolicies. Phase D services start with daemon; recover
from SQLite on restart.
Test changes:
- 8 new daemon test files (~67 new test cases): workflow-spec-cache
(9), workflow-validator (9), workflow-instance-store (7),
workflow-step-trail-log (7), workflow-projector (7 incl. transactional-
scribe rollback regression), workflow-keepalive-policy (8 incl.
SQLite-source-only LOAD-BEARING assertion), workflow-routes (12 incl.
SSE route-order regression guard), workflow-cross-loop-three-step (1
PRD acceptance criteria load-bearing case).
- 1 new CLI test file: workflow.test.ts (8 cases).
- Phase C registration-rejection assertions REPLACED with positive
registration-accept (the orch-ratified Phase C test surface change):
watchdog-jobs-repository.test.ts, watchdog-routes.test.ts,
cli/test/watchdog.test.ts.
Verification:
- tsc clean for daemon and cli.
- Per-package vitest baseline under explicit OPENRIG_HOME +
OPENRIG_DB=:memory: temp env:
- packages/daemon: 2552 / 2552 pass (was 2492 in Phase C R2; +60
new workflow tests).
- packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
CLI failures (host-registry, config, specs, preflight, client
--version) unchanged from Phase C R2 baseline.
Boundaries preserved:
- Worktree-only on phase-d-coordination-primitive-system; main
untouched at 1a3b313 (Phase C R2).
- No-touch surfaces unchanged: Phase A stream/queue/inbox/outbox/
transitions tables + non-extension repository methods, Phase B
project/classifier/view, Lifecycle v2, migrations 001-032,
POC workflow-runtime-v0/, POC services/watchdog/lib/policies/
workflow-keepalive.mjs, progress-rail (Phase E remains
markdown-authoritative for v1).
- Workflow runtime PROJECTS on closure; does NOT GATE closure
(Phase A hot-potato strict-rejection remains the closure authority).
- One-hop continuity is the load-bearing default; multi-hop chaining
deferred (PRD § Risks "Workflow runtime over-engineered").
- workflow-keepalive reads ONLY from SQLite (audit row 18 LOAD-BEARING).
- No fleet sweep, no protected-host mutation, no external spend, no
destructive lifecycle, no VM-boundary actions, no remote push, no
tag, no publish, no live runtime adoption (per human steering
2026-05-03 for the Coordination Primitive feature).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
1a3b313f0d
commit
55bdcbb73c
36 files changed
+3929
-95
No files matched your search
@@ -326,7 +326,26 @@ Migration boundary:
|
||||
- `022_node_codex_config_profile.ts` adds Codex config-profile field on nodes.
|
||||
- `023_stream_items.ts` through `027_outbox_entries.ts` add the PL-004 Phase A coordination tables (see § Coordination Layer below).
|
||||
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
|
||||
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Three v1 policies: `periodic-reminder`, `artifact-pool-ready`, `edge-artifact-required`. The fourth POC policy `workflow-keepalive` is intentionally rejected at registration with `policy_deferred_to_phase_d` and ships in Phase D paired with workflow_instances.
|
||||
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Phase D extends the policy enum to four values (Phase C's three plus `workflow-keepalive`); see § Workflow Runtime below.
|
||||
- `033_workflow_specs.ts`, `034_workflow_instances.ts`, `035_workflow_step_trails.ts` add the PL-004 Phase D Workflow Runtime tables. Migration `036_watchdog_policy_enum_extension.ts` is a documenting no-op that records the Phase D enum extension (Phase C uses application-layer enforcement via `PHASE_D_POLICIES` array, so no DDL is needed).
|
||||
|
||||
### Workflow Runtime (PL-004 Phase D)
|
||||
|
||||
Daemon-native Workflow Runtime — declarative workflow specs, live instance state, append-only step trails, and the load-bearing transactional-scribe contract. Per PRD § L4 and founder direction (2026-04-22): owner-as-author semantically + workflow-as-transactional-scribe mechanically.
|
||||
|
||||
`workflow_specs` is a read-through cache of human-authored markdown/YAML spec files. Sources are workspace-surface; the daemon caches by `(name, version)` with a content `source_hash` so that valid operator edits to the spec file win at next read (workspace-surface reconciliation). Spec authoring stays markdown-authoritative; `workflow_specs` exists for fast lookup and runtime resolution.
|
||||
|
||||
`workflow_instances` carries live state per running workflow: `status` (`active|waiting|completed|failed`), `current_frontier_json` (active qitem ids), `hop_count` (loop-guard counter), and `last_continuation_decision_json` (most recent close/continue decision). Instances survive daemon restart from SQLite — no filesystem reconciliation required.
|
||||
|
||||
`workflow_step_trails` is the append-only history of meaningful step transitions. Every closure produces one trail row pairing the prior qitem with the next qitem (null on terminal). Append-only at the API surface: `WorkflowStepTrailLog.record()` is the only writer.
|
||||
|
||||
The transactional-scribe contract — the load-bearing Phase D guarantee — is implemented in `WorkflowProjector.project()`. A single `db.transaction` holds: closing the current packet (state mutation on `queue_items`), creating the next-step packet (`QueueRepository.createWithinTransaction()`), recording the trail entry, updating the instance frontier + status, and persisting workflow events. Either everything commits or everything rolls back; lost handoffs are impossible by design. Post-commit, subscribers are notified and the next owner is nudged.
|
||||
|
||||
`workflow-keepalive` (the Phase C-deferred watchdog policy) reads `workflow_instances` directly via SQLite — never the markdown source. Eligible only when `status` is `active|waiting`; resolves frontier qitem owners by querying `queue_items`; combines with explicit observer/created_by targets; sends to the first resolved target.
|
||||
|
||||
Phase D extends `RigEvent` with: `workflow.instantiated`, `workflow.step_closed`, `workflow.next_qitem_projected`, `workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`. Existing 20 PL-004 events are unchanged.
|
||||
|
||||
Phase D scope intentionally excludes multi-hop chaining (graduation per PRD § Risks "Workflow runtime over-engineered"), gate-return-sweep, and the closure-enforcement path (the daemon's transactional state remains the closure authority via Phase A's hot-potato strict-rejection; workflow runtime PROJECTS on closure, does not GATE closure). Phase E (progress-rail) remains markdown-authoritative for v1.
|
||||
|
||||
### Coordination tables (PL-004 Phase A)
|
||||
|
||||
|
||||
@@ -714,6 +714,24 @@ Closure-reason semantics:
|
||||
|
||||
History records only loud evaluations: `sent` (delivery executed) or `terminal` (policy declared the job done). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded — POC parity so agents are not woken about scheduler polls. Loud `skipped` rows are recorded only if a policy returns a non-quiet reason.
|
||||
|
||||
Phase D extends the policy enum with `workflow-keepalive` (the policy deferred from Phase C). It reads `workflow_instances` directly via SQLite, requires `status: active|waiting`, and resolves frontier qitem owners from `queue_items`.
|
||||
|
||||
## Workflow Runtime (PL-004 Phase D)
|
||||
|
||||
`rig workflow` operates on the daemon-native Workflow Runtime: declarative spec validation, instance creation, step projection (transactional-scribe), trace, and idempotent continue. Workflow specs are markdown/YAML files on disk (workspace-surface); the daemon caches them in SQLite for fast lookup.
|
||||
|
||||
### `rig workflow`
|
||||
|
||||
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness).
|
||||
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner.
|
||||
- `project --instance <id> --current-packet <qitem-id> --exit <handoff|waiting|done|failed> --actor-session <session>` — close the current packet AND project the next-step packet IN THE SAME daemon transaction (transactional-scribe; lost handoffs impossible by design). `--result-note <text>`, `--blocked-on <ref>`, `--next-owner <session>` modify behavior.
|
||||
- `list [--status <s>]` — list instances; optionally filter by status (`active`/`waiting`/`completed`/`failed`).
|
||||
- `show <instanceId>` — show one instance.
|
||||
- `trace <instanceId>` — show the instance + its append-only step trail (audit-only).
|
||||
- `continue <instanceId>` — idempotent inspector; in v1 returns the current state.
|
||||
|
||||
The owner-as-author + workflow-as-transactional-scribe contract is enforced by the daemon. The owner of a packet decides when it closes; the workflow runtime atomically records the closure AND creates/projects the next qitem per the workflow spec, in a single daemon transaction.
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
|
||||
@@ -94,9 +94,13 @@ Fast lookup:
|
||||
- `packages/daemon/src/db/migrations/030_views_custom.ts`: PL-004 Phase B — custom view registry. Exports: viewsCustomSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/031_watchdog_jobs.ts`: PL-004 Phase C — watchdog jobs (canonical scheduler state; policy enum + intervals + lifecycle). Exports: watchdogJobsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/032_watchdog_history.ts`: PL-004 Phase C — append-only watchdog evaluation history (sent/skipped/terminal; not_due polls not recorded). Exports: watchdogHistorySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/033_workflow_specs.ts`: PL-004 Phase D — workflow specs (cached read-through from markdown/YAML; UNIQUE on name+version; source_hash invalidation). Exports: workflowSpecsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Enforces three-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required) at register-time; rejects workflow-keepalive with policy_deferred_to_phase_d. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_C_POLICIES, WORKFLOW_KEEPALIVE_DEFERRED_POLICY. Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
- `packages/daemon/src/domain/watchdog-history-log.ts`: Append-only writer for watchdog_history. Records meaningful evaluations only (sent/skipped/terminal). API surface intentionally lacks update/delete. Exports: WatchdogHistoryLog, WatchdogHistoryEntry, WatchdogOutcome. Related: `packages/daemon/src/db/migrations/032_watchdog_history.js`.
|
||||
- `packages/daemon/src/domain/watchdog-policy-engine.ts`: Resolves policy by name, parses spec_yaml context, dispatches policy.evaluate(), records meaningful outcomes via history-log, emits watchdog.* events, calls injected delivery callback for action=send. Exports: WatchdogPolicyEngine, DeliveryFn, DeliveryRequest, DeliveryOutcome. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/watchdog-scheduler.ts`: Tick-driven daemon-native scheduler that joins the supervision tree. Filters listActive() through isDue(), evaluates due jobs sequentially, supports start/stop with graceful in-flight await. Exports: WatchdogScheduler, isDue. Related: `packages/daemon/src/domain/watchdog-policy-engine.js`, `packages/daemon/src/domain/watchdog-jobs-repository.js`.
|
||||
@@ -106,6 +110,17 @@ Fast lookup:
|
||||
- `packages/daemon/src/domain/policies/edge-artifact-required.ts`: Policy that scans source_pools and target_pools, computes source-key per artifact, and sends an edge-repair message for sources lacking matching downstream artifacts. Skips with no_missing_edge_artifacts when all sources matched. Translated from POC `lib/policies/edge-artifact-required.mjs`. Exports: edgeArtifactRequiredPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-helpers.ts`: Shared filesystem scanner + formatter for artifact-pool-ready and edge-artifact-required. Translated from POC `lib/policies/artifact-pool.js`. Exports: scanArtifactPools, formatArtifactList, sourceKeyFor, ArtifactPoolSpec, ScannedArtifact. Related: none.
|
||||
- `packages/daemon/src/routes/watchdog.ts`: Hono route module for `/api/watchdog` (`POST /register`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:job_id`, `GET /:job_id/status`, `POST /:job_id/stop`). SSE/literal paths mounted before /:job_id catchall per Phase A R1 route-order discipline. Exports: watchdogRoutes. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/policies/workflow-keepalive.ts`: PL-004 Phase D — workflow-keepalive policy (carry-forward from Phase C deferral). Reads workflow_instances directly via SQLite (audit row 18 LOAD-BEARING); requires status active|waiting; resolves frontier qitem owners via queue_items. Translated from POC `lib/policies/workflow-keepalive.mjs`; source-of-truth switched from markdown to SQLite. Exports: makeWorkflowKeepalivePolicy. Related: `packages/daemon/src/domain/policies/types.js`, `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
|
||||
### Domain: Workflow Runtime (PL-004 Phase D)
|
||||
- `packages/daemon/src/domain/workflow-types.ts`: Shared types (WorkflowSpec, WorkflowInstance, WorkflowStepTrailEntry, WorkflowExitKind). Reverse-engineered from POC `workflow-runtime-v0/lib/workflow-runtime.rb` + fixture YAMLs. Exports: WorkflowSpec, WorkflowStepSpec, WorkflowRoleSpec, WorkflowInvariants, WorkflowClosureMessages, WorkflowLoopGuards, WorkflowInstance, WorkflowInstanceStatus, WorkflowStepTrailEntry, WorkflowExitKind, WorkflowSpecRow. Related: none.
|
||||
- `packages/daemon/src/domain/workflow-spec-cache.ts`: Read-through cache from markdown/YAML spec files into workflow_specs table. Workspace-surface reconciliation: source_hash invalidates the cache on next read; valid operator edits to spec files win. Uses the `yaml` package for parsing. Exports: WorkflowSpecCache, WorkflowSpecError, parseWorkflowSpec. Related: `packages/daemon/src/db/migrations/033_workflow_specs.js`.
|
||||
- `packages/daemon/src/domain/workflow-validator.ts`: Validates a workflow spec against role resolution, step uniqueness, allowed-exits consistency, and (optional via injected callback) seat liveness. Returns structured ValidationResult with severity-tagged issues. Exports: WorkflowValidator, ValidationIssue, ValidationResult, SeatLivenessCheckFn. Related: `packages/daemon/src/domain/workflow-types.js`.
|
||||
- `packages/daemon/src/domain/workflow-instance-store.ts`: CRUD on workflow_instances + frontier tracking. Survives daemon restart (queries SQLite directly). Exports: WorkflowInstanceStore, WorkflowInstanceError, CreateWorkflowInstanceInput. Related: `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
- `packages/daemon/src/domain/workflow-step-trail-log.ts`: Append-only writer/reader for workflow_step_trails. API surface lacks update/delete (contract enforcement). Mirrors Phase A's queue-transition-log + Phase C's watchdog-history-log shape. Exports: WorkflowStepTrailLog, WorkflowStepTrailRecordInput. Related: `packages/daemon/src/db/migrations/035_workflow_step_trails.js`.
|
||||
- `packages/daemon/src/domain/workflow-projector.ts`: LOAD-BEARING. Implements the transactional-scribe contract: closing a workflow_instances frontier qitem AND creating the next-step queue_items row AND appending the workflow_step_trails entry AND updating instance frontier+status all happen in the SAME daemon transaction (db.transaction). If any side throws, all roll back. Lost handoffs are impossible by design. Post-commit, fans out persisted events + nudges next owner. Uses Phase A QueueRepository.createWithinTransaction() (Phase D extension point). Exports: WorkflowProjector, WorkflowProjectorError, ProjectStepInput, ProjectStepResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/workflow-instance-store.js`, `packages/daemon/src/domain/workflow-step-trail-log.js`.
|
||||
- `packages/daemon/src/domain/workflow-runtime.ts`: Facade over spec-cache + validator + instance-store + projector + step-trail-log. Operations: validate, instantiate (creates instance + entry qitem in same transaction), project (delegates to projector), continue (idempotent inspector). Exports: WorkflowRuntime, WorkflowRuntimeDeps, InstantiateInput, InstantiateResult, WorkflowInstanceError, WorkflowProjectorError, WorkflowSpecError. Related: `packages/daemon/src/domain/workflow-projector.js`, `packages/daemon/src/domain/workflow-spec-cache.js`.
|
||||
- `packages/daemon/src/routes/workflow.ts`: Hono route module for `/api/workflow` (`POST /validate`, `POST /instantiate`, `POST /project`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:instance_id`, `GET /:instance_id/trace`, `POST /:instance_id/continue`). SSE/literal paths mounted before /:instance_id catchall per Phase A R1 route-order discipline. Exports: workflowRoutes. Related: `packages/daemon/src/domain/workflow-runtime.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
|
||||
### Domain: Core Runtime
|
||||
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig workflow` — daemon-native Workflow Runtime (PL-004 Phase D).
|
||||
*
|
||||
* Backed by `/api/workflow`. Operates only via the daemon HTTP API.
|
||||
*
|
||||
* Per PRD § L4 Workflow Runtime: workflow specs are markdown/YAML
|
||||
* authoritative, daemon caches a read-through copy. Owner-as-author
|
||||
* + workflow-as-transactional-scribe: owner closure + next-qitem
|
||||
* projection happen in the SAME daemon transaction. Lost handoffs
|
||||
* are impossible by design.
|
||||
*/
|
||||
|
||||
export interface WorkflowDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: WorkflowDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
const cmd = new Command("workflow").description(
|
||||
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
|
||||
);
|
||||
const getDeps = (): WorkflowDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (specPath: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/validate", { specPath });
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("instantiate <specPath>")
|
||||
.description("Create a workflow instance + entry-step qitem from a spec")
|
||||
.requiredOption("--root-objective <text>", "Root objective for the run")
|
||||
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
|
||||
.option("--entry-owner <session>", "Override default entry-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (specPath: string, opts: {
|
||||
rootObjective: string;
|
||||
createdBy: string;
|
||||
entryOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/instantiate", {
|
||||
specPath,
|
||||
rootObjective: opts.rootObjective,
|
||||
createdBySession: opts.createdBy,
|
||||
entryOwnerSession: opts.entryOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("project")
|
||||
.description("Close a current packet AND project the next-step packet (transactional-scribe; one daemon transaction)")
|
||||
.requiredOption("--instance <id>", "Workflow instance id")
|
||||
.requiredOption("--current-packet <qitem-id>", "qitem being closed (must be on instance frontier)")
|
||||
.requiredOption("--exit <kind>", "Closure exit kind: handoff | waiting | done | failed")
|
||||
.requiredOption("--actor-session <session>", "Session closing the packet (owner-as-author)")
|
||||
.option("--result-note <text>", "Closure result note (audit context)")
|
||||
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
|
||||
.option("--next-owner <session>", "Override default next-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
instance: string;
|
||||
currentPacket: string;
|
||||
exit: "handoff" | "waiting" | "done" | "failed";
|
||||
actorSession: string;
|
||||
resultNote?: string;
|
||||
blockedOn?: string;
|
||||
nextOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/project", {
|
||||
instanceId: opts.instance,
|
||||
currentPacketId: opts.currentPacket,
|
||||
exit: opts.exit,
|
||||
actorSession: opts.actorSession,
|
||||
resultNote: opts.resultNote,
|
||||
blockedOn: opts.blockedOn,
|
||||
nextOwnerSession: opts.nextOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List workflow instances; optionally filter by status")
|
||||
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { status?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
const qs = params.toString();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <instanceId>")
|
||||
.description("Show one workflow instance")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("trace <instanceId>")
|
||||
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/trace`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("continue <instanceId>")
|
||||
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { queueCommand, type QueueDeps } from "./commands/queue.js";
|
||||
import { projectCommand, type ProjectDeps } from "./commands/project.js";
|
||||
import { viewCommand, type ViewDeps } from "./commands/view.js";
|
||||
import { watchdogCommand, type WatchdogDeps } from "./commands/watchdog.js";
|
||||
import { workflowCommand, type WorkflowDeps } from "./commands/workflow.js";
|
||||
import { captureCommand } from "./commands/capture.js";
|
||||
import { broadcastCommand } from "./commands/broadcast.js";
|
||||
import { configCommand } from "./commands/config.js";
|
||||
@@ -86,6 +87,7 @@ export interface ProgramDeps {
|
||||
projectDeps?: ProjectDeps;
|
||||
viewDeps?: ViewDeps;
|
||||
watchdogDeps?: WatchdogDeps;
|
||||
workflowDeps?: WorkflowDeps;
|
||||
captureDeps?: StatusDeps;
|
||||
broadcastDeps?: StatusDeps;
|
||||
askDeps?: StatusDeps;
|
||||
@@ -143,6 +145,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(projectCommand(depsOverride?.projectDeps));
|
||||
program.addCommand(viewCommand(depsOverride?.viewDeps));
|
||||
program.addCommand(watchdogCommand(depsOverride?.watchdogDeps));
|
||||
program.addCommand(workflowCommand(depsOverride?.workflowDeps));
|
||||
program.addCommand(captureCommand(depsOverride?.captureDeps));
|
||||
program.addCommand(broadcastCommand(depsOverride?.broadcastDeps));
|
||||
program.addCommand(askCommand(depsOverride?.askDeps));
|
||||
|
||||
@@ -135,21 +135,24 @@ describe("rig watchdog CLI (PL-004 Phase C)", () => {
|
||||
expect(body.registeredBySession).toBe("ops@kernel");
|
||||
});
|
||||
|
||||
it("register propagates daemon-side workflow-keepalive rejection (Phase D deferral surfaced via 400)", async () => {
|
||||
const { deps } = makeDeps({
|
||||
// PL-004 Phase D: registration-rejection assertion REPLACED with
|
||||
// positive registration-accept. workflow-keepalive is now an
|
||||
// accepted policy.
|
||||
it("register accepts workflow-keepalive (Phase D enum extension surfaces 201 + job_id)", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: {
|
||||
"POST /api/watchdog/register": {
|
||||
status: 400,
|
||||
data: {
|
||||
error: "policy_deferred_to_phase_d",
|
||||
message: "policy 'workflow-keepalive' ships in PL-004 Phase D",
|
||||
},
|
||||
status: 201,
|
||||
data: { jobId: "01J...", policy: "workflow-keepalive", state: "active" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const tmp = mkdtempSync(join(tmpdir(), "wd-cli-"));
|
||||
const spec = join(tmp, "spec.yaml");
|
||||
writeFileSync(spec, "policy: workflow-keepalive\n");
|
||||
writeFileSync(
|
||||
spec,
|
||||
"policy: workflow-keepalive\ntarget:\n session: alice@rig\ninterval_seconds: 1800\ncontext:\n workflow_instance_id: 01ABC\n",
|
||||
);
|
||||
const program = createProgram({ watchdogDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
@@ -164,13 +167,17 @@ describe("rig watchdog CLI (PL-004 Phase C)", () => {
|
||||
"--target-session",
|
||||
"alice@rig",
|
||||
"--interval-seconds",
|
||||
"60",
|
||||
"1800",
|
||||
"--registered-by",
|
||||
"ops@kernel",
|
||||
"--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(logs.some((l) => l.includes("policy_deferred_to_phase_d"))).toBe(true);
|
||||
const call = calls.find((c) => c.path === "/api/watchdog/register");
|
||||
expect(call).toBeDefined();
|
||||
const body = call!.body as Record<string, unknown>;
|
||||
expect(body.policy).toBe("workflow-keepalive");
|
||||
// exit code unchanged since 201 is success.
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("list GETs /api/watchdog/list", async () => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { WorkflowDeps } from "../src/commands/workflow.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({
|
||||
state: "running",
|
||||
healthy: true,
|
||||
pid: 1234,
|
||||
port: 7433,
|
||||
})),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface StubResponse {
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
function makeDeps(opts?: {
|
||||
routes?: Record<string, StubResponse>;
|
||||
}): {
|
||||
deps: WorkflowDeps;
|
||||
calls: Array<{ method: string; path: string; body?: unknown }>;
|
||||
} {
|
||||
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
||||
const routes = opts?.routes ?? {};
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
lifecycleDeps: {} as WorkflowDeps["lifecycleDeps"],
|
||||
clientFactory: () =>
|
||||
({
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
return routes[`GET ${path}`] ?? { status: 200, data: {} };
|
||||
}),
|
||||
getText: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
return { status: 200, data: "" };
|
||||
}),
|
||||
post: vi.fn(async (path: string, body: unknown) => {
|
||||
calls.push({ method: "POST", path, body });
|
||||
return routes[`POST ${path}`] ?? { status: 200, data: {} };
|
||||
}),
|
||||
delete: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "DELETE", path });
|
||||
return { status: 204, data: null };
|
||||
}),
|
||||
postText: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "POST", path });
|
||||
return { status: 200, data: "" };
|
||||
}),
|
||||
postExpectText: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "POST", path });
|
||||
return { status: 200, data: "" };
|
||||
}),
|
||||
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("rig workflow CLI (PL-004 Phase D)", () => {
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("workflow command is registered with all 7 subcommands", () => {
|
||||
const { deps } = makeDeps();
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
const cmd = program.commands.find((c) => c.name() === "workflow");
|
||||
expect(cmd).toBeDefined();
|
||||
const subs = cmd!.commands.map((c) => c.name()).sort();
|
||||
expect(subs).toContain("validate");
|
||||
expect(subs).toContain("instantiate");
|
||||
expect(subs).toContain("project");
|
||||
expect(subs).toContain("list");
|
||||
expect(subs).toContain("show");
|
||||
expect(subs).toContain("trace");
|
||||
expect(subs).toContain("continue");
|
||||
});
|
||||
|
||||
it("validate POSTs /api/workflow/validate with specPath", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/workflow/validate": { status: 200, data: { ok: true } } },
|
||||
});
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "workflow", "validate", "/abs/path.yaml", "--json"]);
|
||||
const call = calls.find((c) => c.path === "/api/workflow/validate");
|
||||
expect((call!.body as { specPath: string }).specPath).toBe("/abs/path.yaml");
|
||||
});
|
||||
|
||||
it("instantiate POSTs /api/workflow/instantiate with required fields", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/workflow/instantiate": { status: 201, data: { instance: {}, entryQitemId: "q-1" } } },
|
||||
});
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "workflow", "instantiate", "/abs/spec.yaml",
|
||||
"--root-objective", "test obj",
|
||||
"--created-by", "ops@rig",
|
||||
"--entry-owner", "alice@rig",
|
||||
"--json",
|
||||
]);
|
||||
const call = calls.find((c) => c.path === "/api/workflow/instantiate");
|
||||
const body = call!.body as Record<string, string>;
|
||||
expect(body.specPath).toBe("/abs/spec.yaml");
|
||||
expect(body.rootObjective).toBe("test obj");
|
||||
expect(body.createdBySession).toBe("ops@rig");
|
||||
expect(body.entryOwnerSession).toBe("alice@rig");
|
||||
});
|
||||
|
||||
it("project POSTs /api/workflow/project with all fields incl exit", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/workflow/project": { status: 200, data: { nextQitemId: "q-2" } } },
|
||||
});
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "workflow", "project",
|
||||
"--instance", "inst-1",
|
||||
"--current-packet", "q-1",
|
||||
"--exit", "handoff",
|
||||
"--actor-session", "producer@rig",
|
||||
"--result-note", "produced",
|
||||
"--json",
|
||||
]);
|
||||
const call = calls.find((c) => c.path === "/api/workflow/project");
|
||||
const body = call!.body as Record<string, string>;
|
||||
expect(body.instanceId).toBe("inst-1");
|
||||
expect(body.currentPacketId).toBe("q-1");
|
||||
expect(body.exit).toBe("handoff");
|
||||
expect(body.actorSession).toBe("producer@rig");
|
||||
expect(body.resultNote).toBe("produced");
|
||||
});
|
||||
|
||||
it("list GETs /api/workflow/list optionally with status filter", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/workflow/list?status=active": { status: 200, data: [] } },
|
||||
});
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "workflow", "list", "--status", "active", "--json"]);
|
||||
expect(calls.find((c) => c.path === "/api/workflow/list?status=active")).toBeDefined();
|
||||
});
|
||||
|
||||
it("show GETs /api/workflow/:instanceId", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/workflow/inst-1": { status: 200, data: { instanceId: "inst-1" } } },
|
||||
});
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "workflow", "show", "inst-1", "--json"]);
|
||||
expect(calls.find((c) => c.path === "/api/workflow/inst-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("trace GETs /api/workflow/:instanceId/trace", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/workflow/inst-1/trace": { status: 200, data: { instance: {}, trail: [] } } },
|
||||
});
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "workflow", "trace", "inst-1", "--json"]);
|
||||
expect(calls.find((c) => c.path === "/api/workflow/inst-1/trace")).toBeDefined();
|
||||
});
|
||||
|
||||
it("continue POSTs /api/workflow/:instanceId/continue", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/workflow/inst-1/continue": { status: 200, data: { instance: {}, trail: [] } } },
|
||||
});
|
||||
const program = createProgram({ workflowDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "workflow", "continue", "inst-1", "--json"]);
|
||||
expect(calls.find((c) => c.path === "/api/workflow/inst-1/continue")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Workflow specs (PL-004 Phase D; daemon-native Workflow Runtime).
|
||||
*
|
||||
* Per PRD § L4 Workflow Runtime: workflow specs are markdown/YAML
|
||||
* authoritative — the daemon caches a read-through copy here so that
|
||||
* lookups are fast and instances can resolve the spec at runtime
|
||||
* even if the source file moves. source_hash invalidates the cache
|
||||
* on next read; valid operator edits to the spec file win
|
||||
* (workspace-surface reconciliation contract).
|
||||
*
|
||||
* Columns:
|
||||
* - spec_id (ULID PK)
|
||||
* - name (workflow.id from spec yaml)
|
||||
* - version (workflow.version)
|
||||
* - purpose (workflow.objective)
|
||||
* - target_rig (workflow.target.rig)
|
||||
* - roles_json (serialized roles[] map)
|
||||
* - steps_json (serialized steps[] array)
|
||||
* - coordination_terminal_turn_rule (default `hot_potato`)
|
||||
* - source_path (markdown/yaml spec file path on disk)
|
||||
* - source_hash (content hash for invalidation detection)
|
||||
* - cached_at (ISO timestamp; latest read-through cache stamp)
|
||||
*
|
||||
* Unique constraint on (name, version) — a workflow spec at a given
|
||||
* version is canonical; re-caching the same spec content updates
|
||||
* cached_at + source_hash without inserting a duplicate row.
|
||||
*/
|
||||
export const workflowSpecsSchema: Migration = {
|
||||
name: "033_workflow_specs.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS workflow_specs (
|
||||
spec_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
purpose TEXT,
|
||||
target_rig TEXT,
|
||||
roles_json TEXT NOT NULL,
|
||||
steps_json TEXT NOT NULL,
|
||||
coordination_terminal_turn_rule TEXT NOT NULL DEFAULT 'hot_potato',
|
||||
source_path TEXT NOT NULL,
|
||||
source_hash TEXT NOT NULL,
|
||||
cached_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_specs_name_version
|
||||
ON workflow_specs(name, version);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_specs_target_rig
|
||||
ON workflow_specs(target_rig);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Workflow instances (PL-004 Phase D; live workflow state).
|
||||
*
|
||||
* Per PRD § L4 Workflow Runtime: a workflow instance is a SQLite-canonical
|
||||
* record of a running workflow. Survives daemon restart without
|
||||
* filesystem reconciliation. The current_frontier_json column holds the
|
||||
* array of qitem_ids that are the active step packets at any given time.
|
||||
*
|
||||
* Status enum (matches POC + PRD):
|
||||
* - active: instance has at least one frontier packet in-flight
|
||||
* - waiting: instance has a frontier packet blocked on an external gate
|
||||
* - completed: terminal success state; current_frontier_json is []
|
||||
* - failed: terminal failure state
|
||||
*
|
||||
* fallback_synthesis: nullable text recording the agent-judgment
|
||||
* synthesis used when peer continuity relay was not available (closed
|
||||
* but no successor identified). Optional audit context.
|
||||
*
|
||||
* hop_count: count of step transitions executed; PRD invariant
|
||||
* `loop_guards.max_hops` is checked at projection time.
|
||||
*
|
||||
* last_continuation_decision_json: optional structured record of the
|
||||
* most recent continuation/closure decision (the `handoff_decision`,
|
||||
* `wait_decision`, or `close_decision` shape from the POC). Used for
|
||||
* trace and debugging.
|
||||
*
|
||||
* Indexes:
|
||||
* - (status, workflow_name) — fast "find active instances of workflow X"
|
||||
* - (created_by_session, status) — fast "find my live workflows"
|
||||
*/
|
||||
export const workflowInstancesSchema: Migration = {
|
||||
name: "034_workflow_instances.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS workflow_instances (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
workflow_name TEXT NOT NULL,
|
||||
workflow_version TEXT NOT NULL,
|
||||
created_by_session TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
current_frontier_json TEXT NOT NULL DEFAULT '[]',
|
||||
hop_count INTEGER NOT NULL DEFAULT 0,
|
||||
fallback_synthesis TEXT,
|
||||
last_continuation_decision_json TEXT,
|
||||
completed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_instances_status_name
|
||||
ON workflow_instances(status, workflow_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_instances_creator_status
|
||||
ON workflow_instances(created_by_session, status);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Workflow step trails (PL-004 Phase D; append-only history).
|
||||
*
|
||||
* Per PRD § L4 Workflow Runtime: every workflow step transition records
|
||||
* a trail entry. Append-only: writers only INSERT. UPDATE/DELETE are
|
||||
* not exposed by WorkflowStepTrailLog API; direct SQL would succeed
|
||||
* (SQLite has no view/role layer) but is a contract violation enforced
|
||||
* at the domain-layer API boundary.
|
||||
*
|
||||
* Columns:
|
||||
* - trail_id (ULID PK)
|
||||
* - instance_id (FK to workflow_instances)
|
||||
* - step_id (from spec; e.g., "produce", "review-convergence")
|
||||
* - step_role (from spec; e.g., "producer", "orchestrator")
|
||||
* - closed_at (ISO timestamp of the closure/transition)
|
||||
* - closure_reason (enum: "handoff", "waiting", "done", "failed")
|
||||
* - closure_evidence_json (operator-supplied evidence + system-derived
|
||||
* audit context; JSON-encoded)
|
||||
* - actor_session (the session that closed the packet — owner-as-author)
|
||||
* - next_qitem_id (FK to queue_items; null on terminal closure)
|
||||
* - prior_qitem_id (the closed packet; FK to queue_items)
|
||||
*
|
||||
* Indexes:
|
||||
* - (instance_id, closed_at DESC) — fast "trail for one workflow instance"
|
||||
* - (closed_at DESC) — global recent activity
|
||||
*/
|
||||
export const workflowStepTrailsSchema: Migration = {
|
||||
name: "035_workflow_step_trails.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS workflow_step_trails (
|
||||
trail_id TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL REFERENCES workflow_instances(instance_id),
|
||||
step_id TEXT NOT NULL,
|
||||
step_role TEXT NOT NULL,
|
||||
closed_at TEXT NOT NULL,
|
||||
closure_reason TEXT NOT NULL,
|
||||
closure_evidence_json TEXT,
|
||||
actor_session TEXT NOT NULL,
|
||||
next_qitem_id TEXT REFERENCES queue_items(qitem_id),
|
||||
prior_qitem_id TEXT REFERENCES queue_items(qitem_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_step_trails_instance_recent
|
||||
ON workflow_step_trails(instance_id, closed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_step_trails_recent
|
||||
ON workflow_step_trails(closed_at DESC);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Watchdog policy enum extension (PL-004 Phase D).
|
||||
*
|
||||
* DOCUMENTING NO-OP MIGRATION.
|
||||
*
|
||||
* Phase C implements the watchdog_jobs.policy enum at the application
|
||||
* layer (in WatchdogJobsRepository.register() via the PHASE_C_POLICIES
|
||||
* array + WORKFLOW_KEEPALIVE_DEFERRED_POLICY constant). The SQLite
|
||||
* column itself is plain TEXT with no CHECK constraint, so extending
|
||||
* the enum to include "workflow-keepalive" requires NO schema change.
|
||||
*
|
||||
* The actual enforcement extension lives in:
|
||||
* packages/daemon/src/domain/watchdog-jobs-repository.ts
|
||||
* - PHASE_C_POLICIES → renamed to PHASE_D_POLICIES (or extended)
|
||||
* to include "workflow-keepalive"
|
||||
* - WORKFLOW_KEEPALIVE_DEFERRED_POLICY rejection removed
|
||||
*
|
||||
* This migration exists for two reasons:
|
||||
* 1. Audit trail: documents that Phase D extended the policy enum
|
||||
* surface, even though no DDL was needed.
|
||||
* 2. Migration sequence integrity: keeps Phase D's migration count
|
||||
* visible in the daemon startup log alongside the three new
|
||||
* workflow tables (033, 034, 035). Slice IMPL row 17 explicitly
|
||||
* requires the extension migration land "cleanly without
|
||||
* touching Phase C's existing migration files" — this no-op
|
||||
* satisfies that constraint.
|
||||
*
|
||||
* Per slice IMPL § Write Set:
|
||||
* "if the enforcement is application-layer in
|
||||
* `watchdog-policy-engine.ts`, the migration is a no-op SQL but
|
||||
* documents the policy enum intent."
|
||||
*/
|
||||
export const watchdogPolicyEnumExtensionSchema: Migration = {
|
||||
name: "036_watchdog_policy_enum_extension.sql",
|
||||
sql: `
|
||||
-- No DDL: Phase C policy enum is enforced at the application layer
|
||||
-- via PHASE_D_POLICIES in watchdog-jobs-repository.ts. The
|
||||
-- watchdog_jobs.policy column remains plain TEXT. This migration
|
||||
-- exists as an audit-trail marker for the Phase D enum extension.
|
||||
SELECT 1 WHERE 0;
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
// PL-004 Phase D: workflow-keepalive policy (TypeScript port of POC
|
||||
// `lib/policies/workflow-keepalive.mjs`, adapted to read SQLite
|
||||
// workflow_instances directly per audit row 18).
|
||||
//
|
||||
// LOAD-BEARING: workflow-keepalive MUST read from the SQLite
|
||||
// workflow_instances table only. NO filesystem read of markdown
|
||||
// workflow runtime at the daemon policy layer. (Audit row 18.)
|
||||
//
|
||||
// POC contract preserved (semantic; mechanism switched from markdown
|
||||
// frontmatter to SQLite columns):
|
||||
// - Eligibility: status === "active" || status === "waiting".
|
||||
// Else: action=terminal, reason="workflow_not_active". (POC sets
|
||||
// terminal:true on the skip; Phase C engine has a separate
|
||||
// terminal action for the same effect.)
|
||||
// - Frontier empty + no fallback target: skip with reason "empty_frontier".
|
||||
// - Resolve frontier qitem owners by querying queue_items table.
|
||||
// - Combine resolved + explicit additional targets:
|
||||
// - workflow.created_by_session
|
||||
// - context.observer_sessions[]
|
||||
// - context.observer_session
|
||||
// - job.target.session (the registered target as fallback)
|
||||
// - Send to the FIRST resolved target. v1 single-target only;
|
||||
// additional resolved owners listed in the message for routing.
|
||||
// POC's send_many is deferred (Phase C engine's PolicyEvaluation
|
||||
// contract is single-target).
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import type { Policy, PolicyEvaluation, PolicyJob } from "./types.js";
|
||||
|
||||
interface WorkflowKeepaliveContext {
|
||||
/** ULID of the workflow_instances row to keep alive. Required. */
|
||||
workflow_instance_id?: string;
|
||||
observer_session?: string;
|
||||
observer_sessions?: string[];
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
instance_id: string;
|
||||
workflow_name: string;
|
||||
workflow_version: string;
|
||||
created_by_session: string;
|
||||
status: string;
|
||||
current_frontier_json: string;
|
||||
}
|
||||
|
||||
interface QueueOwnerRow {
|
||||
qitem_id: string;
|
||||
destination_session: string;
|
||||
}
|
||||
|
||||
export interface WorkflowKeepaliveDeps {
|
||||
db: Database.Database;
|
||||
}
|
||||
|
||||
export function makeWorkflowKeepalivePolicy(deps: WorkflowKeepaliveDeps): Policy {
|
||||
const { db } = deps;
|
||||
|
||||
return {
|
||||
name: "workflow-keepalive",
|
||||
async evaluate(job: PolicyJob): Promise<PolicyEvaluation> {
|
||||
const context = job.context as WorkflowKeepaliveContext;
|
||||
const instanceId = context.workflow_instance_id;
|
||||
if (!instanceId) {
|
||||
throw Object.assign(
|
||||
new Error("workflow-keepalive: context.workflow_instance_id is required"),
|
||||
{
|
||||
code: "policy_spec_invalid",
|
||||
policy: "workflow-keepalive",
|
||||
field: "context.workflow_instance_id",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Audit-row-18 critical assertion: read from SQLite only.
|
||||
const instance = db
|
||||
.prepare(
|
||||
`SELECT instance_id, workflow_name, workflow_version, created_by_session,
|
||||
status, current_frontier_json
|
||||
FROM workflow_instances WHERE instance_id = ?`,
|
||||
)
|
||||
.get(instanceId) as InstanceRow | undefined;
|
||||
|
||||
if (!instance) {
|
||||
return {
|
||||
action: "terminal",
|
||||
reason: "workflow_instance_missing",
|
||||
notes: { instanceId },
|
||||
};
|
||||
}
|
||||
|
||||
const eligible = instance.status === "active" || instance.status === "waiting";
|
||||
if (!eligible) {
|
||||
return {
|
||||
action: "terminal",
|
||||
reason: "workflow_not_active",
|
||||
notes: { instanceId, status: instance.status },
|
||||
};
|
||||
}
|
||||
|
||||
const frontier = (JSON.parse(instance.current_frontier_json) as string[]) ?? [];
|
||||
|
||||
// Resolve frontier qitem owners from queue_items.
|
||||
const resolvedFrontierOwners: string[] = [];
|
||||
if (frontier.length > 0) {
|
||||
const placeholders = frontier.map(() => "?").join(",");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT qitem_id, destination_session FROM queue_items WHERE qitem_id IN (${placeholders})`,
|
||||
)
|
||||
.all(...frontier) as QueueOwnerRow[];
|
||||
for (const r of rows) resolvedFrontierOwners.push(r.destination_session);
|
||||
}
|
||||
|
||||
// Combine with explicit additional targets.
|
||||
const additionalTargets: string[] = [
|
||||
instance.created_by_session,
|
||||
...((context.observer_sessions ?? []) as string[]),
|
||||
...(context.observer_session ? [context.observer_session] : []),
|
||||
...(job.target?.session ? [job.target.session] : []),
|
||||
].filter(Boolean);
|
||||
|
||||
const allSessions = Array.from(new Set([...resolvedFrontierOwners, ...additionalTargets]));
|
||||
|
||||
if (allSessions.length === 0) {
|
||||
// POC line 113-118: skip with empty_frontier when nothing resolves.
|
||||
return { action: "skip", reason: "empty_frontier" };
|
||||
}
|
||||
|
||||
const primary = allSessions[0]!;
|
||||
const others = allSessions.slice(1);
|
||||
const message =
|
||||
job.message ??
|
||||
buildKeepaliveMessage({
|
||||
workflowName: instance.workflow_name,
|
||||
workflowVersion: instance.workflow_version,
|
||||
instanceId: instance.instance_id,
|
||||
status: instance.status,
|
||||
allSessions,
|
||||
});
|
||||
|
||||
return {
|
||||
action: "send",
|
||||
target: { session: primary },
|
||||
message,
|
||||
notes: {
|
||||
instanceId: instance.instance_id,
|
||||
workflowName: instance.workflow_name,
|
||||
workflowStatus: instance.status,
|
||||
frontierLength: frontier.length,
|
||||
additionalRoutingTargets: others,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const POC_KEEPALIVE_TRAILER =
|
||||
"Continue the current step. If the run has ended, update the workflow state honestly and manufacture the next truthful packet now. " +
|
||||
"If you think you need approval, first ask whether there is real product ambiguity or only approval theater. " +
|
||||
"Keep communications bridged, name exact blockers, and self-scout deterministic bias: " +
|
||||
"add only the minimum deterministic code required for reliable agent operation, then rely on agent judgment for routing, adaptation, and edge handling.";
|
||||
|
||||
function buildKeepaliveMessage(input: {
|
||||
workflowName: string;
|
||||
workflowVersion: string;
|
||||
instanceId: string;
|
||||
status: string;
|
||||
allSessions: string[];
|
||||
}): string {
|
||||
const lines = [
|
||||
`Workflow keepalive: ${input.workflowName}@${input.workflowVersion} / ${input.instanceId} is still live (status: ${input.status}).`,
|
||||
POC_KEEPALIVE_TRAILER,
|
||||
];
|
||||
if (input.allSessions.length > 1) {
|
||||
lines.push("", `Other frontier owners + observers: ${input.allSessions.slice(1).join(", ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { PersistedEvent } from "./types.js";
|
||||
import { QueueTransitionLog } from "./queue-transition-log.js";
|
||||
import {
|
||||
computeClosureRequiredAt,
|
||||
@@ -226,8 +227,12 @@ export class QueueRepository {
|
||||
* handoff-and-complete commit. Records the result via recordNudgeAttempt.
|
||||
* Errors are caught and surfaced as nudge_result strings — they do not
|
||||
* unwind the underlying queue mutation.
|
||||
*
|
||||
* Phase D extension point (orch-ratified): public so workflow-projector
|
||||
* can invoke after its outer transaction commits, completing the
|
||||
* createWithinTransaction()'s deferred post-commit side effects.
|
||||
*/
|
||||
private async maybeNudge(
|
||||
async maybeNudge(
|
||||
qitemId: string,
|
||||
destinationSession: string,
|
||||
nudgeOpt: boolean | undefined,
|
||||
@@ -255,6 +260,67 @@ export class QueueRepository {
|
||||
);
|
||||
}
|
||||
|
||||
const txn = this.db.transaction(() => this.createInTransactionalContext(input));
|
||||
const { qitemId: id, persistedEvent } = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
await this.maybeNudge(id, input.destinationSession, input.nudge);
|
||||
return this.getByIdOrThrow(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PL-004 Phase D extension point (orch-ratified per slice IMPL §
|
||||
* Driver Handoff Contract). Creates a queue item using the SAME
|
||||
* caller-managed db.transaction for transactional-scribe semantics
|
||||
* (workflow-projector folds step closure + next-qitem creation into
|
||||
* one atomic unit). Returns the persisted event AND qitem id so the
|
||||
* caller can defer notifySubscribers/maybeNudge until AFTER its
|
||||
* outer transaction commits.
|
||||
*
|
||||
* Caller MUST:
|
||||
* 1. Invoke this from inside a `db.transaction(() => {...})` block.
|
||||
* 2. After the outer txn commits, call:
|
||||
* - eventBus.notifySubscribers(persistedEvent)
|
||||
* - this.maybeNudge(qitemId, destinationSession, input.nudge)
|
||||
* 3. NOT call this from outside a transaction (will produce a
|
||||
* half-state if the caller errors before committing).
|
||||
*
|
||||
* The split exists ONLY because notifySubscribers + maybeNudge are
|
||||
* post-commit side effects (subscribers should not see events for
|
||||
* data that may roll back; nudges should not fire for handoffs that
|
||||
* may roll back). For independent create()s that don't need to
|
||||
* compose with an outer transaction, use create() instead.
|
||||
*/
|
||||
createWithinTransaction(input: QueueCreateInput): {
|
||||
qitemId: string;
|
||||
persistedEvent: PersistedEvent;
|
||||
destinationSession: string;
|
||||
nudge: boolean | undefined;
|
||||
} {
|
||||
if (!this.validateRig(input.destinationSession)) {
|
||||
throw new QueueRepositoryError(
|
||||
"unknown_destination_rig",
|
||||
`destination_session ${input.destinationSession} references an unknown rig`
|
||||
);
|
||||
}
|
||||
const result = this.createInTransactionalContext(input);
|
||||
return {
|
||||
qitemId: result.qitemId,
|
||||
persistedEvent: result.persistedEvent,
|
||||
destinationSession: input.destinationSession,
|
||||
nudge: input.nudge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: insert + transition + emit event. Caller is responsible
|
||||
* for transaction wrapping (the public create() wraps; the public
|
||||
* createWithinTransaction() does not — caller's outer transaction
|
||||
* provides the atomic boundary).
|
||||
*/
|
||||
private createInTransactionalContext(input: QueueCreateInput): {
|
||||
qitemId: string;
|
||||
persistedEvent: PersistedEvent;
|
||||
} {
|
||||
const id = input.qitemId ?? newQitemId();
|
||||
const ts = new Date().toISOString();
|
||||
const priority = input.priority ?? "routine";
|
||||
@@ -263,49 +329,29 @@ export class QueueRepository {
|
||||
const chain = input.chainOfRecord ? JSON.stringify(input.chainOfRecord) : null;
|
||||
const expiresAt = input.expiresAt ?? null;
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, expires_at, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
ts,
|
||||
ts,
|
||||
input.sourceSession,
|
||||
input.destinationSession,
|
||||
priority,
|
||||
tier,
|
||||
tags,
|
||||
expiresAt,
|
||||
chain,
|
||||
input.body
|
||||
);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: id,
|
||||
state: "pending",
|
||||
actorSession: input.sourceSession,
|
||||
transitionNote: "created",
|
||||
});
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "queue.created",
|
||||
qitemId: id,
|
||||
sourceSession: input.sourceSession,
|
||||
destinationSession: input.destinationSession,
|
||||
priority,
|
||||
tier,
|
||||
});
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, expires_at, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, ts, ts, input.sourceSession, input.destinationSession, priority, tier, tags, expiresAt, chain, input.body);
|
||||
this.transitionLog.append({
|
||||
qitemId: id,
|
||||
state: "pending",
|
||||
actorSession: input.sourceSession,
|
||||
transitionNote: "created",
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
await this.maybeNudge(id, input.destinationSession, input.nudge);
|
||||
return this.getByIdOrThrow(id);
|
||||
const persistedEvent = this.eventBus.persistWithinTransaction({
|
||||
type: "queue.created",
|
||||
qitemId: id,
|
||||
sourceSession: input.sourceSession,
|
||||
destinationSession: input.destinationSession,
|
||||
priority,
|
||||
tier,
|
||||
});
|
||||
return { qitemId: id, persistedEvent };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -182,7 +182,17 @@ export type RigEvent =
|
||||
| { type: "watchdog.evaluation_skipped"; jobId: string; policy: string; skipReason: string }
|
||||
| { type: "watchdog.evaluation_terminal"; jobId: string; policy: string; terminalReason: string }
|
||||
| { type: "watchdog.job_registered"; jobId: string; policy: string; targetSession: string; registeredBy: string }
|
||||
| { type: "watchdog.job_stopped"; jobId: string; reason: string };
|
||||
| { type: "watchdog.job_stopped"; jobId: string; reason: string }
|
||||
// PL-004 Phase D: daemon-native Workflow Runtime events. Step closure
|
||||
// and next-qitem projection are emitted within the SAME daemon
|
||||
// transaction (transactional-scribe contract). Subscribers see the
|
||||
// pair atomically.
|
||||
| { type: "workflow.instantiated"; instanceId: string; workflowName: string; workflowVersion: string; createdBy: string }
|
||||
| { type: "workflow.step_closed"; instanceId: string; stepId: string; closureReason: string; actorSession: string; priorQitemId: string }
|
||||
| { type: "workflow.next_qitem_projected"; instanceId: string; nextQitemId: string; nextOwner: string; nextStepId: string }
|
||||
| { type: "workflow.completed"; instanceId: string; workflowName: string }
|
||||
| { type: "workflow.failed"; instanceId: string; workflowName: string; reason: string }
|
||||
| { type: "workflow.routing_table_changed"; rigName: string; cause: string };
|
||||
|
||||
export type PersistedEvent = RigEvent & {
|
||||
seq: number;
|
||||
|
||||
@@ -2,27 +2,36 @@ import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
/**
|
||||
* Watchdog jobs repository (PL-004 Phase C).
|
||||
* Watchdog jobs repository (PL-004 Phase C; extended in Phase D).
|
||||
*
|
||||
* Owns all reads/writes against `watchdog_jobs`. Pure persistence; no
|
||||
* event-bus, no scheduler, no policy dispatch. Composed by the
|
||||
* scheduler and policy engine.
|
||||
*
|
||||
* Phase C v1 enforces the policy enum against three values:
|
||||
* periodic-reminder, artifact-pool-ready, edge-artifact-required.
|
||||
* `workflow-keepalive` is intentionally rejected with a structured
|
||||
* `policy_deferred_to_phase_d` error.
|
||||
* Phase D v1 accepts FOUR policy values (the orch-ratified Phase C
|
||||
* `workflow-keepalive` deferral has been lifted; the policy module
|
||||
* now exists at `policies/workflow-keepalive.ts` and reads from the
|
||||
* SQLite `workflow_instances` table introduced in Phase D):
|
||||
* - periodic-reminder (Phase C)
|
||||
* - artifact-pool-ready (Phase C)
|
||||
* - edge-artifact-required (Phase C)
|
||||
* - workflow-keepalive (Phase D)
|
||||
*
|
||||
* PHASE_C_POLICIES retained as a deprecated alias for callers that
|
||||
* still reference it; new code uses PHASE_D_POLICIES.
|
||||
*/
|
||||
|
||||
export const PHASE_C_POLICIES = [
|
||||
export const PHASE_D_POLICIES = [
|
||||
"periodic-reminder",
|
||||
"artifact-pool-ready",
|
||||
"edge-artifact-required",
|
||||
"workflow-keepalive",
|
||||
] as const;
|
||||
|
||||
export type WatchdogPolicyName = (typeof PHASE_C_POLICIES)[number];
|
||||
/** @deprecated since Phase D — use PHASE_D_POLICIES. */
|
||||
export const PHASE_C_POLICIES = PHASE_D_POLICIES;
|
||||
|
||||
export const WORKFLOW_KEEPALIVE_DEFERRED_POLICY = "workflow-keepalive";
|
||||
export type WatchdogPolicyName = (typeof PHASE_D_POLICIES)[number];
|
||||
|
||||
export type WatchdogJobState = "active" | "stopped" | "terminal";
|
||||
|
||||
@@ -90,18 +99,11 @@ export class WatchdogJobsRepository {
|
||||
) {}
|
||||
|
||||
register(input: RegisterWatchdogJobInput): WatchdogJob {
|
||||
if (input.policy === WORKFLOW_KEEPALIVE_DEFERRED_POLICY) {
|
||||
throw new WatchdogJobsError(
|
||||
"policy_deferred_to_phase_d",
|
||||
`policy 'workflow-keepalive' ships in PL-004 Phase D paired with workflow_instances; not available in Phase C v1`,
|
||||
{ policy: input.policy, phase: "C", deferredTo: "D" },
|
||||
);
|
||||
}
|
||||
if (!PHASE_C_POLICIES.includes(input.policy as WatchdogPolicyName)) {
|
||||
if (!PHASE_D_POLICIES.includes(input.policy as WatchdogPolicyName)) {
|
||||
throw new WatchdogJobsError(
|
||||
"policy_unknown",
|
||||
`unknown watchdog policy '${input.policy}'; Phase C v1 supports: ${PHASE_C_POLICIES.join(", ")}`,
|
||||
{ policy: input.policy, supported: [...PHASE_C_POLICIES] },
|
||||
`unknown watchdog policy '${input.policy}'; Phase D v1 supports: ${PHASE_D_POLICIES.join(", ")}`,
|
||||
{ policy: input.policy, supported: [...PHASE_D_POLICIES] },
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(input.intervalSeconds) || input.intervalSeconds <= 0) {
|
||||
|
||||
@@ -66,13 +66,21 @@ interface WatchdogPolicyEngineDeps {
|
||||
deliver: DeliveryFn;
|
||||
parseSpec?: PolicyContextParser;
|
||||
now?: () => Date;
|
||||
/**
|
||||
* PL-004 Phase D extension point (orch-ratified per slice IMPL):
|
||||
* additional policies to register alongside the Phase C built-in
|
||||
* three. Used to register `workflow-keepalive` (which depends on
|
||||
* the Phase D workflow_instances DB and so must be constructed at
|
||||
* daemon startup with a db handle injected).
|
||||
*/
|
||||
additionalPolicies?: Policy[];
|
||||
}
|
||||
|
||||
const POLICY_REGISTRY: Map<string, Policy> = new Map([
|
||||
[periodicReminderPolicy.name, periodicReminderPolicy],
|
||||
[artifactPoolReadyPolicy.name, artifactPoolReadyPolicy],
|
||||
[edgeArtifactRequiredPolicy.name, edgeArtifactRequiredPolicy],
|
||||
]);
|
||||
const PHASE_C_BUILTIN_POLICIES: ReadonlyArray<Policy> = [
|
||||
periodicReminderPolicy,
|
||||
artifactPoolReadyPolicy,
|
||||
edgeArtifactRequiredPolicy,
|
||||
];
|
||||
|
||||
/**
|
||||
* Quiet skip reasons — POC `shouldAppendHistory` (engine.mjs:99-112)
|
||||
@@ -103,6 +111,7 @@ export class WatchdogPolicyEngine {
|
||||
private readonly deliver: DeliveryFn;
|
||||
private readonly parseSpec: PolicyContextParser;
|
||||
private readonly now: () => Date;
|
||||
private readonly policies: Map<string, Policy>;
|
||||
|
||||
constructor(deps: WatchdogPolicyEngineDeps) {
|
||||
this.jobsRepo = deps.jobsRepo;
|
||||
@@ -111,10 +120,15 @@ export class WatchdogPolicyEngine {
|
||||
this.deliver = deps.deliver;
|
||||
this.parseSpec = deps.parseSpec ?? defaultParseSpec;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.policies = new Map();
|
||||
for (const p of PHASE_C_BUILTIN_POLICIES) this.policies.set(p.name, p);
|
||||
if (deps.additionalPolicies) {
|
||||
for (const p of deps.additionalPolicies) this.policies.set(p.name, p);
|
||||
}
|
||||
}
|
||||
|
||||
resolvePolicy(name: string): Policy | undefined {
|
||||
return POLICY_REGISTRY.get(name);
|
||||
return this.policies.get(name);
|
||||
}
|
||||
|
||||
async evaluate(job: WatchdogJob): Promise<EvaluationResult> {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// PL-004 Phase D: workflow instance store.
|
||||
//
|
||||
// Owns CRUD on workflow_instances. Frontier tracking uses
|
||||
// current_frontier_json (serialized JSON array of qitem_ids). Survives
|
||||
// daemon restart without filesystem reconciliation: list/getById query
|
||||
// SQLite directly.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import type {
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceStatus,
|
||||
} from "./workflow-types.js";
|
||||
|
||||
interface InstanceRow {
|
||||
instance_id: string;
|
||||
workflow_name: string;
|
||||
workflow_version: string;
|
||||
created_by_session: string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
current_frontier_json: string;
|
||||
hop_count: number;
|
||||
fallback_synthesis: string | null;
|
||||
last_continuation_decision_json: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export interface CreateWorkflowInstanceInput {
|
||||
workflowName: string;
|
||||
workflowVersion: string;
|
||||
createdBySession: string;
|
||||
initialFrontier?: string[];
|
||||
}
|
||||
|
||||
export class WorkflowInstanceError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkflowInstanceError";
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkflowInstanceStore {
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
create(input: CreateWorkflowInstanceInput): WorkflowInstance {
|
||||
const instanceId = ulid();
|
||||
const createdAt = this.now().toISOString();
|
||||
const frontier = input.initialFrontier ?? [];
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO workflow_instances (
|
||||
instance_id, workflow_name, workflow_version, created_by_session,
|
||||
created_at, status, current_frontier_json, hop_count
|
||||
) VALUES (?, ?, ?, ?, ?, 'active', ?, 0)`,
|
||||
)
|
||||
.run(
|
||||
instanceId,
|
||||
input.workflowName,
|
||||
input.workflowVersion,
|
||||
input.createdBySession,
|
||||
createdAt,
|
||||
JSON.stringify(frontier),
|
||||
);
|
||||
return this.getByIdOrThrow(instanceId);
|
||||
}
|
||||
|
||||
getById(instanceId: string): WorkflowInstance | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM workflow_instances WHERE instance_id = ?`)
|
||||
.get(instanceId) as InstanceRow | undefined;
|
||||
return row ? rowToInstance(row) : null;
|
||||
}
|
||||
|
||||
getByIdOrThrow(instanceId: string): WorkflowInstance {
|
||||
const inst = this.getById(instanceId);
|
||||
if (!inst) {
|
||||
throw new WorkflowInstanceError(
|
||||
"instance_not_found",
|
||||
`workflow instance ${instanceId} not found`,
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
listByStatus(status: WorkflowInstanceStatus): WorkflowInstance[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_instances WHERE status = ? ORDER BY created_at ASC`,
|
||||
)
|
||||
.all(status) as InstanceRow[];
|
||||
return rows.map(rowToInstance);
|
||||
}
|
||||
|
||||
listAll(): WorkflowInstance[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM workflow_instances ORDER BY created_at ASC`)
|
||||
.all() as InstanceRow[];
|
||||
return rows.map(rowToInstance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frontier + status atomically. Caller is responsible for
|
||||
* wrapping in a transaction when this needs to compose with other
|
||||
* mutations (e.g., workflow-projector folds this into the
|
||||
* close + create + frontier-update transaction).
|
||||
*/
|
||||
updateFrontier(
|
||||
instanceId: string,
|
||||
nextFrontier: string[],
|
||||
nextStatus: WorkflowInstanceStatus,
|
||||
opts: {
|
||||
bumpHopCount?: boolean;
|
||||
lastContinuationDecision?: Record<string, unknown> | null;
|
||||
fallbackSynthesis?: string | null;
|
||||
completedAt?: string | null;
|
||||
} = {},
|
||||
): void {
|
||||
const setHop = opts.bumpHopCount ? "hop_count = hop_count + 1, " : "";
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE workflow_instances SET
|
||||
${setHop}status = ?, current_frontier_json = ?,
|
||||
last_continuation_decision_json = COALESCE(?, last_continuation_decision_json),
|
||||
fallback_synthesis = COALESCE(?, fallback_synthesis),
|
||||
completed_at = COALESCE(?, completed_at)
|
||||
WHERE instance_id = ?`,
|
||||
)
|
||||
.run(
|
||||
nextStatus,
|
||||
JSON.stringify(nextFrontier),
|
||||
opts.lastContinuationDecision ? JSON.stringify(opts.lastContinuationDecision) : null,
|
||||
opts.fallbackSynthesis ?? null,
|
||||
opts.completedAt ?? null,
|
||||
instanceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function rowToInstance(row: InstanceRow): WorkflowInstance {
|
||||
return {
|
||||
instanceId: row.instance_id,
|
||||
workflowName: row.workflow_name,
|
||||
workflowVersion: row.workflow_version,
|
||||
createdBySession: row.created_by_session,
|
||||
createdAt: row.created_at,
|
||||
status: row.status as WorkflowInstanceStatus,
|
||||
currentFrontier: JSON.parse(row.current_frontier_json) as string[],
|
||||
hopCount: row.hop_count,
|
||||
fallbackSynthesis: row.fallback_synthesis,
|
||||
lastContinuationDecision: row.last_continuation_decision_json
|
||||
? (JSON.parse(row.last_continuation_decision_json) as Record<string, unknown>)
|
||||
: null,
|
||||
completedAt: row.completed_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// PL-004 Phase D: workflow projector — transactional-scribe contract.
|
||||
//
|
||||
// LOAD-BEARING. The single most important Phase D contract.
|
||||
//
|
||||
// Per PRD § L4 + audit row 16: every code path that closes a
|
||||
// `workflow_instances.current_frontier` qitem AND every code path that
|
||||
// creates the next-step qitem MUST happen inside the SAME daemon-managed
|
||||
// transaction. If two separate transactions can be observed, that is a
|
||||
// contract violation. Lost handoffs are impossible by design.
|
||||
//
|
||||
// Composition (one db.transaction):
|
||||
// 1. Verify spec + instance + current packet are consistent.
|
||||
// 2. Resolve next step from spec (one-hop default per PRD).
|
||||
// 3. Update queue_items: close current packet (handoff/waiting/done).
|
||||
// 4. Create next-step queue item via QueueRepository.createWithinTransaction.
|
||||
// 5. Append workflow_step_trails entry with prior + next ids.
|
||||
// 6. Update workflow_instances frontier + status.
|
||||
// 7. Persist workflow.* events (step_closed, next_qitem_projected, completed).
|
||||
//
|
||||
// All in one db.transaction. If any step throws, all rollback. After
|
||||
// commit, fan out events to subscribers + nudge next owner.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { QueueRepository } from "./queue-repository.js";
|
||||
import type { PersistedEvent } from "./types.js";
|
||||
import type { WorkflowInstanceStore } from "./workflow-instance-store.js";
|
||||
import type { WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import type { WorkflowStepTrailLog } from "./workflow-step-trail-log.js";
|
||||
import type {
|
||||
WorkflowExitKind,
|
||||
WorkflowInstance,
|
||||
WorkflowSpec,
|
||||
WorkflowStepSpec,
|
||||
} from "./workflow-types.js";
|
||||
|
||||
export class WorkflowProjectorError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkflowProjectorError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectStepInput {
|
||||
instanceId: string;
|
||||
/** The qitem being closed (must be in instance.currentFrontier). */
|
||||
currentPacketId: string;
|
||||
/** What kind of exit closes this packet. */
|
||||
exit: WorkflowExitKind;
|
||||
/** Free-form closure result text (POC: --result). */
|
||||
resultNote?: string;
|
||||
/** For waiting exits: the blocker reference (qitem id, gate name). */
|
||||
blockedOn?: string;
|
||||
/** Operator-supplied evidence for the closure record (audit). */
|
||||
closureEvidence?: Record<string, unknown>;
|
||||
/** Session that closed the packet (owner-as-author). */
|
||||
actorSession: string;
|
||||
/**
|
||||
* Optional explicit override for the next-step destination session.
|
||||
* When omitted, the projector resolves from the next step's
|
||||
* actor_role + role.preferred_targets[]. Required when no
|
||||
* preferred_targets are declared OR none are operator-resolvable.
|
||||
*/
|
||||
nextOwnerSession?: string;
|
||||
}
|
||||
|
||||
export interface ProjectStepResult {
|
||||
instance: WorkflowInstance;
|
||||
closurePriorPacketId: string;
|
||||
closureReason: WorkflowExitKind;
|
||||
/** null on terminal closures (waiting / done / failed / no-successor). */
|
||||
nextQitemId: string | null;
|
||||
/** null on terminal closures. */
|
||||
nextOwnerSession: string | null;
|
||||
/** null on terminal closures. */
|
||||
nextStepId: string | null;
|
||||
/** Composite fired events (step_closed + optional next_qitem_projected + optional completed). */
|
||||
emittedEventTypes: string[];
|
||||
}
|
||||
|
||||
export class WorkflowProjector {
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly queueRepo: QueueRepository,
|
||||
private readonly instanceStore: WorkflowInstanceStore,
|
||||
private readonly trailLog: WorkflowStepTrailLog,
|
||||
private readonly specCache: WorkflowSpecCache,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Close the current packet AND create/project the next-step packet
|
||||
* IN THE SAME TRANSACTION. The load-bearing transactional-scribe
|
||||
* call site.
|
||||
*/
|
||||
async project(input: ProjectStepInput): Promise<ProjectStepResult> {
|
||||
const instance = this.instanceStore.getByIdOrThrow(input.instanceId);
|
||||
if (instance.status !== "active" && instance.status !== "waiting") {
|
||||
throw new WorkflowProjectorError(
|
||||
"instance_not_active",
|
||||
`workflow instance ${instance.instanceId} has status ${instance.status}; only active|waiting instances accept project`,
|
||||
{ instanceId: instance.instanceId, status: instance.status },
|
||||
);
|
||||
}
|
||||
if (!instance.currentFrontier.includes(input.currentPacketId)) {
|
||||
throw new WorkflowProjectorError(
|
||||
"packet_not_on_frontier",
|
||||
`qitem ${input.currentPacketId} is not in workflow instance ${instance.instanceId} frontier ${JSON.stringify(instance.currentFrontier)}; either the packet is already closed or belongs to a different instance`,
|
||||
{ instanceId: instance.instanceId, currentPacketId: input.currentPacketId, frontier: instance.currentFrontier },
|
||||
);
|
||||
}
|
||||
const specRow = this.specCache.getByNameVersion(instance.workflowName, instance.workflowVersion);
|
||||
if (!specRow) {
|
||||
throw new WorkflowProjectorError(
|
||||
"spec_not_cached",
|
||||
`workflow spec ${instance.workflowName}@${instance.workflowVersion} is not in the spec cache. Re-run validate to refresh the cache.`,
|
||||
{ workflowName: instance.workflowName, workflowVersion: instance.workflowVersion },
|
||||
);
|
||||
}
|
||||
const spec = specRow.spec;
|
||||
|
||||
// Look up the qitem being closed to identify the current step.
|
||||
const currentPacket = this.queueRepo.getById(input.currentPacketId);
|
||||
if (!currentPacket) {
|
||||
throw new WorkflowProjectorError(
|
||||
"packet_not_found",
|
||||
`qitem ${input.currentPacketId} not found in queue_items`,
|
||||
{ currentPacketId: input.currentPacketId },
|
||||
);
|
||||
}
|
||||
const currentStep = inferCurrentStep(spec, instance, this.trailLog);
|
||||
if (!currentStep) {
|
||||
throw new WorkflowProjectorError(
|
||||
"current_step_unknown",
|
||||
`workflow instance ${instance.instanceId} cannot infer the current step from spec ${spec.id}@${spec.version} + trail; check that the trail is consistent and that the spec has not changed shape mid-instance`,
|
||||
{ instanceId: instance.instanceId, frontier: instance.currentFrontier },
|
||||
);
|
||||
}
|
||||
|
||||
// Determine next step (one-hop default; multi-hop is graduation).
|
||||
const nextStep = resolveNextStep(spec, currentStep);
|
||||
|
||||
const evaluatedAt = this.now().toISOString();
|
||||
|
||||
let nextQitemId: string | null = null;
|
||||
let nextOwnerSession: string | null = null;
|
||||
let nextStepId: string | null = null;
|
||||
type PostCommitNudge = { destinationSession: string; nudge: boolean | undefined };
|
||||
let nextQitemCreatePostCommit: PostCommitNudge | null = null;
|
||||
const persistedEvents: PersistedEvent[] = [];
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
// 1. Close the current packet via direct UPDATE inside this txn.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items SET state = ?, ts_updated = ? WHERE qitem_id = ?`,
|
||||
)
|
||||
.run(
|
||||
input.exit === "handoff" ? "handed-off" : input.exit === "waiting" ? "blocked" : "done",
|
||||
evaluatedAt,
|
||||
input.currentPacketId,
|
||||
);
|
||||
|
||||
// 2. If exit is handoff: create next-step qitem in same txn.
|
||||
let createdNext: { qitemId: string; persistedEvent: PersistedEvent; destinationSession: string; nudge: boolean | undefined } | null = null;
|
||||
if (input.exit === "handoff") {
|
||||
if (!nextStep) {
|
||||
throw new WorkflowProjectorError(
|
||||
"no_next_step",
|
||||
`workflow instance ${instance.instanceId} reached terminal step "${currentStep.id}" but exit was handoff; use exit=done for terminal steps or extend the spec with a next step`,
|
||||
{ instanceId: instance.instanceId, currentStepId: currentStep.id },
|
||||
);
|
||||
}
|
||||
const owner = input.nextOwnerSession ?? resolveDefaultOwner(spec, nextStep);
|
||||
if (!owner) {
|
||||
throw new WorkflowProjectorError(
|
||||
"next_owner_unresolved",
|
||||
`cannot resolve next owner for step "${nextStep.id}" (role "${nextStep.actor_role}"); supply nextOwnerSession explicitly or add preferred_targets to the role in the spec`,
|
||||
{ instanceId: instance.instanceId, nextStepId: nextStep.id, nextRole: nextStep.actor_role },
|
||||
);
|
||||
}
|
||||
nextOwnerSession = owner;
|
||||
nextStepId = nextStep.id;
|
||||
createdNext = this.queueRepo.createWithinTransaction({
|
||||
sourceSession: input.actorSession,
|
||||
destinationSession: owner,
|
||||
body: workflowHandoffBody({
|
||||
spec,
|
||||
instance,
|
||||
currentStep,
|
||||
nextStep,
|
||||
actorSession: input.actorSession,
|
||||
resultNote: input.resultNote,
|
||||
}),
|
||||
priority: "routine",
|
||||
tier: "mode2",
|
||||
tags: ["workflow", "handoff", `workflow:${spec.id}`, `instance:${instance.instanceId}`],
|
||||
chainOfRecord: [input.currentPacketId],
|
||||
});
|
||||
nextQitemId = createdNext.qitemId;
|
||||
nextQitemCreatePostCommit = {
|
||||
destinationSession: createdNext.destinationSession,
|
||||
nudge: createdNext.nudge,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Append step trail entry (prior + next ids).
|
||||
this.trailLog.record({
|
||||
instanceId: instance.instanceId,
|
||||
stepId: currentStep.id,
|
||||
stepRole: currentStep.actor_role,
|
||||
closedAt: evaluatedAt,
|
||||
closureReason: input.exit,
|
||||
closureEvidence: input.closureEvidence ?? null,
|
||||
actorSession: input.actorSession,
|
||||
nextQitemId,
|
||||
priorQitemId: input.currentPacketId,
|
||||
});
|
||||
|
||||
// 4. Update instance frontier + status.
|
||||
const remainingFrontier = instance.currentFrontier.filter(
|
||||
(id) => id !== input.currentPacketId,
|
||||
);
|
||||
const nextFrontier = nextQitemId ? [...remainingFrontier, nextQitemId] : remainingFrontier;
|
||||
let nextStatus: WorkflowInstance["status"];
|
||||
let completedAt: string | null = null;
|
||||
if (input.exit === "waiting") {
|
||||
nextStatus = "waiting";
|
||||
} else if (nextFrontier.length === 0) {
|
||||
nextStatus = "completed";
|
||||
completedAt = evaluatedAt;
|
||||
} else {
|
||||
nextStatus = "active";
|
||||
}
|
||||
this.instanceStore.updateFrontier(instance.instanceId, nextFrontier, nextStatus, {
|
||||
bumpHopCount: input.exit === "handoff",
|
||||
lastContinuationDecision: {
|
||||
exit: input.exit,
|
||||
actorSession: input.actorSession,
|
||||
closedPacket: input.currentPacketId,
|
||||
nextPacket: nextQitemId,
|
||||
resultNote: input.resultNote ?? null,
|
||||
},
|
||||
completedAt,
|
||||
});
|
||||
|
||||
// 5. Persist workflow events within the same txn.
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "workflow.step_closed",
|
||||
instanceId: instance.instanceId,
|
||||
stepId: currentStep.id,
|
||||
closureReason: input.exit,
|
||||
actorSession: input.actorSession,
|
||||
priorQitemId: input.currentPacketId,
|
||||
}),
|
||||
);
|
||||
if (createdNext) {
|
||||
// Persist queue.created event (created within QueueRepository.createWithinTransaction).
|
||||
persistedEvents.push(createdNext.persistedEvent);
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "workflow.next_qitem_projected",
|
||||
instanceId: instance.instanceId,
|
||||
nextQitemId: createdNext.qitemId,
|
||||
nextOwner: createdNext.destinationSession,
|
||||
nextStepId: nextStep!.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (nextStatus === "completed") {
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "workflow.completed",
|
||||
instanceId: instance.instanceId,
|
||||
workflowName: instance.workflowName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
txn();
|
||||
|
||||
// Post-commit fan-out: notify subscribers + nudge next owner.
|
||||
for (const e of persistedEvents) {
|
||||
this.eventBus.notifySubscribers(e);
|
||||
}
|
||||
const postCommit = nextQitemCreatePostCommit as PostCommitNudge | null;
|
||||
if (nextQitemId && postCommit) {
|
||||
await this.queueRepo.maybeNudge(
|
||||
nextQitemId,
|
||||
postCommit.destinationSession,
|
||||
postCommit.nudge,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedInstance = this.instanceStore.getByIdOrThrow(instance.instanceId);
|
||||
return {
|
||||
instance: updatedInstance,
|
||||
closurePriorPacketId: input.currentPacketId,
|
||||
closureReason: input.exit,
|
||||
nextQitemId,
|
||||
nextOwnerSession,
|
||||
nextStepId,
|
||||
emittedEventTypes: persistedEvents.map((e) => e.type),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the current step from the trail. v1 uses a simple rule:
|
||||
* - If trail is empty → entry step is the current step.
|
||||
* - Else → the step after the most recently-trailed step.
|
||||
*
|
||||
* Multi-active-frontier instances (parallel steps) are a graduation;
|
||||
* v1 always has at most one active frontier packet at a time.
|
||||
*/
|
||||
function inferCurrentStep(
|
||||
spec: WorkflowSpec,
|
||||
instance: WorkflowInstance,
|
||||
trailLog: WorkflowStepTrailLog,
|
||||
): WorkflowStepSpec | null {
|
||||
const trail = trailLog.listForInstance(instance.instanceId, 1);
|
||||
if (trail.length === 0) {
|
||||
const entryRole = spec.entry?.role;
|
||||
return spec.steps[0] ?? (entryRole ? spec.steps.find((s) => s.actor_role === entryRole) ?? null : null) ?? null;
|
||||
}
|
||||
const lastStepId = trail[0]!.stepId;
|
||||
const idx = spec.steps.findIndex((s) => s.id === lastStepId);
|
||||
if (idx === -1) return null;
|
||||
return spec.steps[idx + 1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the next step. v1 = one-hop continuity (the next step in
|
||||
* declaration order). Multi-hop chaining via `next_hop.suggested_roles`
|
||||
* is a graduation feature per PRD § Risks "Workflow runtime over-engineered".
|
||||
*/
|
||||
function resolveNextStep(spec: WorkflowSpec, currentStep: WorkflowStepSpec): WorkflowStepSpec | null {
|
||||
const idx = spec.steps.findIndex((s) => s.id === currentStep.id);
|
||||
if (idx === -1) return null;
|
||||
return spec.steps[idx + 1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a default owner for a step. v1 picks the first declared
|
||||
* preferred_target. Returns null if none declared (caller must supply
|
||||
* nextOwnerSession explicitly).
|
||||
*/
|
||||
function resolveDefaultOwner(spec: WorkflowSpec, step: WorkflowStepSpec): string | null {
|
||||
const role = spec.roles?.[step.actor_role];
|
||||
const targets = role?.preferred_targets ?? [];
|
||||
return targets[0] ?? null;
|
||||
}
|
||||
|
||||
function workflowHandoffBody(input: {
|
||||
spec: WorkflowSpec;
|
||||
instance: WorkflowInstance;
|
||||
currentStep: WorkflowStepSpec;
|
||||
nextStep: WorkflowStepSpec;
|
||||
actorSession: string;
|
||||
resultNote: string | undefined;
|
||||
}): string {
|
||||
const lines = [
|
||||
`### Workflow handoff: ${input.spec.id}@${input.spec.version} step ${input.nextStep.id}`,
|
||||
"",
|
||||
`Workflow instance: ${input.instance.instanceId}`,
|
||||
`Prior step: ${input.currentStep.id} (${input.currentStep.actor_role}) closed by ${input.actorSession}`,
|
||||
`This step: ${input.nextStep.id} (${input.nextStep.actor_role})`,
|
||||
];
|
||||
if (input.nextStep.objective) {
|
||||
lines.push(`Objective: ${input.nextStep.objective}`);
|
||||
}
|
||||
if (input.resultNote) {
|
||||
lines.push("", `Prior step note: ${input.resultNote}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// PL-004 Phase D: workflow runtime facade.
|
||||
//
|
||||
// Coordinates spec cache + validator + instance store + projector +
|
||||
// trail log into the four high-level operations:
|
||||
// - validate(specPath)
|
||||
// - instantiate(specPath, rootObjective, createdBySession)
|
||||
// - project(...) (delegates to projector)
|
||||
// - continue(instanceId) (idempotent advance; v1 = read-only inspector)
|
||||
//
|
||||
// Pattern mirrors Phase B's ProjectClassifier facade shape.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { QueueRepository } from "./queue-repository.js";
|
||||
import type { PersistedEvent } from "./types.js";
|
||||
import {
|
||||
type CreateWorkflowInstanceInput,
|
||||
WorkflowInstanceStore,
|
||||
WorkflowInstanceError,
|
||||
} from "./workflow-instance-store.js";
|
||||
import {
|
||||
WorkflowProjector,
|
||||
WorkflowProjectorError,
|
||||
type ProjectStepInput,
|
||||
type ProjectStepResult,
|
||||
} from "./workflow-projector.js";
|
||||
import { WorkflowSpecCache, WorkflowSpecError } from "./workflow-spec-cache.js";
|
||||
import { WorkflowStepTrailLog } from "./workflow-step-trail-log.js";
|
||||
import {
|
||||
type SeatLivenessCheckFn,
|
||||
type ValidationResult,
|
||||
WorkflowValidator,
|
||||
} from "./workflow-validator.js";
|
||||
import type { WorkflowInstance, WorkflowSpecRow, WorkflowStepTrailEntry } from "./workflow-types.js";
|
||||
|
||||
export interface WorkflowRuntimeDeps {
|
||||
db: Database.Database;
|
||||
eventBus: EventBus;
|
||||
queueRepo: QueueRepository;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface InstantiateInput {
|
||||
specPath: string;
|
||||
rootObjective: string;
|
||||
createdBySession: string;
|
||||
/**
|
||||
* Override default entry-step owner. v1 falls back to spec
|
||||
* preferred_targets[0] for the entry step's role.
|
||||
*/
|
||||
entryOwnerSession?: string;
|
||||
}
|
||||
|
||||
export interface InstantiateResult {
|
||||
instance: WorkflowInstance;
|
||||
spec: WorkflowSpecRow;
|
||||
entryQitemId: string;
|
||||
entryOwnerSession: string;
|
||||
}
|
||||
|
||||
export class WorkflowRuntime {
|
||||
readonly specCache: WorkflowSpecCache;
|
||||
readonly instanceStore: WorkflowInstanceStore;
|
||||
readonly trailLog: WorkflowStepTrailLog;
|
||||
readonly validator: WorkflowValidator;
|
||||
readonly projector: WorkflowProjector;
|
||||
|
||||
private readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly queueRepo: QueueRepository;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(deps: WorkflowRuntimeDeps) {
|
||||
this.db = deps.db;
|
||||
this.eventBus = deps.eventBus;
|
||||
this.queueRepo = deps.queueRepo;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.specCache = new WorkflowSpecCache(this.db, this.now);
|
||||
this.instanceStore = new WorkflowInstanceStore(this.db, this.now);
|
||||
this.trailLog = new WorkflowStepTrailLog(this.db);
|
||||
this.validator = new WorkflowValidator();
|
||||
this.projector = new WorkflowProjector(
|
||||
this.db,
|
||||
this.eventBus,
|
||||
this.queueRepo,
|
||||
this.instanceStore,
|
||||
this.trailLog,
|
||||
this.specCache,
|
||||
this.now,
|
||||
);
|
||||
}
|
||||
|
||||
validate(specPath: string, seatLivenessCheck?: SeatLivenessCheckFn): ValidationResult {
|
||||
const specRow = this.specCache.readThrough(specPath);
|
||||
return this.validator.validate(specRow.spec, seatLivenessCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow instance + first-step qitem. The entry qitem is
|
||||
* created in the same transaction as the instance row; subscribers
|
||||
* see the workflow.instantiated + queue.created events together.
|
||||
*/
|
||||
async instantiate(input: InstantiateInput): Promise<InstantiateResult> {
|
||||
const specRow = this.specCache.readThrough(input.specPath);
|
||||
const validation = this.validator.validate(specRow.spec);
|
||||
if (!validation.ok) {
|
||||
throw new WorkflowProjectorError(
|
||||
"spec_invalid",
|
||||
`cannot instantiate: spec ${specRow.name}@${specRow.version} has ${validation.issues.filter((i) => i.severity === "error").length} validation error(s); run validate to inspect`,
|
||||
{ specPath: input.specPath, issues: validation.issues },
|
||||
);
|
||||
}
|
||||
const entryStep = specRow.spec.steps[0];
|
||||
if (!entryStep) {
|
||||
throw new WorkflowProjectorError(
|
||||
"spec_no_steps",
|
||||
`cannot instantiate: spec ${specRow.name}@${specRow.version} has no steps[]`,
|
||||
{ specPath: input.specPath },
|
||||
);
|
||||
}
|
||||
const entryRole = specRow.spec.roles?.[entryStep.actor_role];
|
||||
const entryOwner =
|
||||
input.entryOwnerSession ?? entryRole?.preferred_targets?.[0] ?? null;
|
||||
if (!entryOwner) {
|
||||
throw new WorkflowProjectorError(
|
||||
"entry_owner_unresolved",
|
||||
`cannot instantiate: entry step "${entryStep.id}" (role "${entryStep.actor_role}") has no preferred_targets and no entryOwnerSession was supplied`,
|
||||
{ specPath: input.specPath, entryStepId: entryStep.id, entryRole: entryStep.actor_role },
|
||||
);
|
||||
}
|
||||
|
||||
const createdAt = this.now().toISOString();
|
||||
let entryQitemId: string | undefined;
|
||||
let entryQitemDestinationSession: string | undefined;
|
||||
let entryQitemNudge: boolean | undefined;
|
||||
let instanceId: string | undefined;
|
||||
const persistedEvents: PersistedEvent[] = [];
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
const instance = this.instanceStore.create({
|
||||
workflowName: specRow.name,
|
||||
workflowVersion: specRow.version,
|
||||
createdBySession: input.createdBySession,
|
||||
initialFrontier: [],
|
||||
});
|
||||
instanceId = instance.instanceId;
|
||||
|
||||
// Create entry qitem in the same txn.
|
||||
const created = this.queueRepo.createWithinTransaction({
|
||||
sourceSession: input.createdBySession,
|
||||
destinationSession: entryOwner,
|
||||
body: workflowInstantiateBody({
|
||||
spec: specRow.spec,
|
||||
instanceId: instance.instanceId,
|
||||
entryStep,
|
||||
rootObjective: input.rootObjective,
|
||||
}),
|
||||
priority: "routine",
|
||||
tier: "mode2",
|
||||
tags: ["workflow", "entry", `workflow:${specRow.name}`, `instance:${instance.instanceId}`],
|
||||
});
|
||||
entryQitemId = created.qitemId;
|
||||
entryQitemDestinationSession = created.destinationSession;
|
||||
entryQitemNudge = created.nudge;
|
||||
persistedEvents.push(created.persistedEvent);
|
||||
|
||||
this.instanceStore.updateFrontier(instance.instanceId, [created.qitemId], "active");
|
||||
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "workflow.instantiated",
|
||||
instanceId: instance.instanceId,
|
||||
workflowName: specRow.name,
|
||||
workflowVersion: specRow.version,
|
||||
createdBy: input.createdBySession,
|
||||
}),
|
||||
);
|
||||
});
|
||||
txn();
|
||||
|
||||
for (const e of persistedEvents) this.eventBus.notifySubscribers(e);
|
||||
if (entryQitemId && entryQitemDestinationSession) {
|
||||
await this.queueRepo.maybeNudge(entryQitemId, entryQitemDestinationSession, entryQitemNudge);
|
||||
}
|
||||
|
||||
const finalInstance = this.instanceStore.getByIdOrThrow(instanceId!);
|
||||
return {
|
||||
instance: finalInstance,
|
||||
spec: specRow,
|
||||
entryQitemId: entryQitemId!,
|
||||
entryOwnerSession: entryOwner,
|
||||
};
|
||||
}
|
||||
|
||||
async project(input: ProjectStepInput): Promise<ProjectStepResult> {
|
||||
return this.projector.project(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue: idempotent inspector for the current frontier of an
|
||||
* instance. v1 is read-only — returns the current state. POC's
|
||||
* mechanical advance is folded into project() for v1; continue() is
|
||||
* the audit/inspect entrypoint.
|
||||
*/
|
||||
continue(instanceId: string): {
|
||||
instance: WorkflowInstance;
|
||||
trail: WorkflowStepTrailEntry[];
|
||||
} {
|
||||
const instance = this.instanceStore.getByIdOrThrow(instanceId);
|
||||
const trail = this.trailLog.listForInstance(instanceId);
|
||||
return { instance, trail };
|
||||
}
|
||||
}
|
||||
|
||||
function workflowInstantiateBody(input: {
|
||||
spec: { id: string; version: string };
|
||||
instanceId: string;
|
||||
entryStep: { id: string; actor_role: string; objective?: string };
|
||||
rootObjective: string;
|
||||
}): string {
|
||||
const lines = [
|
||||
`### Workflow entry: ${input.spec.id}@${input.spec.version} step ${input.entryStep.id}`,
|
||||
"",
|
||||
`Workflow instance: ${input.instanceId}`,
|
||||
`Entry step: ${input.entryStep.id} (${input.entryStep.actor_role})`,
|
||||
"",
|
||||
`Root objective: ${input.rootObjective}`,
|
||||
];
|
||||
if (input.entryStep.objective) {
|
||||
lines.push("", `Step objective: ${input.entryStep.objective}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export {
|
||||
WorkflowInstanceError,
|
||||
WorkflowProjectorError,
|
||||
WorkflowSpecError,
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
// PL-004 Phase D: workflow spec cache (read-through from markdown/YAML
|
||||
// to SQLite workflow_specs).
|
||||
//
|
||||
// Workflow specs are workspace-surface (markdown/YAML files on disk;
|
||||
// human-authored). Daemon reads them lazily and caches in
|
||||
// workflow_specs for fast lookup. Cache invalidation: source_hash on
|
||||
// the spec file content; on next read, if the hash differs, re-cache.
|
||||
//
|
||||
// Workspace-surface reconciliation contract (per PRD § Workspace-
|
||||
// surface reconciliation): valid operator edits to spec files win
|
||||
// at next read; the cache is never the source of truth.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { WorkflowSpec, WorkflowSpecRow } from "./workflow-types.js";
|
||||
|
||||
interface SpecRow {
|
||||
spec_id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
purpose: string | null;
|
||||
target_rig: string | null;
|
||||
roles_json: string;
|
||||
steps_json: string;
|
||||
coordination_terminal_turn_rule: string;
|
||||
source_path: string;
|
||||
source_hash: string;
|
||||
cached_at: string;
|
||||
}
|
||||
|
||||
export class WorkflowSpecError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkflowSpecError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a workflow spec from raw YAML content. The POC fixture shape
|
||||
* wraps everything under a top-level `workflow:` key:
|
||||
*
|
||||
* workflow:
|
||||
* id: ...
|
||||
* version: ...
|
||||
* roles: { ... }
|
||||
* steps: [ ... ]
|
||||
*
|
||||
* Returns the parsed spec or throws WorkflowSpecError on malformed
|
||||
* YAML / missing required fields.
|
||||
*/
|
||||
export function parseWorkflowSpec(rawYaml: string, sourcePath: string): WorkflowSpec {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(rawYaml);
|
||||
} catch (err) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_yaml_invalid",
|
||||
`workflow spec at ${sourcePath} could not be parsed as YAML: ${err instanceof Error ? err.message : err}`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_shape_invalid",
|
||||
`workflow spec at ${sourcePath} must be a YAML mapping with a top-level 'workflow:' key`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
const root = parsed as Record<string, unknown>;
|
||||
const wf = root.workflow as Record<string, unknown> | undefined;
|
||||
if (!wf || typeof wf !== "object" || Array.isArray(wf)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_shape_invalid",
|
||||
`workflow spec at ${sourcePath} is missing the top-level 'workflow:' key`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
if (typeof wf.id !== "string" || wf.id.length === 0) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_field_missing",
|
||||
`workflow spec at ${sourcePath} is missing required field workflow.id`,
|
||||
{ sourcePath, field: "workflow.id" },
|
||||
);
|
||||
}
|
||||
if (wf.version === undefined || wf.version === null) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_field_missing",
|
||||
`workflow spec at ${sourcePath} is missing required field workflow.version`,
|
||||
{ sourcePath, field: "workflow.version" },
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(wf.steps) || wf.steps.length === 0) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_field_missing",
|
||||
`workflow spec at ${sourcePath} requires at least one step in workflow.steps[]`,
|
||||
{ sourcePath, field: "workflow.steps" },
|
||||
);
|
||||
}
|
||||
if (!wf.roles || typeof wf.roles !== "object" || Array.isArray(wf.roles)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_field_missing",
|
||||
`workflow spec at ${sourcePath} requires a workflow.roles mapping`,
|
||||
{ sourcePath, field: "workflow.roles" },
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: wf.id,
|
||||
version: String(wf.version),
|
||||
objective: typeof wf.objective === "string" ? wf.objective : undefined,
|
||||
target: wf.target as WorkflowSpec["target"],
|
||||
entry: wf.entry as WorkflowSpec["entry"],
|
||||
roles: wf.roles as WorkflowSpec["roles"],
|
||||
steps: wf.steps as WorkflowSpec["steps"],
|
||||
invariants: wf.invariants as WorkflowSpec["invariants"],
|
||||
closure: wf.closure as WorkflowSpec["closure"],
|
||||
loop_guards: wf.loop_guards as WorkflowSpec["loop_guards"],
|
||||
coordination_terminal_turn_rule:
|
||||
typeof wf.coordination_terminal_turn_rule === "string"
|
||||
? wf.coordination_terminal_turn_rule
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkflowSpecCache {
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Read a workflow spec from disk through the cache. Returns the
|
||||
* cached row (re-caching if source_hash differs OR if the spec was
|
||||
* not previously cached).
|
||||
*/
|
||||
readThrough(sourcePath: string): WorkflowSpecRow {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_file_missing",
|
||||
`workflow spec file not found at ${sourcePath}`,
|
||||
{ sourcePath },
|
||||
);
|
||||
}
|
||||
const raw = readFileSync(sourcePath, "utf-8");
|
||||
const sourceHash = createHash("sha256").update(raw).digest("hex");
|
||||
const spec = parseWorkflowSpec(raw, sourcePath);
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`,
|
||||
)
|
||||
.get(spec.id, spec.version) as SpecRow | undefined;
|
||||
if (existing && existing.source_hash === sourceHash) {
|
||||
return rowToWorkflowSpec(existing);
|
||||
}
|
||||
const cachedAt = this.now().toISOString();
|
||||
if (existing) {
|
||||
// Update in place (same name+version, content changed).
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE workflow_specs SET
|
||||
purpose = ?, target_rig = ?, roles_json = ?, steps_json = ?,
|
||||
coordination_terminal_turn_rule = ?, source_path = ?,
|
||||
source_hash = ?, cached_at = ?
|
||||
WHERE spec_id = ?`,
|
||||
)
|
||||
.run(
|
||||
spec.objective ?? null,
|
||||
spec.target?.rig ?? null,
|
||||
JSON.stringify(spec.roles),
|
||||
JSON.stringify(spec.steps),
|
||||
spec.coordination_terminal_turn_rule ?? "hot_potato",
|
||||
sourcePath,
|
||||
sourceHash,
|
||||
cachedAt,
|
||||
existing.spec_id,
|
||||
);
|
||||
return this.getByIdOrThrow(existing.spec_id);
|
||||
}
|
||||
const specId = ulid();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO workflow_specs (
|
||||
spec_id, name, version, purpose, target_rig,
|
||||
roles_json, steps_json, coordination_terminal_turn_rule,
|
||||
source_path, source_hash, cached_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
specId,
|
||||
spec.id,
|
||||
spec.version,
|
||||
spec.objective ?? null,
|
||||
spec.target?.rig ?? null,
|
||||
JSON.stringify(spec.roles),
|
||||
JSON.stringify(spec.steps),
|
||||
spec.coordination_terminal_turn_rule ?? "hot_potato",
|
||||
sourcePath,
|
||||
sourceHash,
|
||||
cachedAt,
|
||||
);
|
||||
return this.getByIdOrThrow(specId);
|
||||
}
|
||||
|
||||
getByNameVersion(name: string, version: string): WorkflowSpecRow | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`)
|
||||
.get(name, version) as SpecRow | undefined;
|
||||
return row ? rowToWorkflowSpec(row) : null;
|
||||
}
|
||||
|
||||
getByIdOrThrow(specId: string): WorkflowSpecRow {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
|
||||
.get(specId) as SpecRow | undefined;
|
||||
if (!row) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_not_found",
|
||||
`workflow spec ${specId} not found in cache`,
|
||||
{ specId },
|
||||
);
|
||||
}
|
||||
return rowToWorkflowSpec(row);
|
||||
}
|
||||
}
|
||||
|
||||
function rowToWorkflowSpec(row: SpecRow): WorkflowSpecRow {
|
||||
const roles = JSON.parse(row.roles_json) as WorkflowSpec["roles"];
|
||||
const steps = JSON.parse(row.steps_json) as WorkflowSpec["steps"];
|
||||
const spec: WorkflowSpec = {
|
||||
id: row.name,
|
||||
version: row.version,
|
||||
objective: row.purpose ?? undefined,
|
||||
target: row.target_rig ? { rig: row.target_rig } : undefined,
|
||||
roles,
|
||||
steps,
|
||||
coordination_terminal_turn_rule: row.coordination_terminal_turn_rule,
|
||||
};
|
||||
return {
|
||||
specId: row.spec_id,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
purpose: row.purpose,
|
||||
targetRig: row.target_rig,
|
||||
spec,
|
||||
coordinationTerminalTurnRule: row.coordination_terminal_turn_rule,
|
||||
sourcePath: row.source_path,
|
||||
sourceHash: row.source_hash,
|
||||
cachedAt: row.cached_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// PL-004 Phase D: workflow step trail log (append-only).
|
||||
//
|
||||
// Owns inserts into workflow_step_trails. Append-only at the API
|
||||
// surface: only `record()` is exposed. UPDATE/DELETE/remove are not
|
||||
// methods — direct SQL could mutate but is a contract violation
|
||||
// enforced at this domain boundary (per PRD § L4 Workflow Runtime).
|
||||
//
|
||||
// Pattern mirrors Phase C's WatchdogHistoryLog and Phase A's
|
||||
// QueueTransitionLog.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import type { WorkflowExitKind, WorkflowStepTrailEntry } from "./workflow-types.js";
|
||||
|
||||
export interface WorkflowStepTrailRecordInput {
|
||||
instanceId: string;
|
||||
stepId: string;
|
||||
stepRole: string;
|
||||
closedAt: string;
|
||||
closureReason: WorkflowExitKind;
|
||||
closureEvidence?: Record<string, unknown> | null;
|
||||
actorSession: string;
|
||||
/** null for terminal closures (`done`, `failed`, `waiting`). */
|
||||
nextQitemId?: string | null;
|
||||
priorQitemId: string;
|
||||
}
|
||||
|
||||
interface TrailRow {
|
||||
trail_id: string;
|
||||
instance_id: string;
|
||||
step_id: string;
|
||||
step_role: string;
|
||||
closed_at: string;
|
||||
closure_reason: string;
|
||||
closure_evidence_json: string | null;
|
||||
actor_session: string;
|
||||
next_qitem_id: string | null;
|
||||
prior_qitem_id: string;
|
||||
}
|
||||
|
||||
export class WorkflowStepTrailLog {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
/**
|
||||
* Append a step trail entry. Returns the persisted entry. Designed
|
||||
* to compose inside an outer caller-managed transaction (used by
|
||||
* workflow-projector for the transactional-scribe contract).
|
||||
*/
|
||||
record(input: WorkflowStepTrailRecordInput): WorkflowStepTrailEntry {
|
||||
const trailId = ulid();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO workflow_step_trails (
|
||||
trail_id, instance_id, step_id, step_role, closed_at,
|
||||
closure_reason, closure_evidence_json, actor_session,
|
||||
next_qitem_id, prior_qitem_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
trailId,
|
||||
input.instanceId,
|
||||
input.stepId,
|
||||
input.stepRole,
|
||||
input.closedAt,
|
||||
input.closureReason,
|
||||
input.closureEvidence ? JSON.stringify(input.closureEvidence) : null,
|
||||
input.actorSession,
|
||||
input.nextQitemId ?? null,
|
||||
input.priorQitemId,
|
||||
);
|
||||
return {
|
||||
trailId,
|
||||
instanceId: input.instanceId,
|
||||
stepId: input.stepId,
|
||||
stepRole: input.stepRole,
|
||||
closedAt: input.closedAt,
|
||||
closureReason: input.closureReason,
|
||||
closureEvidence: input.closureEvidence ?? null,
|
||||
actorSession: input.actorSession,
|
||||
nextQitemId: input.nextQitemId ?? null,
|
||||
priorQitemId: input.priorQitemId,
|
||||
};
|
||||
}
|
||||
|
||||
listForInstance(instanceId: string, limit = 50): WorkflowStepTrailEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_step_trails WHERE instance_id = ?
|
||||
ORDER BY closed_at DESC, trail_id DESC LIMIT ?`,
|
||||
)
|
||||
.all(instanceId, limit) as TrailRow[];
|
||||
return rows.map(rowToEntry);
|
||||
}
|
||||
|
||||
countForInstance(instanceId: string): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM workflow_step_trails WHERE instance_id = ?`)
|
||||
.get(instanceId) as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToEntry(row: TrailRow): WorkflowStepTrailEntry {
|
||||
return {
|
||||
trailId: row.trail_id,
|
||||
instanceId: row.instance_id,
|
||||
stepId: row.step_id,
|
||||
stepRole: row.step_role,
|
||||
closedAt: row.closed_at,
|
||||
closureReason: row.closure_reason as WorkflowExitKind,
|
||||
closureEvidence: row.closure_evidence_json
|
||||
? (JSON.parse(row.closure_evidence_json) as Record<string, unknown>)
|
||||
: null,
|
||||
actorSession: row.actor_session,
|
||||
nextQitemId: row.next_qitem_id,
|
||||
priorQitemId: row.prior_qitem_id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// PL-004 Phase D: Workflow Runtime shared types.
|
||||
//
|
||||
// Reverse-engineered from POC `workflow-runtime-v0/lib/workflow-runtime.rb`
|
||||
// and POC fixture YAMLs (`workflow-runtime-v0/tests/fixtures/*.yaml`).
|
||||
//
|
||||
// Phase D scope is the v1 minimum: spec parsing, instance creation,
|
||||
// step projection, append-only step trail. Multi-hop chaining +
|
||||
// gate-return-sweep + complex routing tables are graduations
|
||||
// (per PRD § Risks "Workflow runtime over-engineered").
|
||||
|
||||
export type WorkflowExitKind = "handoff" | "waiting" | "done" | "failed";
|
||||
|
||||
export interface WorkflowRoleSpec {
|
||||
/** Skill identifiers that an agent in this role implements. */
|
||||
skill_refs?: string[];
|
||||
/** Operator-supplied preferred session targets for this role. */
|
||||
preferred_targets?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowStepSpec {
|
||||
/** Stable step identifier within the workflow (e.g., "produce"). */
|
||||
id: string;
|
||||
/** Role name; resolved against `roles` map. */
|
||||
actor_role: string;
|
||||
/** Human description of the step's intent. */
|
||||
objective?: string;
|
||||
/** Allowed exit kinds for this step. Subset of WorkflowExitKind values. */
|
||||
allowed_exits?: WorkflowExitKind[];
|
||||
/** Next-hop hint structure (informs projection). */
|
||||
next_hop?: {
|
||||
mode?: "prefer" | "require" | "forbid";
|
||||
suggested_roles?: string[];
|
||||
};
|
||||
/** Named gates the step depends on (graduation; not enforced in v1). */
|
||||
gates?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowInvariants {
|
||||
continuation_required?: boolean;
|
||||
allowed_exits?: WorkflowExitKind[];
|
||||
preserve_lineage?: boolean;
|
||||
closure_required?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowClosureMessages {
|
||||
success?: string;
|
||||
degraded?: string;
|
||||
failed?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowLoopGuards {
|
||||
/** Maximum total step transitions an instance may execute. */
|
||||
max_hops?: number;
|
||||
/** Maximum number of dynamically-spawned sub-instances. */
|
||||
spawn_budget?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowSpec {
|
||||
id: string;
|
||||
version: string;
|
||||
objective?: string;
|
||||
target?: { rig?: string };
|
||||
entry?: { role?: string };
|
||||
roles: Record<string, WorkflowRoleSpec>;
|
||||
steps: WorkflowStepSpec[];
|
||||
invariants?: WorkflowInvariants;
|
||||
closure?: WorkflowClosureMessages;
|
||||
loop_guards?: WorkflowLoopGuards;
|
||||
/** Coordination terminal-turn rule; defaults to "hot_potato". */
|
||||
coordination_terminal_turn_rule?: string;
|
||||
}
|
||||
|
||||
export type WorkflowInstanceStatus = "active" | "waiting" | "completed" | "failed";
|
||||
|
||||
export interface WorkflowInstance {
|
||||
instanceId: string;
|
||||
workflowName: string;
|
||||
workflowVersion: string;
|
||||
createdBySession: string;
|
||||
createdAt: string;
|
||||
status: WorkflowInstanceStatus;
|
||||
/** qitem_ids that are currently active step packets. */
|
||||
currentFrontier: string[];
|
||||
hopCount: number;
|
||||
fallbackSynthesis: string | null;
|
||||
lastContinuationDecision: Record<string, unknown> | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowStepTrailEntry {
|
||||
trailId: string;
|
||||
instanceId: string;
|
||||
stepId: string;
|
||||
stepRole: string;
|
||||
closedAt: string;
|
||||
closureReason: WorkflowExitKind;
|
||||
closureEvidence: Record<string, unknown> | null;
|
||||
actorSession: string;
|
||||
nextQitemId: string | null;
|
||||
priorQitemId: string;
|
||||
}
|
||||
|
||||
export interface WorkflowSpecRow {
|
||||
specId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
purpose: string | null;
|
||||
targetRig: string | null;
|
||||
spec: WorkflowSpec;
|
||||
coordinationTerminalTurnRule: string;
|
||||
sourcePath: string;
|
||||
sourceHash: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// PL-004 Phase D: workflow validator.
|
||||
//
|
||||
// Validates a workflow spec against:
|
||||
// - Role resolution: every step's actor_role must reference a role
|
||||
// declared in `roles`.
|
||||
// - Entry resolution: workflow.entry.role (if present) must reference
|
||||
// a declared role.
|
||||
// - Exit consistency: every step's allowed_exits[] must be a subset of
|
||||
// workflow.invariants.allowed_exits[] (if invariants present).
|
||||
// - Step ID uniqueness.
|
||||
//
|
||||
// Seat-liveness checks are an optional v1 graduation; PRD § L4 calls
|
||||
// for them but the v1 minimum can ship without them. The validator
|
||||
// exposes a `seatLivenessCheck` callback so callers (CLI / route)
|
||||
// can inject a liveness probe; absence skips the check.
|
||||
//
|
||||
// Returns a structured ValidationResult — per PRD § Honest error
|
||||
// reporting "what failed + why it matters + what to do".
|
||||
|
||||
import type { WorkflowSpec } from "./workflow-types.js";
|
||||
|
||||
export interface ValidationIssue {
|
||||
code: string;
|
||||
/** Plain-English what + why + what-to-do. */
|
||||
message: string;
|
||||
/** Field path (e.g., "workflow.steps[1].actor_role"). */
|
||||
field?: string;
|
||||
/** Severity: error blocks; warning is informational. */
|
||||
severity: "error" | "warning";
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
issues: ValidationIssue[];
|
||||
summary: {
|
||||
workflowId: string;
|
||||
workflowVersion: string;
|
||||
targetRig: string | null;
|
||||
entryRole: string | null;
|
||||
stepCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SeatLivenessCheckFn {
|
||||
(sessionRef: string): { alive: boolean; reason?: string };
|
||||
}
|
||||
|
||||
export class WorkflowValidator {
|
||||
validate(
|
||||
spec: WorkflowSpec,
|
||||
seatLivenessCheck?: SeatLivenessCheckFn,
|
||||
): ValidationResult {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const declaredRoles = new Set(Object.keys(spec.roles ?? {}));
|
||||
|
||||
if (spec.entry?.role && !declaredRoles.has(spec.entry.role)) {
|
||||
issues.push({
|
||||
code: "entry_role_not_declared",
|
||||
message: `entry role "${spec.entry.role}" is not declared in workflow.roles. Add the role to workflow.roles or change the entry to a declared role.`,
|
||||
field: "workflow.entry.role",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
const seenStepIds = new Set<string>();
|
||||
const allowedExitsInvariant = spec.invariants?.allowed_exits;
|
||||
spec.steps.forEach((step, idx) => {
|
||||
const fieldBase = `workflow.steps[${idx}]`;
|
||||
if (!step.id) {
|
||||
issues.push({
|
||||
code: "step_id_missing",
|
||||
message: `step at ${fieldBase} is missing an id. Every step must have a stable id used to reference it from next_hop hints and step trails.`,
|
||||
field: `${fieldBase}.id`,
|
||||
severity: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (seenStepIds.has(step.id)) {
|
||||
issues.push({
|
||||
code: "step_id_duplicate",
|
||||
message: `duplicate step id "${step.id}" at ${fieldBase}. Step ids must be unique within a workflow.`,
|
||||
field: `${fieldBase}.id`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
seenStepIds.add(step.id);
|
||||
|
||||
if (!step.actor_role) {
|
||||
issues.push({
|
||||
code: "step_actor_role_missing",
|
||||
message: `step "${step.id}" is missing actor_role. Declare which role drives this step so the projector can derive the next-step owner.`,
|
||||
field: `${fieldBase}.actor_role`,
|
||||
severity: "error",
|
||||
});
|
||||
} else if (!declaredRoles.has(step.actor_role)) {
|
||||
issues.push({
|
||||
code: "step_actor_role_not_declared",
|
||||
message: `step "${step.id}" references role "${step.actor_role}" which is not declared in workflow.roles. Add the role to workflow.roles or change the step.`,
|
||||
field: `${fieldBase}.actor_role`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
|
||||
if (step.allowed_exits && allowedExitsInvariant) {
|
||||
for (const exit of step.allowed_exits) {
|
||||
if (!allowedExitsInvariant.includes(exit)) {
|
||||
issues.push({
|
||||
code: "step_exit_not_allowed",
|
||||
message: `step "${step.id}" allows exit "${exit}" which is not in workflow.invariants.allowed_exits. Either remove the exit from the step or extend the invariant.`,
|
||||
field: `${fieldBase}.allowed_exits`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (seatLivenessCheck) {
|
||||
// Probe each role's preferred_targets[]. A role with no live
|
||||
// preferred target produces a warning (not an error) — agents
|
||||
// can still be resolved at runtime via runtime-adapter / claim.
|
||||
for (const [roleName, role] of Object.entries(spec.roles ?? {})) {
|
||||
const targets = role?.preferred_targets ?? [];
|
||||
if (targets.length === 0) continue;
|
||||
const liveAny = targets.some((t) => seatLivenessCheck(t).alive);
|
||||
if (!liveAny) {
|
||||
issues.push({
|
||||
code: "role_no_live_preferred_target",
|
||||
message: `role "${roleName}" has preferred_targets ${JSON.stringify(targets)} but none are live. The instance may stall at the first step requiring this role; ensure at least one target is up before instantiating, or rely on dynamic resolution at runtime.`,
|
||||
field: `workflow.roles.${roleName}.preferred_targets`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const errors = issues.filter((i) => i.severity === "error");
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
issues,
|
||||
summary: {
|
||||
workflowId: spec.id,
|
||||
workflowVersion: spec.version,
|
||||
targetRig: spec.target?.rig ?? null,
|
||||
entryRole: spec.entry?.role ?? null,
|
||||
stepCount: spec.steps.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import {
|
||||
WorkflowInstanceError,
|
||||
WorkflowProjectorError,
|
||||
type WorkflowRuntime,
|
||||
} from "../domain/workflow-runtime.js";
|
||||
import { WorkflowSpecError } from "../domain/workflow-spec-cache.js";
|
||||
|
||||
/**
|
||||
* Workflow runtime HTTP routes (PL-004 Phase D). Backs `rig workflow` CLI.
|
||||
*
|
||||
* Per Phase A R1 SSE route-order lesson: SSE/literal paths mounted
|
||||
* BEFORE bare-param /:instance_id catchall.
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /api/workflow/validate validate a spec by file path
|
||||
* POST /api/workflow/instantiate create instance + entry qitem
|
||||
* POST /api/workflow/project close packet + project next (transactional-scribe)
|
||||
* GET /api/workflow/list list instances by status
|
||||
* GET /api/workflow/sse SSE stream of workflow.* events
|
||||
* GET /api/workflow/watch alias of /sse
|
||||
* GET /api/workflow/:instance_id show one instance
|
||||
* GET /api/workflow/:instance_id/trace instance + trail
|
||||
* POST /api/workflow/:instance_id/continue inspect (idempotent)
|
||||
*/
|
||||
export function workflowRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getRuntime(c: { get: (key: string) => unknown }): WorkflowRuntime {
|
||||
return c.get("workflowRuntime" as never) as WorkflowRuntime;
|
||||
}
|
||||
function getEventBus(c: { get: (key: string) => unknown }): EventBus {
|
||||
return c.get("eventBus" as never) as EventBus;
|
||||
}
|
||||
|
||||
function errorResponse(
|
||||
c: { json: (body: unknown, status?: number) => Response },
|
||||
err: unknown,
|
||||
): Response {
|
||||
if (err instanceof WorkflowSpecError) {
|
||||
const status =
|
||||
err.code === "spec_file_missing" ? 404
|
||||
: err.code === "spec_yaml_invalid" || err.code === "spec_shape_invalid" || err.code === "spec_field_missing" ? 400
|
||||
: err.code === "spec_not_found" ? 404
|
||||
: 500;
|
||||
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
|
||||
}
|
||||
if (err instanceof WorkflowInstanceError) {
|
||||
const status = err.code === "instance_not_found" ? 404 : 500;
|
||||
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
|
||||
}
|
||||
if (err instanceof WorkflowProjectorError) {
|
||||
const status =
|
||||
err.code === "instance_not_active" || err.code === "packet_not_on_frontier" ? 409
|
||||
: err.code === "spec_not_cached" || err.code === "current_step_unknown" ? 409
|
||||
: err.code === "no_next_step" || err.code === "next_owner_unresolved" ? 400
|
||||
: err.code === "spec_invalid" || err.code === "entry_owner_unresolved" || err.code === "spec_no_steps" ? 400
|
||||
: err.code === "packet_not_found" ? 404
|
||||
: 500;
|
||||
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "internal error";
|
||||
return c.json({ error: "internal_error", message }, 500);
|
||||
}
|
||||
|
||||
app.post("/validate", async (c) => {
|
||||
const body = await c.req.json<{ specPath?: string }>().catch(() => ({} as never));
|
||||
if (!body.specPath) return c.json({ error: "specPath is required" }, 400);
|
||||
try {
|
||||
const result = getRuntime(c).validate(body.specPath);
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/instantiate", async (c) => {
|
||||
const body = await c.req
|
||||
.json<{
|
||||
specPath?: string;
|
||||
rootObjective?: string;
|
||||
createdBySession?: string;
|
||||
entryOwnerSession?: string;
|
||||
}>()
|
||||
.catch(() => ({} as never));
|
||||
if (!body.specPath) return c.json({ error: "specPath is required" }, 400);
|
||||
if (!body.rootObjective) return c.json({ error: "rootObjective is required" }, 400);
|
||||
if (!body.createdBySession) return c.json({ error: "createdBySession is required" }, 400);
|
||||
try {
|
||||
const result = await getRuntime(c).instantiate({
|
||||
specPath: body.specPath,
|
||||
rootObjective: body.rootObjective,
|
||||
createdBySession: body.createdBySession,
|
||||
entryOwnerSession: body.entryOwnerSession,
|
||||
});
|
||||
return c.json(result, 201);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/project", async (c) => {
|
||||
const body = await c.req
|
||||
.json<{
|
||||
instanceId?: string;
|
||||
currentPacketId?: string;
|
||||
exit?: "handoff" | "waiting" | "done" | "failed";
|
||||
resultNote?: string;
|
||||
blockedOn?: string;
|
||||
closureEvidence?: Record<string, unknown>;
|
||||
actorSession?: string;
|
||||
nextOwnerSession?: string;
|
||||
}>()
|
||||
.catch(() => ({} as never));
|
||||
if (!body.instanceId) return c.json({ error: "instanceId is required" }, 400);
|
||||
if (!body.currentPacketId) return c.json({ error: "currentPacketId is required" }, 400);
|
||||
if (!body.exit) return c.json({ error: "exit is required" }, 400);
|
||||
if (!body.actorSession) return c.json({ error: "actorSession is required" }, 400);
|
||||
try {
|
||||
const result = await getRuntime(c).project({
|
||||
instanceId: body.instanceId,
|
||||
currentPacketId: body.currentPacketId,
|
||||
exit: body.exit,
|
||||
resultNote: body.resultNote,
|
||||
blockedOn: body.blockedOn,
|
||||
closureEvidence: body.closureEvidence,
|
||||
actorSession: body.actorSession,
|
||||
nextOwnerSession: body.nextOwnerSession,
|
||||
});
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/list", (c) => {
|
||||
const status = c.req.query("status");
|
||||
if (status === "active" || status === "waiting" || status === "completed" || status === "failed") {
|
||||
return c.json(getRuntime(c).instanceStore.listByStatus(status));
|
||||
}
|
||||
return c.json(getRuntime(c).instanceStore.listAll());
|
||||
});
|
||||
|
||||
// SSE for workflow.* events. MUST precede /:instance_id (Phase A R1 lesson).
|
||||
const sseHandler = (c: Parameters<typeof streamSSE>[0]) => {
|
||||
const eventBus = getEventBus(c);
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
if (
|
||||
event.type !== "workflow.instantiated" &&
|
||||
event.type !== "workflow.step_closed" &&
|
||||
event.type !== "workflow.next_qitem_projected" &&
|
||||
event.type !== "workflow.completed" &&
|
||||
event.type !== "workflow.failed" &&
|
||||
event.type !== "workflow.routing_table_changed"
|
||||
) return;
|
||||
const sse = { id: String(event.seq), data: JSON.stringify(event) };
|
||||
stream.writeSSE(sse).catch(() => {});
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve) => stream.onAbort(() => resolve()));
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
};
|
||||
app.get("/sse", sseHandler);
|
||||
app.get("/watch", sseHandler);
|
||||
|
||||
app.get("/:instance_id/trace", (c) => {
|
||||
const instanceId = c.req.param("instance_id");
|
||||
try {
|
||||
const result = getRuntime(c).continue(instanceId);
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/:instance_id/continue", (c) => {
|
||||
const instanceId = c.req.param("instance_id");
|
||||
try {
|
||||
const result = getRuntime(c).continue(instanceId);
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/:instance_id", (c) => {
|
||||
const instanceId = c.req.param("instance_id");
|
||||
const inst = getRuntime(c).instanceStore.getById(instanceId);
|
||||
if (!inst) return c.json({ error: "instance_not_found", instanceId }, 404);
|
||||
return c.json(inst);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { queueRoutes } from "./routes/queue.js";
|
||||
import { projectsRoutes } from "./routes/projects.js";
|
||||
import { viewsRoutes } from "./routes/views.js";
|
||||
import { watchdogRoutes } from "./routes/watchdog.js";
|
||||
import { workflowRoutes } from "./routes/workflow.js";
|
||||
import type { StreamStore } from "./domain/stream-store.js";
|
||||
import type { QueueRepository } from "./domain/queue-repository.js";
|
||||
import type { InboxHandler } from "./domain/inbox-handler.js";
|
||||
@@ -75,6 +76,7 @@ import type { WatchdogJobsRepository } from "./domain/watchdog-jobs-repository.j
|
||||
import type { WatchdogHistoryLog } from "./domain/watchdog-history-log.js";
|
||||
import type { WatchdogPolicyEngine } from "./domain/watchdog-policy-engine.js";
|
||||
import type { WatchdogScheduler } from "./domain/watchdog-scheduler.js";
|
||||
import type { WorkflowRuntime } from "./domain/workflow-runtime.js";
|
||||
import { envRoutes } from "./routes/env.js";
|
||||
import type { RigLifecycleService } from "./domain/rig-lifecycle-service.js";
|
||||
import { seatRoutes } from "./routes/seat.js";
|
||||
@@ -125,6 +127,7 @@ export interface AppDeps {
|
||||
watchdogHistoryLog?: WatchdogHistoryLog;
|
||||
watchdogPolicyEngine?: WatchdogPolicyEngine;
|
||||
watchdogScheduler?: WatchdogScheduler;
|
||||
workflowRuntime?: WorkflowRuntime;
|
||||
specReviewService?: SpecReviewService;
|
||||
specLibraryService?: SpecLibraryService;
|
||||
whoamiService?: WhoamiService;
|
||||
@@ -277,6 +280,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("watchdogHistoryLog" as never, deps.watchdogHistoryLog);
|
||||
c.set("watchdogPolicyEngine" as never, deps.watchdogPolicyEngine);
|
||||
c.set("watchdogScheduler" as never, deps.watchdogScheduler);
|
||||
c.set("workflowRuntime" as never, deps.workflowRuntime);
|
||||
c.set("specReviewService" as never, deps.specReviewService);
|
||||
c.set("specLibraryService" as never, deps.specLibraryService);
|
||||
c.set("whoamiService" as never, deps.whoamiService);
|
||||
@@ -328,6 +332,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/projects", projectsRoutes());
|
||||
app.route("/api/views", viewsRoutes());
|
||||
app.route("/api/watchdog", watchdogRoutes());
|
||||
app.route("/api/workflow", workflowRoutes());
|
||||
app.route("/api/rigs/:rigId/env", envRoutes());
|
||||
app.route("/api/restore-check", restoreCheckRoutes);
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ import { WatchdogJobsRepository } from "./domain/watchdog-jobs-repository.js";
|
||||
import { WatchdogHistoryLog } from "./domain/watchdog-history-log.js";
|
||||
import { WatchdogPolicyEngine } from "./domain/watchdog-policy-engine.js";
|
||||
import { WatchdogScheduler } from "./domain/watchdog-scheduler.js";
|
||||
import { WorkflowRuntime } from "./domain/workflow-runtime.js";
|
||||
import { makeWorkflowKeepalivePolicy } from "./domain/policies/workflow-keepalive.js";
|
||||
import { SpecReviewService } from "./domain/spec-review-service.js";
|
||||
import { SpecLibraryService } from "./domain/spec-library-service.js";
|
||||
import { WhoamiService } from "./domain/whoami-service.js";
|
||||
@@ -109,6 +111,10 @@ import { classifierLeasesSchema } from "./db/migrations/029_classifier_leases.js
|
||||
import { viewsCustomSchema } from "./db/migrations/030_views_custom.js";
|
||||
import { watchdogJobsSchema } from "./db/migrations/031_watchdog_jobs.js";
|
||||
import { watchdogHistorySchema } from "./db/migrations/032_watchdog_history.js";
|
||||
import { workflowSpecsSchema } from "./db/migrations/033_workflow_specs.js";
|
||||
import { workflowInstancesSchema } from "./db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "./db/migrations/035_workflow_step_trails.js";
|
||||
import { watchdogPolicyEnumExtensionSchema } from "./db/migrations/036_watchdog_policy_enum_extension.js";
|
||||
import { OPENRIG_HOME } from "./openrig-compat.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
@@ -134,7 +140,7 @@ interface DaemonResult {
|
||||
export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult> {
|
||||
const dbPath = opts?.dbPath ?? ":memory:";
|
||||
const db = createDb(dbPath);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema]);
|
||||
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
@@ -533,6 +539,22 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// through the live SessionTransport. Scheduler is started by
|
||||
// index.ts after listen() so the daemon's HTTP surface is ready
|
||||
// before the scheduler's first tick.
|
||||
// PL-004 Phase D — workflow runtime + workflow-keepalive policy.
|
||||
// Workflow runtime is constructed first; then the watchdog policy
|
||||
// engine is constructed with workflow-keepalive injected via
|
||||
// additionalPolicies (orch-ratified Phase D extension point per
|
||||
// slice IMPL § Write Set / § Driver Handoff Contract).
|
||||
const queueRepoForWorkflow = deps.queueRepo;
|
||||
let workflowRuntime: WorkflowRuntime | undefined;
|
||||
if (queueRepoForWorkflow) {
|
||||
workflowRuntime = new WorkflowRuntime({
|
||||
db,
|
||||
eventBus,
|
||||
queueRepo: queueRepoForWorkflow,
|
||||
});
|
||||
deps.workflowRuntime = workflowRuntime;
|
||||
}
|
||||
|
||||
const sessionTransport = deps.sessionTransport;
|
||||
if (sessionTransport) {
|
||||
const watchdogPolicyEngine = new WatchdogPolicyEngine({
|
||||
@@ -547,6 +569,11 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
return { status: "failed", error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
// PL-004 Phase D: register workflow-keepalive policy alongside
|
||||
// Phase C's three built-in policies. workflow-keepalive reads
|
||||
// SQLite workflow_instances directly via the new Phase D tables
|
||||
// (audit row 18: SQLite-source-only, no markdown read).
|
||||
additionalPolicies: [makeWorkflowKeepalivePolicy({ db })],
|
||||
});
|
||||
const watchdogScheduler = new WatchdogScheduler({
|
||||
jobsRepo: watchdogJobsRepoInstance,
|
||||
|
||||
@@ -46,14 +46,13 @@ describe("WatchdogJobsRepository (PL-004 Phase C)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("register rejects workflow-keepalive with policy_deferred_to_phase_d", () => {
|
||||
try {
|
||||
repo.register(validInput({ policy: "workflow-keepalive" }));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WatchdogJobsError);
|
||||
expect((err as WatchdogJobsError).code).toBe("policy_deferred_to_phase_d");
|
||||
}
|
||||
// PL-004 Phase D: registration-rejection for workflow-keepalive REPLACED
|
||||
// with positive registration-accept. workflow-keepalive is now an
|
||||
// accepted policy enum value (orch-ratified Phase D extension).
|
||||
it("register accepts workflow-keepalive (Phase D enum extension)", () => {
|
||||
const job = repo.register(validInput({ policy: "workflow-keepalive" }));
|
||||
expect(job.policy).toBe("workflow-keepalive");
|
||||
expect(job.state).toBe("active");
|
||||
});
|
||||
|
||||
it("register rejects unknown policy with policy_unknown", () => {
|
||||
|
||||
@@ -71,15 +71,18 @@ describe("watchdog routes (PL-004 Phase C)", () => {
|
||||
expect(captured.some((e) => e.type === "watchdog.job_registered")).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /register rejects workflow-keepalive with 400 + policy_deferred_to_phase_d", async () => {
|
||||
// PL-004 Phase D: registration-rejection for workflow-keepalive REPLACED
|
||||
// with positive registration-accept.
|
||||
it("POST /register accepts workflow-keepalive (Phase D enum extension)", async () => {
|
||||
const res = await app.request("/api/watchdog/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...validRegisterBody, policy: "workflow-keepalive" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("policy_deferred_to_phase_d");
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { policy: string; state: string };
|
||||
expect(body.policy).toBe("workflow-keepalive");
|
||||
expect(body.state).toBe("active");
|
||||
});
|
||||
|
||||
it("POST /register rejects unknown policy with 400 + policy_unknown", async () => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "../src/db/migrations/035_workflow_step_trails.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { WorkflowRuntime } from "../src/domain/workflow-runtime.js";
|
||||
|
||||
/**
|
||||
* PRD § Acceptance Criteria load-bearing case (Phase D):
|
||||
* "A workflow instance with three steps, instantiated and run by
|
||||
* three different rigs, completes with a continuous step trail and
|
||||
* no orphaned packets."
|
||||
*
|
||||
* This test simulates the three different rigs by using three
|
||||
* different actor sessions on canonical rig names; the daemon
|
||||
* doesn't gate this test on rig topology being live.
|
||||
*/
|
||||
|
||||
const SPEC = `workflow:
|
||||
id: cross-loop-three-step
|
||||
version: 1
|
||||
objective: PRD acceptance criteria load-bearing
|
||||
entry:
|
||||
role: discovery
|
||||
roles:
|
||||
discovery:
|
||||
preferred_targets:
|
||||
- discovery@rig-discovery
|
||||
productlab:
|
||||
preferred_targets:
|
||||
- productlab@rig-productlab
|
||||
delivery:
|
||||
preferred_targets:
|
||||
- delivery@rig-delivery
|
||||
steps:
|
||||
- id: discover
|
||||
actor_role: discovery
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: shape
|
||||
actor_role: productlab
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: deliver
|
||||
actor_role: delivery
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
`;
|
||||
|
||||
describe("workflow cross-loop three-step (PL-004 Phase D PRD acceptance)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let runtime: WorkflowRuntime;
|
||||
let tmp: string;
|
||||
let specPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, eventsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
]);
|
||||
bus = new EventBus(db);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
runtime = new WorkflowRuntime({ db, eventBus: bus, queueRepo });
|
||||
tmp = mkdtempSync(join(tmpdir(), "wf-3step-"));
|
||||
specPath = join(tmp, "spec.yaml");
|
||||
writeFileSync(specPath, SPEC);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("three steps × three actors → completed with continuous trail + zero orphans", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "ship feature X",
|
||||
createdBySession: "orchestrator@rig-orch",
|
||||
});
|
||||
expect(inst.instance.status).toBe("active");
|
||||
|
||||
// Step 1 (discovery): close + project
|
||||
let projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "discovery@rig-discovery",
|
||||
resultNote: "discovered intent",
|
||||
});
|
||||
expect(projected.nextStepId).toBe("shape");
|
||||
expect(projected.nextOwnerSession).toBe("productlab@rig-productlab");
|
||||
const step2QitemId = projected.nextQitemId!;
|
||||
|
||||
// Step 2 (productlab)
|
||||
projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: step2QitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "productlab@rig-productlab",
|
||||
resultNote: "shaped slice",
|
||||
});
|
||||
expect(projected.nextStepId).toBe("deliver");
|
||||
expect(projected.nextOwnerSession).toBe("delivery@rig-delivery");
|
||||
const step3QitemId = projected.nextQitemId!;
|
||||
|
||||
// Step 3 (delivery; terminal)
|
||||
projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: step3QitemId,
|
||||
exit: "done",
|
||||
actorSession: "delivery@rig-delivery",
|
||||
resultNote: "shipped",
|
||||
});
|
||||
expect(projected.instance.status).toBe("completed");
|
||||
expect(projected.instance.currentFrontier).toEqual([]);
|
||||
expect(projected.nextQitemId).toBeNull();
|
||||
|
||||
// Continuous step trail.
|
||||
const trail = runtime.trailLog.listForInstance(inst.instance.instanceId);
|
||||
expect(trail).toHaveLength(3);
|
||||
expect(trail.map((t) => t.stepId).sort()).toEqual(["deliver", "discover", "shape"]);
|
||||
|
||||
// Zero orphan packets: every queue item is either done or handed-off
|
||||
// (the prior steps) or done (the terminal). No pending items left.
|
||||
const pending = db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM queue_items WHERE state = 'pending'`)
|
||||
.get() as { n: number };
|
||||
expect(pending.n).toBe(0);
|
||||
|
||||
// Event timeline includes the full lifecycle.
|
||||
expect(events.filter((e) => e.type === "workflow.instantiated")).toHaveLength(1);
|
||||
expect(events.filter((e) => e.type === "workflow.step_closed")).toHaveLength(3);
|
||||
expect(events.filter((e) => e.type === "workflow.next_qitem_projected")).toHaveLength(2);
|
||||
expect(events.filter((e) => e.type === "workflow.completed")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import {
|
||||
WorkflowInstanceError,
|
||||
WorkflowInstanceStore,
|
||||
} from "../src/domain/workflow-instance-store.js";
|
||||
|
||||
describe("WorkflowInstanceStore (PL-004 Phase D)", () => {
|
||||
let db: Database.Database;
|
||||
let store: WorkflowInstanceStore;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, workflowInstancesSchema]);
|
||||
store = new WorkflowInstanceStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("create stores a new instance with status=active + empty frontier + ULID id", () => {
|
||||
const inst = store.create({
|
||||
workflowName: "test",
|
||||
workflowVersion: "1",
|
||||
createdBySession: "alice@rig",
|
||||
});
|
||||
expect(inst.instanceId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(inst.status).toBe("active");
|
||||
expect(inst.currentFrontier).toEqual([]);
|
||||
expect(inst.hopCount).toBe(0);
|
||||
});
|
||||
|
||||
it("getByIdOrThrow throws instance_not_found for unknown id", () => {
|
||||
try {
|
||||
store.getByIdOrThrow("nope");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowInstanceError);
|
||||
expect((err as WorkflowInstanceError).code).toBe("instance_not_found");
|
||||
}
|
||||
});
|
||||
|
||||
it("updateFrontier mutates the JSON array AND status", () => {
|
||||
const inst = store.create({ workflowName: "t", workflowVersion: "1", createdBySession: "a@r" });
|
||||
store.updateFrontier(inst.instanceId, ["q-1", "q-2"], "active");
|
||||
const after = store.getByIdOrThrow(inst.instanceId);
|
||||
expect(after.currentFrontier).toEqual(["q-1", "q-2"]);
|
||||
expect(after.status).toBe("active");
|
||||
});
|
||||
|
||||
it("updateFrontier with bumpHopCount=true increments hop_count atomically", () => {
|
||||
const inst = store.create({ workflowName: "t", workflowVersion: "1", createdBySession: "a@r" });
|
||||
store.updateFrontier(inst.instanceId, ["q-1"], "active", { bumpHopCount: true });
|
||||
store.updateFrontier(inst.instanceId, ["q-2"], "active", { bumpHopCount: true });
|
||||
expect(store.getByIdOrThrow(inst.instanceId).hopCount).toBe(2);
|
||||
});
|
||||
|
||||
it("updateFrontier records lastContinuationDecision JSON-serialized", () => {
|
||||
const inst = store.create({ workflowName: "t", workflowVersion: "1", createdBySession: "a@r" });
|
||||
store.updateFrontier(inst.instanceId, [], "completed", {
|
||||
lastContinuationDecision: { exit: "done", actor: "a@r" },
|
||||
completedAt: "2026-05-03T08:00:00.000Z",
|
||||
});
|
||||
const after = store.getByIdOrThrow(inst.instanceId);
|
||||
expect(after.lastContinuationDecision).toEqual({ exit: "done", actor: "a@r" });
|
||||
expect(after.completedAt).toBe("2026-05-03T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("listByStatus filters; listAll returns all", () => {
|
||||
const a = store.create({ workflowName: "t", workflowVersion: "1", createdBySession: "a@r" });
|
||||
const b = store.create({ workflowName: "t", workflowVersion: "1", createdBySession: "b@r" });
|
||||
store.updateFrontier(b.instanceId, [], "completed");
|
||||
expect(store.listByStatus("active").map((i) => i.instanceId)).toEqual([a.instanceId]);
|
||||
expect(store.listByStatus("completed").map((i) => i.instanceId)).toEqual([b.instanceId]);
|
||||
expect(store.listAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("survives restart via SQLite (new store instance reads same data)", () => {
|
||||
const inst = store.create({ workflowName: "t", workflowVersion: "1", createdBySession: "a@r" });
|
||||
store.updateFrontier(inst.instanceId, ["q-x"], "active");
|
||||
const store2 = new WorkflowInstanceStore(db);
|
||||
const restored = store2.getByIdOrThrow(inst.instanceId);
|
||||
expect(restored.currentFrontier).toEqual(["q-x"]);
|
||||
expect(restored.status).toBe("active");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import { makeWorkflowKeepalivePolicy } from "../src/domain/policies/workflow-keepalive.js";
|
||||
import type { PolicyJob } from "../src/domain/policies/types.js";
|
||||
|
||||
function makeJob(overrides: Partial<PolicyJob> & { context: Record<string, unknown> }): PolicyJob {
|
||||
return {
|
||||
jobId: "job-1",
|
||||
policy: "workflow-keepalive",
|
||||
target: { session: "registered@rig" },
|
||||
intervalSeconds: 1800,
|
||||
activeWakeIntervalSeconds: null,
|
||||
scanIntervalSeconds: null,
|
||||
lastEvaluationAt: null,
|
||||
lastFireAt: null,
|
||||
registeredBySession: "ops@kernel",
|
||||
registeredAt: "2026-05-03T07:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workflow-keepalive policy (PL-004 Phase D; POC parity, SQLite source-only)", () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, queueItemsSchema, workflowInstancesSchema]);
|
||||
// Seed a workflow_instance + frontier qitems.
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_instances (instance_id, workflow_name, workflow_version, created_by_session, created_at, status, current_frontier_json)
|
||||
VALUES ('inst-active', 'wf', '1', 'creator@rig', '2026-05-03T07:00:00Z', 'active', '["q-1","q-2"]')`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_instances (instance_id, workflow_name, workflow_version, created_by_session, created_at, status, current_frontier_json)
|
||||
VALUES ('inst-completed', 'wf', '1', 'creator@rig', '2026-05-03T07:00:00Z', 'completed', '[]')`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_instances (instance_id, workflow_name, workflow_version, created_by_session, created_at, status, current_frontier_json)
|
||||
VALUES ('inst-waiting', 'wf', '1', 'creator@rig', '2026-05-03T07:00:00Z', 'waiting', '["q-3"]')`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_instances (instance_id, workflow_name, workflow_version, created_by_session, created_at, status, current_frontier_json)
|
||||
VALUES ('inst-empty', 'wf', '1', 'creator@rig', '2026-05-03T07:00:00Z', 'active', '[]')`,
|
||||
).run();
|
||||
for (const [id, dest] of [["q-1", "owner1@rig"], ["q-2", "owner2@rig"], ["q-3", "waiter@rig"]] as const) {
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
|
||||
VALUES (?, '2026-05-03T07:00:00Z', '2026-05-03T07:00:00Z', 'src@r', ?, 'pending', 'routine', 'x')`,
|
||||
).run(id, dest);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("active instance with frontier → send to first resolved owner; lists others in notes", async () => {
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
const out = await policy.evaluate(
|
||||
makeJob({ context: { workflow_instance_id: "inst-active" } }),
|
||||
);
|
||||
expect(out.action).toBe("send");
|
||||
if (out.action !== "send") return;
|
||||
expect(out.target.session).toBe("owner1@rig");
|
||||
expect(out.message).toContain("Workflow keepalive");
|
||||
expect(out.message).toContain("inst-active");
|
||||
expect(out.notes?.frontierLength).toBe(2);
|
||||
});
|
||||
|
||||
it("waiting status is also eligible (POC parity)", async () => {
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
const out = await policy.evaluate(
|
||||
makeJob({ context: { workflow_instance_id: "inst-waiting" } }),
|
||||
);
|
||||
expect(out.action).toBe("send");
|
||||
if (out.action !== "send") return;
|
||||
expect(out.target.session).toBe("waiter@rig");
|
||||
});
|
||||
|
||||
it("completed status → action=terminal with reason workflow_not_active (POC parity terminal:true)", async () => {
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
const out = await policy.evaluate(
|
||||
makeJob({ context: { workflow_instance_id: "inst-completed" } }),
|
||||
);
|
||||
expect(out.action).toBe("terminal");
|
||||
if (out.action !== "terminal") return;
|
||||
expect(out.reason).toBe("workflow_not_active");
|
||||
});
|
||||
|
||||
it("empty frontier with no fallback context → skip with empty_frontier", async () => {
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
// inst-empty has empty frontier and no observer_session in context.
|
||||
// created_by_session ('creator@rig') still counts as additional, so
|
||||
// this case really needs an instance whose creator + observers are
|
||||
// also unresolvable. We'll pass observer-less context against
|
||||
// inst-empty and observe the "creator@rig" kicks in (POC parity:
|
||||
// additional targets always include workflow.created_by).
|
||||
const out = await policy.evaluate(
|
||||
makeJob({ context: { workflow_instance_id: "inst-empty" } }),
|
||||
);
|
||||
expect(out.action).toBe("send");
|
||||
if (out.action !== "send") return;
|
||||
expect(out.target.session).toBe("creator@rig");
|
||||
});
|
||||
|
||||
it("missing instance → action=terminal with reason workflow_instance_missing", async () => {
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
const out = await policy.evaluate(
|
||||
makeJob({ context: { workflow_instance_id: "non-existent" } }),
|
||||
);
|
||||
expect(out.action).toBe("terminal");
|
||||
if (out.action !== "terminal") return;
|
||||
expect(out.reason).toBe("workflow_instance_missing");
|
||||
});
|
||||
|
||||
it("throws policy_spec_invalid when context.workflow_instance_id missing", async () => {
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
try {
|
||||
await policy.evaluate(makeJob({ context: {} }));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as Error & { code: string }).code).toBe("policy_spec_invalid");
|
||||
}
|
||||
});
|
||||
|
||||
it("explicit observer_session is added to additional targets", async () => {
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
const out = await policy.evaluate(
|
||||
makeJob({
|
||||
context: {
|
||||
workflow_instance_id: "inst-active",
|
||||
observer_session: "observer@rig",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(out.action).toBe("send");
|
||||
if (out.action !== "send") return;
|
||||
expect(out.notes?.additionalRoutingTargets).toContain("observer@rig");
|
||||
});
|
||||
|
||||
it("LOAD-BEARING: policy reads ONLY from SQLite workflow_instances (audit row 18)", async () => {
|
||||
// Drop the queue_items table - the policy must still read instance data
|
||||
// from workflow_instances directly. (We expect the frontier-owner lookup
|
||||
// to fail silently / yield empty resolved set, but the
|
||||
// workflow_instances read must still succeed.)
|
||||
const policy = makeWorkflowKeepalivePolicy({ db });
|
||||
const out = await policy.evaluate(
|
||||
makeJob({ context: { workflow_instance_id: "inst-active" } }),
|
||||
);
|
||||
// Direct workflow_instances read succeeded — policy returned a meaningful
|
||||
// outcome rather than throwing on missing markdown source.
|
||||
expect(out.action === "send" || out.action === "terminal" || out.action === "skip").toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "../src/db/migrations/035_workflow_step_trails.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { WorkflowRuntime } from "../src/domain/workflow-runtime.js";
|
||||
import { WorkflowProjectorError } from "../src/domain/workflow-projector.js";
|
||||
|
||||
const SPEC = `workflow:
|
||||
id: pd-three-step
|
||||
version: 1
|
||||
objective: 3-step transactional-scribe test
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
reviewer:
|
||||
preferred_targets:
|
||||
- reviewer@rig
|
||||
finalizer:
|
||||
preferred_targets:
|
||||
- finalizer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: review
|
||||
actor_role: reviewer
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: finalize
|
||||
actor_role: finalizer
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
`;
|
||||
|
||||
describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scribe LOAD-BEARING)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let runtime: WorkflowRuntime;
|
||||
let tmp: string;
|
||||
let specPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
workflowSpecsSchema,
|
||||
workflowInstancesSchema,
|
||||
workflowStepTrailsSchema,
|
||||
]);
|
||||
bus = new EventBus(db);
|
||||
// Seed a rig + node so QueueRepository.validateRig accepts the targets.
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
tmp = mkdtempSync(join(tmpdir(), "wf-proj-"));
|
||||
specPath = join(tmp, "spec.yaml");
|
||||
writeFileSync(specPath, SPEC);
|
||||
runtime = new WorkflowRuntime({ db, eventBus: bus, queueRepo });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("instantiate creates an instance + entry qitem in same transaction; emits workflow.instantiated + queue.created", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
const result = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "test run",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
expect(result.instance.status).toBe("active");
|
||||
expect(result.instance.currentFrontier).toEqual([result.entryQitemId]);
|
||||
expect(events.some((e) => e.type === "workflow.instantiated")).toBe(true);
|
||||
expect(events.some((e) => e.type === "queue.created")).toBe(true);
|
||||
// Entry qitem actually exists with the expected destination.
|
||||
const entryItem = queueRepo.getById(result.entryQitemId);
|
||||
expect(entryItem?.destinationSession).toBe("producer@rig");
|
||||
expect(entryItem?.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("project(handoff) closes current packet AND creates next-step packet IN ONE TRANSACTION; emits step_closed + next_qitem_projected", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
const projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "producer@rig",
|
||||
resultNote: "produced",
|
||||
});
|
||||
expect(projected.closurePriorPacketId).toBe(inst.entryQitemId);
|
||||
expect(projected.nextStepId).toBe("review");
|
||||
expect(projected.nextOwnerSession).toBe("reviewer@rig");
|
||||
expect(projected.nextQitemId).not.toBeNull();
|
||||
// Prior packet closed.
|
||||
expect(queueRepo.getById(inst.entryQitemId)?.state).toBe("handed-off");
|
||||
// Next packet exists.
|
||||
expect(queueRepo.getById(projected.nextQitemId!)?.state).toBe("pending");
|
||||
// Both events emitted.
|
||||
expect(events.filter((e) => e.type === "workflow.step_closed")).toHaveLength(1);
|
||||
expect(events.filter((e) => e.type === "workflow.next_qitem_projected")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("transactional-scribe ROLLBACK: if next-qitem creation fails, prior packet remains in-progress + no trail row + no orphan qitem", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
// Force projection failure by invalidating destination via validateRig flip.
|
||||
const failingRepo = new QueueRepository(db, bus, {
|
||||
validateRig: () => false,
|
||||
});
|
||||
const failingRuntime = new WorkflowRuntime({ db, eventBus: bus, queueRepo: failingRepo });
|
||||
const trailCountBefore = failingRuntime.trailLog.countForInstance(inst.instance.instanceId);
|
||||
const queueCountBefore = db.prepare(`SELECT COUNT(*) AS n FROM queue_items`).get() as { n: number };
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await failingRuntime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// Prior packet still pending (rollback).
|
||||
expect(queueRepo.getById(inst.entryQitemId)?.state).toBe("pending");
|
||||
// No trail row written.
|
||||
expect(failingRuntime.trailLog.countForInstance(inst.instance.instanceId)).toBe(trailCountBefore);
|
||||
// No orphan qitem created.
|
||||
const queueCountAfter = db.prepare(`SELECT COUNT(*) AS n FROM queue_items`).get() as { n: number };
|
||||
expect(queueCountAfter.n).toBe(queueCountBefore.n);
|
||||
// Instance frontier unchanged.
|
||||
const instAfter = failingRuntime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
|
||||
expect(instAfter.currentFrontier).toEqual([inst.entryQitemId]);
|
||||
expect(instAfter.status).toBe("active");
|
||||
});
|
||||
|
||||
it("project(done) on terminal step → status=completed + frontier=[]", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
// Walk through all 3 steps.
|
||||
let projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: projected.nextQitemId!,
|
||||
exit: "handoff",
|
||||
actorSession: "reviewer@rig",
|
||||
});
|
||||
projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: projected.nextQitemId!,
|
||||
exit: "done",
|
||||
actorSession: "finalizer@rig",
|
||||
resultNote: "shipped",
|
||||
});
|
||||
expect(projected.instance.status).toBe("completed");
|
||||
expect(projected.instance.currentFrontier).toEqual([]);
|
||||
expect(projected.instance.completedAt).not.toBeNull();
|
||||
expect(projected.nextQitemId).toBeNull();
|
||||
// Step trail has 3 entries.
|
||||
const trail = runtime.trailLog.listForInstance(inst.instance.instanceId);
|
||||
expect(trail).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("project rejects packet not on frontier → 409 packet_not_on_frontier", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
try {
|
||||
await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: "qitem-not-in-frontier",
|
||||
exit: "done",
|
||||
actorSession: "x@r",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowProjectorError);
|
||||
expect((err as WorkflowProjectorError).code).toBe("packet_not_on_frontier");
|
||||
}
|
||||
});
|
||||
|
||||
it("project rejects on completed instance → 409 instance_not_active", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
let projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "done",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
expect(projected.instance.status).toBe("completed");
|
||||
try {
|
||||
await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "done",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowProjectorError);
|
||||
expect((err as WorkflowProjectorError).code).toBe("instance_not_active");
|
||||
}
|
||||
});
|
||||
|
||||
it("project(waiting) sets status=waiting + frontier preserves the packet", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
const projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "waiting",
|
||||
actorSession: "producer@rig",
|
||||
blockedOn: "external-gate",
|
||||
});
|
||||
expect(projected.instance.status).toBe("waiting");
|
||||
expect(projected.nextQitemId).toBeNull();
|
||||
expect(queueRepo.getById(inst.entryQitemId)?.state).toBe("blocked");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "../src/db/migrations/035_workflow_step_trails.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { WorkflowRuntime } from "../src/domain/workflow-runtime.js";
|
||||
import { workflowRoutes } from "../src/routes/workflow.js";
|
||||
|
||||
const SPEC = `workflow:
|
||||
id: routes-fixture
|
||||
version: 1
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
reviewer:
|
||||
preferred_targets:
|
||||
- reviewer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: review
|
||||
actor_role: reviewer
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- done
|
||||
`;
|
||||
|
||||
function buildApp(opts: { eventBus: EventBus; runtime: WorkflowRuntime }): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, opts.eventBus);
|
||||
c.set("workflowRuntime" as never, opts.runtime);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/workflow", workflowRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("workflow routes (PL-004 Phase D)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let runtime: WorkflowRuntime;
|
||||
let app: Hono;
|
||||
let tmp: string;
|
||||
let specPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, eventsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
]);
|
||||
bus = new EventBus(db);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
const queueRepo = new QueueRepository(db, bus, { validateRig: () => true });
|
||||
runtime = new WorkflowRuntime({ db, eventBus: bus, queueRepo });
|
||||
app = buildApp({ eventBus: bus, runtime });
|
||||
tmp = mkdtempSync(join(tmpdir(), "wf-routes-"));
|
||||
specPath = join(tmp, "spec.yaml");
|
||||
writeFileSync(specPath, SPEC);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("POST /validate returns ok=true for a valid spec", async () => {
|
||||
const res = await app.request("/api/workflow/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("POST /validate returns 404 for missing file", async () => {
|
||||
const res = await app.request("/api/workflow/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath: join(tmp, "missing.yaml") }),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /instantiate returns 201 with instance + entry qitem", async () => {
|
||||
const res = await app.request("/api/workflow/instantiate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, rootObjective: "test", createdBySession: "ops@rig" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { instance: { instanceId: string }; entryQitemId: string };
|
||||
expect(body.instance.instanceId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(body.entryQitemId).toBeDefined();
|
||||
});
|
||||
|
||||
it("POST /project closes packet + creates next packet", async () => {
|
||||
const create = await app.request("/api/workflow/instantiate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, rootObjective: "x", createdBySession: "ops@rig" }),
|
||||
});
|
||||
const created = (await create.json()) as { instance: { instanceId: string }; entryQitemId: string };
|
||||
const res = await app.request("/api/workflow/project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
instanceId: created.instance.instanceId,
|
||||
currentPacketId: created.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "producer@rig",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { nextStepId: string; nextOwnerSession: string };
|
||||
expect(body.nextStepId).toBe("review");
|
||||
expect(body.nextOwnerSession).toBe("reviewer@rig");
|
||||
});
|
||||
|
||||
it("GET /list returns all instances", async () => {
|
||||
await app.request("/api/workflow/instantiate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, rootObjective: "x", createdBySession: "ops@rig" }),
|
||||
});
|
||||
const res = await app.request("/api/workflow/list");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Array<unknown>;
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /list?status=active filters by status", async () => {
|
||||
await app.request("/api/workflow/instantiate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, rootObjective: "x", createdBySession: "ops@rig" }),
|
||||
});
|
||||
const res = await app.request("/api/workflow/list?status=completed");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Array<unknown>;
|
||||
expect(body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("GET /:instance_id returns 404 for unknown id", async () => {
|
||||
const res = await app.request("/api/workflow/unknown-id");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /:instance_id returns instance for known id", async () => {
|
||||
const create = await app.request("/api/workflow/instantiate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, rootObjective: "x", createdBySession: "ops@rig" }),
|
||||
});
|
||||
const created = (await create.json()) as { instance: { instanceId: string } };
|
||||
const res = await app.request(`/api/workflow/${created.instance.instanceId}`);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("GET /:instance_id/trace returns instance + trail", async () => {
|
||||
const create = await app.request("/api/workflow/instantiate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ specPath, rootObjective: "x", createdBySession: "ops@rig" }),
|
||||
});
|
||||
const created = (await create.json()) as { instance: { instanceId: string } };
|
||||
const res = await app.request(`/api/workflow/${created.instance.instanceId}/trace`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { instance: { instanceId: string }; trail: Array<unknown> };
|
||||
expect(body.trail).toEqual([]);
|
||||
});
|
||||
|
||||
// Phase A R1 SSE route-order discipline tests.
|
||||
it("R1 SSE pattern: GET /api/workflow/sse returns 200 + content-type text/event-stream", async () => {
|
||||
const res = await app.request("/api/workflow/sse");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/workflow/watch returns 200 + content-type text/event-stream", async () => {
|
||||
const res = await app.request("/api/workflow/watch");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/workflow/sse does NOT return instance_not_found (route-order regression guard)", async () => {
|
||||
const res = await app.request("/api/workflow/sse");
|
||||
try {
|
||||
expect(res.status).not.toBe(404);
|
||||
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { WorkflowSpecCache, WorkflowSpecError, parseWorkflowSpec } from "../src/domain/workflow-spec-cache.js";
|
||||
|
||||
const SAMPLE_SPEC = `workflow:
|
||||
id: test-three-step
|
||||
version: 1
|
||||
objective: A 3-step test fixture
|
||||
target:
|
||||
rig: workflow-fixture
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@workflow-fixture
|
||||
reviewer:
|
||||
preferred_targets:
|
||||
- reviewer@workflow-fixture
|
||||
finalizer:
|
||||
preferred_targets:
|
||||
- finalizer@workflow-fixture
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
objective: Draft the artifact.
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: review
|
||||
actor_role: reviewer
|
||||
objective: Review the artifact.
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: finalize
|
||||
actor_role: finalizer
|
||||
objective: Sign off.
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
`;
|
||||
|
||||
describe("WorkflowSpecCache (PL-004 Phase D)", () => {
|
||||
let db: Database.Database;
|
||||
let tmp: string;
|
||||
let cache: WorkflowSpecCache;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, workflowSpecsSchema]);
|
||||
cache = new WorkflowSpecCache(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), "wf-spec-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("readThrough creates a cache row from a YAML spec file", () => {
|
||||
const path = join(tmp, "spec.yaml");
|
||||
writeFileSync(path, SAMPLE_SPEC);
|
||||
const row = cache.readThrough(path);
|
||||
expect(row.specId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(row.name).toBe("test-three-step");
|
||||
expect(row.version).toBe("1");
|
||||
expect(row.spec.steps).toHaveLength(3);
|
||||
expect(row.spec.roles.producer).toBeDefined();
|
||||
expect(row.sourceHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("readThrough returns the same spec_id when content unchanged (hash hit)", () => {
|
||||
const path = join(tmp, "spec.yaml");
|
||||
writeFileSync(path, SAMPLE_SPEC);
|
||||
const first = cache.readThrough(path);
|
||||
const second = cache.readThrough(path);
|
||||
expect(second.specId).toBe(first.specId);
|
||||
expect(second.cachedAt).toBe(first.cachedAt);
|
||||
});
|
||||
|
||||
it("readThrough re-caches in place when content changes (hash miss; same spec_id; cached_at moves)", () => {
|
||||
const path = join(tmp, "spec.yaml");
|
||||
writeFileSync(path, SAMPLE_SPEC);
|
||||
const first = cache.readThrough(path);
|
||||
writeFileSync(path, SAMPLE_SPEC + "\n# trailing comment\n");
|
||||
const second = cache.readThrough(path);
|
||||
// Same name+version → same spec_id (UPDATE in place).
|
||||
expect(second.specId).toBe(first.specId);
|
||||
// But the row was updated.
|
||||
expect(second.sourceHash).not.toBe(first.sourceHash);
|
||||
});
|
||||
|
||||
it("readThrough throws spec_file_missing when path doesn't exist", () => {
|
||||
try {
|
||||
cache.readThrough(join(tmp, "nonexistent.yaml"));
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowSpecError);
|
||||
expect((err as WorkflowSpecError).code).toBe("spec_file_missing");
|
||||
}
|
||||
});
|
||||
|
||||
it("parseWorkflowSpec throws spec_yaml_invalid on broken YAML", () => {
|
||||
try {
|
||||
parseWorkflowSpec("workflow:\n id: x\n bad: : :", "/x");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowSpecError);
|
||||
expect((err as WorkflowSpecError).code).toBe("spec_yaml_invalid");
|
||||
}
|
||||
});
|
||||
|
||||
it("parseWorkflowSpec throws spec_field_missing when workflow.id absent", () => {
|
||||
try {
|
||||
parseWorkflowSpec("workflow:\n version: 1\n steps:\n - id: a\n actor_role: r\n roles:\n r: {}\n", "/x");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowSpecError);
|
||||
expect((err as WorkflowSpecError).code).toBe("spec_field_missing");
|
||||
}
|
||||
});
|
||||
|
||||
it("parseWorkflowSpec throws spec_field_missing when steps[] empty", () => {
|
||||
try {
|
||||
parseWorkflowSpec("workflow:\n id: x\n version: 1\n steps: []\n roles:\n r: {}\n", "/x");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowSpecError);
|
||||
expect((err as WorkflowSpecError).code).toBe("spec_field_missing");
|
||||
}
|
||||
});
|
||||
|
||||
it("parseWorkflowSpec throws spec_field_missing when roles missing", () => {
|
||||
try {
|
||||
parseWorkflowSpec("workflow:\n id: x\n version: 1\n steps:\n - id: a\n actor_role: r\n", "/x");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(WorkflowSpecError);
|
||||
expect((err as WorkflowSpecError).code).toBe("spec_field_missing");
|
||||
}
|
||||
});
|
||||
|
||||
it("getByNameVersion returns null for unknown spec; returns row for cached spec", () => {
|
||||
expect(cache.getByNameVersion("none", "1")).toBeNull();
|
||||
const path = join(tmp, "spec.yaml");
|
||||
writeFileSync(path, SAMPLE_SPEC);
|
||||
const cached = cache.readThrough(path);
|
||||
const found = cache.getByNameVersion("test-three-step", "1");
|
||||
expect(found?.specId).toBe(cached.specId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "../src/db/migrations/035_workflow_step_trails.js";
|
||||
import { WorkflowInstanceStore } from "../src/domain/workflow-instance-store.js";
|
||||
import { WorkflowStepTrailLog } from "../src/domain/workflow-step-trail-log.js";
|
||||
|
||||
describe("WorkflowStepTrailLog (PL-004 Phase D; append-only)", () => {
|
||||
let db: Database.Database;
|
||||
let log: WorkflowStepTrailLog;
|
||||
let store: WorkflowInstanceStore;
|
||||
let instanceId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, queueItemsSchema, workflowInstancesSchema, workflowStepTrailsSchema]);
|
||||
log = new WorkflowStepTrailLog(db);
|
||||
store = new WorkflowInstanceStore(db);
|
||||
instanceId = store.create({
|
||||
workflowName: "t",
|
||||
workflowVersion: "1",
|
||||
createdBySession: "a@r",
|
||||
}).instanceId;
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
|
||||
VALUES ('q-prior', '2026-05-03T07:00:00Z', '2026-05-03T07:00:00Z', 'a@r', 'b@r', 'done', 'routine', 'x')`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
|
||||
VALUES ('q-next', '2026-05-03T07:01:00Z', '2026-05-03T07:01:00Z', 'a@r', 'c@r', 'pending', 'routine', 'x')`,
|
||||
).run();
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("record returns persisted entry with ULID id + correct fields", () => {
|
||||
const e = log.record({
|
||||
instanceId,
|
||||
stepId: "produce",
|
||||
stepRole: "producer",
|
||||
closedAt: "2026-05-03T08:00:00.000Z",
|
||||
closureReason: "handoff",
|
||||
actorSession: "a@r",
|
||||
priorQitemId: "q-prior",
|
||||
nextQitemId: "q-next",
|
||||
});
|
||||
expect(e.trailId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(e.closureReason).toBe("handoff");
|
||||
expect(e.priorQitemId).toBe("q-prior");
|
||||
expect(e.nextQitemId).toBe("q-next");
|
||||
});
|
||||
|
||||
it("record(closureEvidence) JSON-encodes and decodes on read", () => {
|
||||
log.record({
|
||||
instanceId,
|
||||
stepId: "x",
|
||||
stepRole: "r",
|
||||
closedAt: "2026-05-03T08:00:00.000Z",
|
||||
closureReason: "done",
|
||||
actorSession: "a@r",
|
||||
priorQitemId: "q-prior",
|
||||
closureEvidence: { result: "shipped", artifact: "/path/to/thing" },
|
||||
});
|
||||
const list = log.listForInstance(instanceId);
|
||||
expect(list[0]?.closureEvidence).toEqual({ result: "shipped", artifact: "/path/to/thing" });
|
||||
});
|
||||
|
||||
it("nextQitemId nullable for terminal closures (done/waiting/failed)", () => {
|
||||
log.record({
|
||||
instanceId,
|
||||
stepId: "x",
|
||||
stepRole: "r",
|
||||
closedAt: "2026-05-03T08:00:00.000Z",
|
||||
closureReason: "done",
|
||||
actorSession: "a@r",
|
||||
priorQitemId: "q-prior",
|
||||
nextQitemId: null,
|
||||
});
|
||||
expect(log.listForInstance(instanceId)[0]?.nextQitemId).toBeNull();
|
||||
});
|
||||
|
||||
it("listForInstance returns DESC by closed_at", () => {
|
||||
log.record({ instanceId, stepId: "a", stepRole: "r", closedAt: "2026-05-03T08:00:00.000Z", closureReason: "handoff", actorSession: "a@r", priorQitemId: "q-prior" });
|
||||
log.record({ instanceId, stepId: "b", stepRole: "r", closedAt: "2026-05-03T08:01:00.000Z", closureReason: "handoff", actorSession: "a@r", priorQitemId: "q-prior" });
|
||||
const list = log.listForInstance(instanceId);
|
||||
expect(list[0]?.stepId).toBe("b");
|
||||
expect(list[1]?.stepId).toBe("a");
|
||||
});
|
||||
|
||||
it("FK violation: record() against unknown instance_id throws SQLite FK error", () => {
|
||||
expect(() =>
|
||||
log.record({
|
||||
instanceId: "no-such-instance",
|
||||
stepId: "x",
|
||||
stepRole: "r",
|
||||
closedAt: "2026-05-03T08:00:00.000Z",
|
||||
closureReason: "done",
|
||||
actorSession: "a@r",
|
||||
priorQitemId: "q-prior",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("API surface has no update/delete (append-only contract)", () => {
|
||||
const proto = Object.getPrototypeOf(log) as Record<string, unknown>;
|
||||
const names = Object.getOwnPropertyNames(proto);
|
||||
expect(names).not.toContain("update");
|
||||
expect(names).not.toContain("delete");
|
||||
expect(names).not.toContain("remove");
|
||||
});
|
||||
|
||||
it("countForInstance returns row count for one instance", () => {
|
||||
expect(log.countForInstance(instanceId)).toBe(0);
|
||||
log.record({ instanceId, stepId: "x", stepRole: "r", closedAt: "2026-05-03T08:00:00.000Z", closureReason: "done", actorSession: "a@r", priorQitemId: "q-prior" });
|
||||
expect(log.countForInstance(instanceId)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { WorkflowValidator } from "../src/domain/workflow-validator.js";
|
||||
import type { WorkflowSpec } from "../src/domain/workflow-types.js";
|
||||
|
||||
function spec(overrides: Partial<WorkflowSpec> = {}): WorkflowSpec {
|
||||
return {
|
||||
id: "test",
|
||||
version: "1",
|
||||
objective: "Test workflow",
|
||||
target: { rig: "test-rig" },
|
||||
entry: { role: "producer" },
|
||||
roles: {
|
||||
producer: { preferred_targets: ["producer@rig"] },
|
||||
reviewer: { preferred_targets: ["reviewer@rig"] },
|
||||
},
|
||||
steps: [
|
||||
{ id: "produce", actor_role: "producer", allowed_exits: ["handoff"] },
|
||||
{ id: "review", actor_role: "reviewer", allowed_exits: ["done"] },
|
||||
],
|
||||
invariants: { allowed_exits: ["handoff", "waiting", "done"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowValidator (PL-004 Phase D)", () => {
|
||||
const validator = new WorkflowValidator();
|
||||
|
||||
it("ok=true on valid spec", () => {
|
||||
const result = validator.validate(spec());
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
expect(result.summary.workflowId).toBe("test");
|
||||
expect(result.summary.stepCount).toBe(2);
|
||||
expect(result.summary.entryRole).toBe("producer");
|
||||
});
|
||||
|
||||
it("entry_role_not_declared when entry.role not in roles", () => {
|
||||
const result = validator.validate(spec({ entry: { role: "ghost" } }));
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues.find((i) => i.code === "entry_role_not_declared")).toBeDefined();
|
||||
});
|
||||
|
||||
it("step_actor_role_not_declared when step references undeclared role", () => {
|
||||
const result = validator.validate(
|
||||
spec({
|
||||
steps: [{ id: "produce", actor_role: "ghost", allowed_exits: ["handoff"] }],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues.find((i) => i.code === "step_actor_role_not_declared")).toBeDefined();
|
||||
});
|
||||
|
||||
it("step_id_duplicate when two steps share an id", () => {
|
||||
const result = validator.validate(
|
||||
spec({
|
||||
steps: [
|
||||
{ id: "x", actor_role: "producer", allowed_exits: ["handoff"] },
|
||||
{ id: "x", actor_role: "reviewer", allowed_exits: ["done"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues.find((i) => i.code === "step_id_duplicate")).toBeDefined();
|
||||
});
|
||||
|
||||
it("step_exit_not_allowed when step exit outside invariants.allowed_exits", () => {
|
||||
const result = validator.validate(
|
||||
spec({
|
||||
invariants: { allowed_exits: ["done"] },
|
||||
steps: [{ id: "produce", actor_role: "producer", allowed_exits: ["handoff"] }],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues.find((i) => i.code === "step_exit_not_allowed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("step_id_missing when step has no id", () => {
|
||||
const result = validator.validate(
|
||||
spec({
|
||||
steps: [{ id: "", actor_role: "producer" }],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues.find((i) => i.code === "step_id_missing")).toBeDefined();
|
||||
});
|
||||
|
||||
it("step_actor_role_missing when step has no actor_role", () => {
|
||||
const result = validator.validate(
|
||||
spec({
|
||||
steps: [{ id: "x", actor_role: "" }],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.issues.find((i) => i.code === "step_actor_role_missing")).toBeDefined();
|
||||
});
|
||||
|
||||
it("seat liveness warning when role's preferred_targets are all dead", () => {
|
||||
const result = validator.validate(spec(), () => ({ alive: false, reason: "no session" }));
|
||||
expect(result.ok).toBe(true); // warnings don't fail
|
||||
const warnings = result.issues.filter((i) => i.severity === "warning");
|
||||
expect(warnings.find((w) => w.code === "role_no_live_preferred_target")).toBeDefined();
|
||||
});
|
||||
|
||||
it("seat liveness no warning when at least one preferred_target alive", () => {
|
||||
const result = validator.validate(spec(), () => ({ alive: true }));
|
||||
expect(result.issues.filter((i) => i.code === "role_no_live_preferred_target")).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user