feat(rsi-v2-starter): v0 — bundled workflow_spec + startup loader + specs CLI

RSI v2 starter v0 — the canonical Discovery → Product Lab → Delivery →
Lifecycle → QA hot-potato loop ships as a built-in workflow_spec
out-of-box. Three components, single coherent vertical. NO new
schema, NO migrations, NO new event types — composes PL-004 Phase D's
existing workflow runtime + cache + reconciliation contract.

Component 1 — canonical RSI v2 spec file:
- packages/daemon/src/builtins/workflow-specs/rsi-v2-hot-potato.yaml
- 5 roles (discovery-router, product-lab-planner, delivery-driver,
  lifecycle-router, qa-tester) with skill_refs + preferred_targets.
- 5 steps in loop order (discovery → product-lab → delivery →
  lifecycle → qa); routing hints via next_hop.suggested_roles. Only
  the qa step permits `done` as a terminal exit (no-follow-on
  acceptance) — non-qa steps must handoff/waiting/failed. qa loops
  back to discovery via next_hop on the followup-signal path.
- coordination_terminal_turn_rule: hot_potato (matches founder default).
- loop_guards.max_hops: 30 (conservative ceiling against routing-table
  churn; normal loops close in 5-10 hops).
- Schema follows packages/daemon/src/domain/workflow-types.ts strictly:
  top-level `workflow:` key, `id` (not `name`), `objective` (not
  `purpose`), `roles` mapping (not array), step `actor_role` (not
  `role`). PRD's draft content used field names that didn't match
  Phase D's parser — re-authored against the actual schema.

Component 2 — startup loader:
- packages/daemon/src/domain/workflow/starter-spec-loader.ts
- loadStarterWorkflowSpecs walks builtinDir, parses each .yaml/.yml
  via WorkflowSpecCache.parseWorkflowSpec, and seeds via
  cache.readThrough(absPath) — but ONLY when no row exists for the
  same (name, version). Idempotent on repeated startup.
- Workspace-surface reconciliation preserved: when an operator has
  authored a competing spec at a workspace path with the same
  (name, version), the loader SKIPS rather than clobbering. Test
  pins this behavior end-to-end.
- defaultBuiltinSpecsDir() resolves from the loader file's own
  import.meta.url (../../builtins/workflow-specs from src/dist root)
  so it works in both dev (running from src/) and prod (running
  from dist/). Daemon package.json build script copies the .yaml
  file from src/builtins/workflow-specs/ to dist/builtins/workflow-specs/.
- Wired into startup.ts after WorkflowRuntime construction. Errors
  are collected into the result for diagnostic logging (console.warn);
  startup is NOT blocked by a malformed bundled spec.
- Surfaces the resolved builtinDir path on AppDeps.workflowBuiltinSpecsDir
  so the routes layer can compute per-row isBuiltIn flags.

Component 3 — `rig workflow specs` subcommand + indicator:
- New WorkflowSpecCache.listAll() ordered by (name, version).
- New GET /api/workflow/specs route returning { specs: [...] } with
  per-row isBuiltIn computed from sourcePath being strictly under
  workflowBuiltinSpecsDir (path.sep boundary check rules out the
  `/foo/bar-other` matches `/foo/bar` false-positive). Mounted
  BEFORE /:instance_id catchall per Phase A R1 SSE route-order
  lesson — pinned by a dedicated test.
- New `rig workflow specs [--json]` CLI subcommand. Sibling of the
  existing `rig workflow validate / list / instantiate` — NOT a new
  top-level command (PRD bounce condition was specifically against
  `rig workflow starter-list`-style additions; `rig workflow specs`
  is the natural complement to the existing instance-listing
  `rig workflow list`).
- Human output: `<name> v<version>` with optional `(built-in)` suffix
  + indented purpose excerpt (120 chars) + source path.
- JSON output: full row with isBuiltIn boolean for agents.

PRD audit-row reinterpretations recorded in commit + handoff:
- Row 6: `rig workflow list` lists INSTANCES, not specs. Adding
  `rig workflow specs` (sibling subcommand, not top-level) respects
  the bounce condition while extending the workflow surface naturally.
- Row 2: PRD draft spec content used incompatible field names; the
  shipped spec follows Phase D's actual parser shape strictly.

Verification:
- daemon: 2750/2750 (was 2723 baseline at 66796c2 + 27 new RSI-v2
  cases across 3 new test files: 13 spec-content + 7 starter-loader +
  7 specs-route).
- cli: 797/800 — same 3 pre-existing env-probe failures (preflight
  +config) reproduce on baseline; no regression. Driver did not
  modify those code paths.
- tsc clean for daemon + cli + ui.
- npm run build copies the YAML to dist/builtins/workflow-specs/
  (verified post-build: rsi-v2-hot-potato.yaml present in dist/).

Boundaries preserved:
- Worktree-only on rsi-v2-starter-workflow-spec-v0; main untouched at 66796c2.
- NO new migrations, NO new schema, NO new event types.
- PL-004 Phase D's workflow_specs table + workflow-spec-cache.ts +
  workflow-runtime.ts contracts are the destination — extended via
  `WorkflowSpecCache.listAll()` (additive) and one new GET route
  (additive); no existing surface modified.
- Workspace-surface reconciliation contract honored end-to-end:
  loader skips already-cached (name, version) so operator overrides
  are preserved.
- NO auto-instantiation on rig creation (PRD bounce); operator
  instantiates explicitly via existing Phase D verb.
- NO multi-spec starter library; v0 ships ONE starter
  (rsi-v2-hot-potato). Future starters are separate slices.
- NO production-shop language. NO new top-level CLI command.
- Slice Story View v0, PL-005, PL-019, PL-004 Phase A/B/C surfaces
  all read-only.

Operator setup hint for QA dogfood:
- After `pnpm install` + `npm --workspaces run build`, start the daemon
  and run:
    rig workflow specs --json | jq '.specs[] | {name, version, isBuiltIn}'
  Expect: rsi-v2-hot-potato v1 with isBuiltIn=true.
- Validate the spec:
    rig workflow validate dist/builtins/workflow-specs/rsi-v2-hot-potato.yaml
  Expect: passes Phase D's validator (no errors).
- Instantiate it:
    rig workflow instantiate \\
      --spec-path dist/builtins/workflow-specs/rsi-v2-hot-potato.yaml \\
      --root-objective "smoke test" \\
      --created-by-session <my-session> \\
      --entry-owner-session <intake-router@my-rig>
  Expect: workflow_instance created; current_step_id="discovery".
  (Note: Phase D's instantiate verb takes specPath, not name+version
  per the route signature — operator points it at the bundled spec
  file directly.)

Hard-gate (operator dogfood per PRD audit row 7) is QA-owned per
feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for guard
inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 02:53:46 -07:00
1 parent 66796c21dd
commit 5bdda4f8be
11 files changed
+898 -1

No files matched your search

+41
View File
@@ -140,6 +140,47 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
});
});
// RSI v2 starter v0: list cached workflow_specs (NOT instances). The
// existing `rig workflow list` lists instances; this is the
// complementary surface for inspecting which specs are registered,
// including the new built-in starter(s) shipped at daemon startup.
// Built-in rows display a `(built-in)` indicator in human output and
// an `isBuiltIn: true` field in JSON output.
cmd
.command("specs")
.description("List registered workflow specs; built-in starters tagged with (built-in)")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<{ specs: Array<{
name: string; version: string; purpose: string | null;
targetRig: string | null; coordinationTerminalTurnRule: string;
sourcePath: string; cachedAt: string; isBuiltIn: boolean;
}> }>("/api/workflow/specs");
if (opts.json) {
printResult(true, res.data, res.status);
return;
}
if (res.status >= 400) {
printResult(false, res.data, res.status);
return;
}
const rows = res.data.specs ?? [];
if (rows.length === 0) {
console.log("No workflow specs registered.");
return;
}
// Compact human table — name, version, source, indicator.
for (const row of rows) {
const indicator = row.isBuiltIn ? " (built-in)" : "";
console.log(`${row.name} v${row.version}${indicator}`);
if (row.purpose) console.log(` purpose: ${row.purpose.replace(/\n/g, " ").slice(0, 120)}`);
console.log(` source: ${row.sourcePath}`);
}
});
});
cmd
.command("show <instanceId>")
.description("Show one workflow instance")
+1 -1
View File
@@ -5,7 +5,7 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "tsc",
"build": "tsc && mkdir -p dist/builtins/workflow-specs && cp src/builtins/workflow-specs/*.yaml dist/builtins/workflow-specs/",
"test": "vitest run",
"test:watch": "vitest",
"dev": "tsx watch src/index.ts"
@@ -0,0 +1,164 @@
# RSI v2 starter workflow_spec — Discovery → Product Lab → Delivery →
# Lifecycle → QA hot-potato loop.
#
# This is the canonical first workflow_spec OpenRig ships out-of-box. New
# rigs see it under `rig workflow specs`; operators instantiate it
# explicitly with `rig workflow instantiate <path-to-this-file>
# --root-objective "..."` (the existing PL-004 Phase D verb).
#
# Workspace-surface reconciliation: an operator who wants a custom flow
# can copy this file to a workspace path, edit it, and have the
# operator-authored version win at next read (Phase D's
# WorkflowSpecCache contract). The starter loader at startup will skip
# upserting when an entry for (name=rsi-v2-hot-potato, version=1) is
# already cached — operator edits are not clobbered.
#
# Schema follows packages/daemon/src/domain/workflow-types.ts. Notable:
# - top-level `workflow:` key (parser requirement)
# - `id` is the spec name; `version` is a string
# - `objective` is the spec-level purpose (stored as workflow_specs.purpose)
# - `roles` is a MAPPING (role-id → { skill_refs, preferred_targets })
# - each step has `actor_role` (resolved against roles); routing hints
# live in `next_hop.suggested_roles` (NOT in success/failure branch
# fields — Phase D's runtime resolves the next owner via next_hop +
# operator/explicit nextOwnerSession at project-time)
# - `coordination_terminal_turn_rule: hot_potato` matches the team's
# default operating model
workflow:
id: rsi-v2-hot-potato
version: "1"
objective: |
Canonical RSI v2 hot-potato loop — Discovery → Product Lab → Delivery →
Lifecycle → QA. Each step's owner closes with a hot-potato terminal-turn
reason (handoff to next-step owner, or done/waiting/failed); the workflow
runtime atomically projects the next-step packet in the same daemon
transaction (transactional-scribe). QA loops back to Discovery for
follow-up signal, with `done` as the acceptable terminal when no
follow-up surfaces.
target:
rig: openrig-velocity
entry:
role: discovery-router
invariants:
continuation_required: true
allowed_exits: [handoff, waiting, done, failed]
preserve_lineage: true
closure_required: true
loop_guards:
# Conservative ceiling for a single trip around the loop. The team's
# actual loops typically close in 5-10 hops; max_hops here protects
# against runaway routing-table churn rather than constraining normal
# operation.
max_hops: 30
coordination_terminal_turn_rule: hot_potato
closure:
success: |
Slice shipped through the full loop: Discovery surfaced the candidate;
Product Lab ratified; Delivery implemented and passed guard + QA;
Lifecycle promoted to main with caveats preserved; QA confirmed
runtime ship-readiness.
degraded: |
One or more steps closed with `waiting` (blocked on external input).
The instance is recoverable — resume by closing the blocked step
with the relevant evidence, or re-instantiate from the failed step.
failed: |
A step closed with `failed`. Inspect the trail; the failure reason
is in the trail entry's closure_evidence. Operator owns next steps
(re-route, re-instantiate, or close as no-follow-on).
roles:
discovery-router:
skill_refs: [discovery, intake, candidate-routing]
preferred_targets:
- intake-router@openrig-velocity
- discovery-router@openrig-velocity
product-lab-planner:
skill_refs: [planning, slice-authoring, prd-shaping]
preferred_targets:
- velocity-planner@openrig-velocity
- planning-slice-writer@openrig-velocity
delivery-driver:
skill_refs: [implementation, driver, guard-review, qa-handoff]
preferred_targets:
- velocity-driver@openrig-velocity
lifecycle-router:
skill_refs: [source-promotion, caveat-preservation, bundling]
preferred_targets:
- lifecycle-router@openrig-velocity
- orch-lead@openrig-velocity
qa-tester:
skill_refs: [dogfood, headed-browser, vm-verification, fix-and-retest]
preferred_targets:
- velocity-qa@openrig-velocity
- velocity-tester@openrig-velocity
steps:
- id: discovery
actor_role: discovery-router
objective: |
Surface and classify a candidate from raw signal; route the shaped
candidate forward to Product Lab, OR close as no-op with rationale
recorded in the closure_evidence.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: prefer
suggested_roles: [product-lab-planner]
- id: product-lab
actor_role: product-lab-planner
objective: |
Shape the candidate into a ratified slice packet (Implementation-PRD,
or 3-file split per the founder's per-slice format directive). Orch
ratifies via `rigx queue handoff`; operator holds open questions
through this step rather than letting them block delivery.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: prefer
suggested_roles: [delivery-driver]
- id: delivery
actor_role: delivery-driver
objective: |
Implement the slice per packet on a clean worktree branch; close
with single coherent commit; pass the guard checkpoint and the QA
dogfood gate. Hand back to orch-lead with a comprehensive packet.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: prefer
suggested_roles: [lifecycle-router]
- id: lifecycle
actor_role: lifecycle-router
objective: |
Source-promote the shipped slice to main; preserve dogfood caveats
verbatim; bundle for future review. Lifecycle is NOT a re-test
gate — caveats from QA dogfood travel with the promotion.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: prefer
suggested_roles: [qa-tester]
- id: qa
actor_role: qa-tester
objective: |
Confirm runtime ship-readiness via dogfood (headed-browser for UI
slices, VM verification for source-touching slices). QA owns the
fix-and-retest cycle end-to-end; only escalate large/architectural
blockers back to delivery. Close with `handoff` to discovery when
follow-up signal exists; close with `done` when none does.
allowed_exits: [handoff, done, waiting, failed]
next_hop:
# `prefer` (not `require`) — many slices close with `done` (no
# follow-up signal) which Phase D treats as a terminal exit and
# ignores the next_hop hint. The hint exists for the looping
# case so projection routes correctly when QA hands back to
# Discovery.
mode: prefer
suggested_roles: [discovery-router]
@@ -214,6 +214,19 @@ export class WorkflowSpecCache {
return row ? rowToWorkflowSpec(row) : null;
}
/**
* Lists every cached spec, ordered by name then version. Used by the
* `GET /api/workflow/specs` endpoint (RSI v2 starter v0). Cheap —
* the workflow_specs table is bounded by the number of operator-
* authored + built-in starter specs (single-host MVP).
*/
listAll(): WorkflowSpecRow[] {
const rows = this.db
.prepare(`SELECT * FROM workflow_specs ORDER BY name, version`)
.all() as SpecRow[];
return rows.map(rowToWorkflowSpec);
}
getByIdOrThrow(specId: string): WorkflowSpecRow {
const row = this.db
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
@@ -0,0 +1,161 @@
// RSI v2 starter v0 — built-in workflow-spec loader.
//
// Walks a built-in starter directory at daemon startup and seeds each
// spec file into PL-004 Phase D's WorkflowSpecCache. Idempotent: on
// repeated startup (or when an operator has authored a competing spec
// at a workspace path), the loader SKIPS specs that are already cached
// for the same (name, version) — the workspace-surface reconciliation
// contract from Phase D requires that operator edits win at next read,
// so the loader must not clobber them.
//
// Why skip-if-present rather than always-readThrough:
// WorkflowSpecCache.readThrough(absPath) UPDATES the cached row's
// source_path to whatever the caller passes. If we called readThrough
// on the built-in path every startup, an operator-authored row at a
// workspace path would have its source_path overwritten back to the
// built-in path — silently undoing the operator's override. The
// skip-if-present check preserves the override.
//
// Resolution: operators who want to refresh the built-in row from the
// shipped file (e.g., after deleting a workspace override) can call
// `cache.readThrough(builtinAbsPath)` directly via a future explicit
// refresh path; v0 does not surface that path, since the typical case
// (cold daemon, no override) is handled by the loader on first start.
import * as fs from "node:fs";
import * as path from "node:path";
import {
WorkflowSpecError,
parseWorkflowSpec,
type WorkflowSpecCache,
} from "../workflow-spec-cache.js";
export interface StarterSpecLoadResult {
/** Specs newly seeded into the cache during this call. */
loaded: Array<{ name: string; version: string; sourcePath: string }>;
/** Specs that were already cached (operator override or prior startup). */
skipped: Array<{ name: string; version: string; sourcePathInCache: string }>;
/** Specs that failed to parse / load — surfaced for diagnostic logging. */
errors: Array<{ sourcePath: string; code: string; message: string }>;
}
export interface StarterSpecLoaderOpts {
/** Phase D's workflow-spec-cache (already constructed in startup.ts). */
cache: WorkflowSpecCache;
/** Absolute directory containing built-in starter spec files (.yaml only at v0). */
builtinDir: string;
}
const SPEC_FILE_EXTENSIONS = new Set([".yaml", ".yml"]);
/**
* Walks the builtinDir and seeds each spec file into the cache, skipping
* those that are already cached for the same (name, version). Idempotent
* on repeated calls. Returns a structured result for diagnostic logging
* (which the daemon can log at INFO; tests assert on the shape).
*
* If the builtinDir doesn't exist, returns an empty result (no error) —
* a daemon shipped without bundled starter specs is a valid configuration.
*/
export function loadStarterWorkflowSpecs(opts: StarterSpecLoaderOpts): StarterSpecLoadResult {
const result: StarterSpecLoadResult = { loaded: [], skipped: [], errors: [] };
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(opts.builtinDir, { withFileTypes: true });
} catch {
// Directory absent — no starter specs bundled. This is a valid
// configuration; not an error.
return result;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!SPEC_FILE_EXTENSIONS.has(ext)) continue;
const absPath = path.join(opts.builtinDir, entry.name);
let raw: string;
try {
raw = fs.readFileSync(absPath, "utf-8");
} catch (err) {
result.errors.push({
sourcePath: absPath,
code: "spec_read_failed",
message: err instanceof Error ? err.message : String(err),
});
continue;
}
let parsedName: string;
let parsedVersion: string;
try {
const parsed = parseWorkflowSpec(raw, absPath);
parsedName = parsed.id;
parsedVersion = parsed.version;
} catch (err) {
if (err instanceof WorkflowSpecError) {
result.errors.push({ sourcePath: absPath, code: err.code, message: err.message });
} else {
result.errors.push({
sourcePath: absPath,
code: "spec_parse_failed",
message: err instanceof Error ? err.message : String(err),
});
}
continue;
}
// Workspace-surface reconciliation: if an entry already exists for
// this (name, version), DO NOT seed — operator override (or a prior
// startup's seed) wins.
const existing = opts.cache.getByNameVersion(parsedName, parsedVersion);
if (existing) {
result.skipped.push({
name: parsedName,
version: parsedVersion,
sourcePathInCache: existing.sourcePath,
});
continue;
}
// Seed via readThrough so the cache's normal insert path runs (hash
// computation, cached_at timestamp, JSON serialization of roles +
// steps). readThrough re-reads + re-parses the file; that's a small
// duplicate-work cost per spec at startup, acceptable for v0.
try {
const row = opts.cache.readThrough(absPath);
result.loaded.push({ name: row.name, version: row.version, sourcePath: row.sourcePath });
} catch (err) {
if (err instanceof WorkflowSpecError) {
result.errors.push({ sourcePath: absPath, code: err.code, message: err.message });
} else {
result.errors.push({
sourcePath: absPath,
code: "spec_seed_failed",
message: err instanceof Error ? err.message : String(err),
});
}
}
}
return result;
}
/**
* Resolves the default built-in starter directory relative to this
* loader file's location on disk. Works in both dev (running from
* `src/`) and prod (running from `dist/`) — the build step copies the
* .yaml files to `dist/builtins/workflow-specs/` so the resolved path
* exists in both layouts.
*
* Layout: this file is at `<pkg>/{src|dist}/domain/workflow/`. The
* built-in dir is at `<pkg>/{src|dist}/builtins/workflow-specs/`. Two
* levels up from this file's dirname is the package's src/ or dist/
* root.
*/
export function defaultBuiltinSpecsDir(): string {
const here = path.dirname(new URL(import.meta.url).pathname);
// here = .../{src|dist}/domain/workflow
// package src/dist root = .../{src|dist}
return path.resolve(here, "..", "..", "builtins", "workflow-specs");
}
+37
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import * as path from "node:path";
import type { EventBus } from "../domain/event-bus.js";
import {
WorkflowInstanceError,
@@ -148,6 +149,28 @@ export function workflowRoutes(): Hono {
return c.json(getRuntime(c).instanceStore.listAll());
});
// RSI v2 starter v0: lists every cached workflow_spec with an
// `isBuiltIn` flag computed from whether the spec's source_path is
// under the daemon's built-in starter directory. Mounted BEFORE
// /:instance_id (Phase A R1 SSE route-order lesson) so the literal
// `/specs` path isn't shadowed by the bare-param catchall.
app.get("/specs", (c) => {
const runtime = getRuntime(c);
const builtinDirAbs = c.get("workflowBuiltinSpecsDir" as never) as string | undefined;
const rows = runtime.specCache.listAll();
const payload = rows.map((row) => ({
name: row.name,
version: row.version,
purpose: row.purpose,
targetRig: row.targetRig,
coordinationTerminalTurnRule: row.coordinationTerminalTurnRule,
sourcePath: row.sourcePath,
cachedAt: row.cachedAt,
isBuiltIn: builtinDirAbs ? isUnderDir(row.sourcePath, builtinDirAbs) : false,
}));
return c.json({ specs: payload });
});
// SSE for workflow.* events. MUST precede /:instance_id (Phase A R1 lesson).
const sseHandler = (c: Parameters<typeof streamSSE>[0]) => {
const eventBus = getEventBus(c);
@@ -203,3 +226,17 @@ export function workflowRoutes(): Hono {
return app;
}
/**
* Returns true when childPath resolves to a location strictly underneath
* parentDir on disk. Both inputs are resolved to absolute paths first;
* the parent comparison appends a trailing path.sep to avoid the
* `/foo/bar-other` matching `/foo/bar` false-positive case.
*/
function isUnderDir(childPath: string, parentDir: string): boolean {
const child = path.resolve(childPath);
const parent = path.resolve(parentDir);
if (child === parent) return false;
const parentWithSep = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
return child.startsWith(parentWithSep);
}
+10
View File
@@ -130,6 +130,15 @@ export interface AppDeps {
watchdogPolicyEngine?: WatchdogPolicyEngine;
watchdogScheduler?: WatchdogScheduler;
workflowRuntime?: WorkflowRuntime;
/**
* RSI v2 starter v0: absolute path to the daemon's bundled built-in
* workflow-specs directory. Used by `GET /api/workflow/specs` to
* compute the per-row `isBuiltIn` flag (source_path under this dir
* → built-in; otherwise → operator-authored). Optional: when unset,
* the route returns isBuiltIn=false for every spec (graceful — the
* surface still works, just without the indicator).
*/
workflowBuiltinSpecsDir?: string;
missionControlReadLayer?: import("./domain/mission-control/mission-control-read-layer.js").MissionControlReadLayer;
missionControlWriteContract?: import("./domain/mission-control/mission-control-write-contract.js").MissionControlWriteContract;
missionControlActionLog?: import("./domain/mission-control/mission-control-action-log.js").MissionControlActionLog;
@@ -308,6 +317,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("watchdogPolicyEngine" as never, deps.watchdogPolicyEngine);
c.set("watchdogScheduler" as never, deps.watchdogScheduler);
c.set("workflowRuntime" as never, deps.workflowRuntime);
c.set("workflowBuiltinSpecsDir" as never, deps.workflowBuiltinSpecsDir);
c.set("missionControlReadLayer" as never, deps.missionControlReadLayer);
c.set("missionControlWriteContract" as never, deps.missionControlWriteContract);
c.set("missionControlActionLog" as never, deps.missionControlActionLog);
+24
View File
@@ -580,6 +580,30 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
queueRepo: queueRepoForWorkflow,
});
deps.workflowRuntime = workflowRuntime;
// RSI v2 starter v0: seed built-in starter workflow_specs into the
// cache. Idempotent + workspace-surface-respecting — operator
// overrides at workspace paths are preserved (skip-if-cached).
// Errors are collected into the result for diagnostic logging but
// do NOT block startup; a malformed bundled spec should not bring
// the daemon down.
const { loadStarterWorkflowSpecs, defaultBuiltinSpecsDir } = await import(
"./domain/workflow/starter-spec-loader.js"
);
const builtinSpecsDir = defaultBuiltinSpecsDir();
const starterResult = loadStarterWorkflowSpecs({
cache: workflowRuntime.specCache,
builtinDir: builtinSpecsDir,
});
// Surface the resolved path to the routes layer so
// GET /api/workflow/specs can compute the per-row isBuiltIn flag.
deps.workflowBuiltinSpecsDir = builtinSpecsDir;
if (starterResult.errors.length > 0) {
console.warn(
`[starter-spec-loader] ${starterResult.errors.length} spec(s) failed to load:`,
starterResult.errors,
);
}
}
// PL-005 Phase A: Mission Control / Queue Observability services.
@@ -0,0 +1,111 @@
// RSI v2 starter v0 — pin the canonical bundled spec file's shape.
//
// This test reads the actual builtins/workflow-specs/rsi-v2-hot-potato.yaml
// shipped with the daemon and validates it against PL-004 Phase D's
// parser + validator. If the spec drifts (someone edits the YAML in a
// way that breaks Phase D's contract), this test fails loudly. Pin both
// the parse path AND the structural shape (5 roles, 5 steps, hot-potato
// rule, expected role+step ids) so the canonical RSI v2 loop content
// stays canonical.
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseWorkflowSpec } from "../src/domain/workflow-spec-cache.js";
import { WorkflowValidator } from "../src/domain/workflow-validator.js";
const SPEC_PATH = resolve(
fileURLToPath(import.meta.url),
"..",
"..",
"src",
"builtins",
"workflow-specs",
"rsi-v2-hot-potato.yaml",
);
const EXPECTED_ROLE_IDS = [
"discovery-router",
"product-lab-planner",
"delivery-driver",
"lifecycle-router",
"qa-tester",
];
const EXPECTED_STEP_IDS = ["discovery", "product-lab", "delivery", "lifecycle", "qa"];
describe("RSI v2 starter spec content", () => {
const raw = readFileSync(SPEC_PATH, "utf-8");
const spec = parseWorkflowSpec(raw, SPEC_PATH);
it("parses cleanly via Phase D's parseWorkflowSpec without throwing", () => {
// The parse already happened above; if it threw, the test setup
// wouldn't even reach here.
expect(spec).toBeDefined();
});
it("declares the canonical name + version", () => {
expect(spec.id).toBe("rsi-v2-hot-potato");
expect(spec.version).toBe("1");
});
it("uses hot_potato as the coordination_terminal_turn_rule (per founder default)", () => {
expect(spec.coordination_terminal_turn_rule).toBe("hot_potato");
});
it("declares exactly the five canonical RSI v2 roles", () => {
const roleIds = Object.keys(spec.roles ?? {}).sort();
expect(roleIds).toEqual([...EXPECTED_ROLE_IDS].sort());
});
it("declares exactly the five canonical RSI v2 steps in loop order", () => {
const stepIds = spec.steps.map((s) => s.id);
expect(stepIds).toEqual(EXPECTED_STEP_IDS);
});
it("each step's actor_role resolves against a declared role", () => {
const declared = new Set(Object.keys(spec.roles ?? {}));
for (const step of spec.steps) {
expect(declared.has(step.actor_role)).toBe(true);
}
});
it("entry role is discovery-router (matches the founder loop start)", () => {
expect(spec.entry?.role).toBe("discovery-router");
});
it("loop edge: qa step's next_hop suggests routing back to discovery-router", () => {
const qa = spec.steps.find((s) => s.id === "qa");
expect(qa?.next_hop?.suggested_roles).toContain("discovery-router");
});
it("qa step allows `done` as a terminal exit (no-follow-on case)", () => {
const qa = spec.steps.find((s) => s.id === "qa");
expect(qa?.allowed_exits).toContain("done");
});
it("non-qa steps do NOT permit `done` (only qa is a valid terminal)", () => {
for (const step of spec.steps) {
if (step.id === "qa") continue;
expect(step.allowed_exits ?? []).not.toContain("done");
}
});
it("invariants permit all four exit kinds (steps subset)", () => {
expect(spec.invariants?.allowed_exits).toEqual(
expect.arrayContaining(["handoff", "waiting", "done", "failed"]),
);
});
it("loop_guards.max_hops is set to a finite ceiling", () => {
expect(spec.loop_guards?.max_hops).toBe(30);
});
it("passes Phase D's WorkflowValidator with no errors", () => {
const validator = new WorkflowValidator();
const result = validator.validate(spec);
expect(result.ok).toBe(true);
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
});
});
@@ -0,0 +1,157 @@
// RSI v2 starter v0 — built-in starter loader tests.
//
// Drives the loader against a temp builtin directory + an in-memory
// workflow_specs cache so the test stays deterministic and parallel-safe.
// Pins the load-bearing behaviors:
//
// - cold start with N specs: all N seeded
// - re-run on already-cached spec: SKIPPED (no clobber of operator
// overrides under workspace-surface reconciliation)
// - operator override at workspace path: SKIPPED + source_path in
// cache stays the operator's path
// - malformed spec file: error collected, not thrown; other specs
// in the same dir still load
// - missing builtin dir: empty result, no throw (graceful)
// - non-yaml files in dir: silently ignored
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 } from "../src/domain/workflow-spec-cache.js";
import { loadStarterWorkflowSpecs } from "../src/domain/workflow/starter-spec-loader.js";
const ALPHA_SPEC = `workflow:
id: alpha-spec
version: 1
objective: alpha test
roles:
a:
preferred_targets: [a@r]
steps:
- id: only
actor_role: a
allowed_exits: [handoff]
`;
const BETA_SPEC = `workflow:
id: beta-spec
version: 1
objective: beta test
roles:
b:
preferred_targets: [b@r]
steps:
- id: only
actor_role: b
allowed_exits: [handoff]
`;
describe("RSI v2 starter spec loader", () => {
let db: Database.Database;
let cache: WorkflowSpecCache;
let builtinDir: string;
let cleanupRoot: string;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, workflowSpecsSchema]);
cache = new WorkflowSpecCache(db);
cleanupRoot = mkdtempSync(join(tmpdir(), "starter-loader-"));
builtinDir = join(cleanupRoot, "workflow-specs");
require("node:fs").mkdirSync(builtinDir, { recursive: true });
});
afterEach(() => {
db.close();
rmSync(cleanupRoot, { recursive: true, force: true });
});
it("returns empty result with no throw when builtinDir doesn't exist", () => {
const missing = join(cleanupRoot, "definitely-missing");
const result = loadStarterWorkflowSpecs({ cache, builtinDir: missing });
expect(result).toEqual({ loaded: [], skipped: [], errors: [] });
});
it("cold start: seeds every .yaml spec in the directory", () => {
writeFileSync(join(builtinDir, "alpha.yaml"), ALPHA_SPEC);
writeFileSync(join(builtinDir, "beta.yaml"), BETA_SPEC);
const result = loadStarterWorkflowSpecs({ cache, builtinDir });
expect(result.loaded).toHaveLength(2);
expect(result.loaded.map((r) => r.name).sort()).toEqual(["alpha-spec", "beta-spec"]);
expect(result.skipped).toEqual([]);
expect(result.errors).toEqual([]);
// Confirm the cache actually has them.
expect(cache.getByNameVersion("alpha-spec", "1")).not.toBeNull();
expect(cache.getByNameVersion("beta-spec", "1")).not.toBeNull();
});
it("idempotent: second call skips already-cached specs (no clobber)", () => {
writeFileSync(join(builtinDir, "alpha.yaml"), ALPHA_SPEC);
const first = loadStarterWorkflowSpecs({ cache, builtinDir });
expect(first.loaded).toHaveLength(1);
const second = loadStarterWorkflowSpecs({ cache, builtinDir });
expect(second.loaded).toEqual([]);
expect(second.skipped).toHaveLength(1);
expect(second.skipped[0]?.name).toBe("alpha-spec");
});
it("operator override (workspace-surface reconciliation): existing cache row at non-builtin source_path wins; loader does NOT overwrite", () => {
// Operator authors a spec at a "workspace" path with same (name, version)
// and reads it through the cache first (simulates operator workflow).
const operatorPath = join(cleanupRoot, "operator-override-alpha.yaml");
writeFileSync(operatorPath, ALPHA_SPEC);
cache.readThrough(operatorPath);
const beforeRow = cache.getByNameVersion("alpha-spec", "1");
expect(beforeRow?.sourcePath).toBe(operatorPath);
// Now the daemon starts and runs the starter loader on the bundled
// builtin dir, which contains the same (name, version).
writeFileSync(join(builtinDir, "alpha.yaml"), ALPHA_SPEC);
const result = loadStarterWorkflowSpecs({ cache, builtinDir });
// Loader skipped (operator wins).
expect(result.loaded).toEqual([]);
expect(result.skipped).toHaveLength(1);
expect(result.skipped[0]?.sourcePathInCache).toBe(operatorPath);
// Cache row's source_path is STILL the operator's path — not the
// bundled built-in path (workspace-surface reconciliation preserved).
const afterRow = cache.getByNameVersion("alpha-spec", "1");
expect(afterRow?.sourcePath).toBe(operatorPath);
});
it("malformed spec file: error collected, other specs in same dir still load", () => {
writeFileSync(join(builtinDir, "alpha.yaml"), ALPHA_SPEC);
writeFileSync(join(builtinDir, "broken.yaml"), "this is: not [a valid spec");
const result = loadStarterWorkflowSpecs({ cache, builtinDir });
expect(result.loaded.map((r) => r.name)).toEqual(["alpha-spec"]);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]?.sourcePath).toContain("broken.yaml");
// Cache has alpha but NOT broken (which has no name to begin with).
expect(cache.getByNameVersion("alpha-spec", "1")).not.toBeNull();
});
it("non-yaml files in dir are silently ignored (.md, .txt, .json)", () => {
writeFileSync(join(builtinDir, "alpha.yaml"), ALPHA_SPEC);
writeFileSync(join(builtinDir, "README.md"), "# notes about the bundled specs");
writeFileSync(join(builtinDir, "scratch.txt"), "ignore me");
writeFileSync(join(builtinDir, "metadata.json"), `{"hint":"ignored"}`);
const result = loadStarterWorkflowSpecs({ cache, builtinDir });
expect(result.loaded).toHaveLength(1);
expect(result.loaded[0]?.name).toBe("alpha-spec");
expect(result.errors).toEqual([]);
});
it("supports both .yaml and .yml extensions", () => {
writeFileSync(join(builtinDir, "alpha.yaml"), ALPHA_SPEC);
writeFileSync(join(builtinDir, "beta.yml"), BETA_SPEC);
const result = loadStarterWorkflowSpecs({ cache, builtinDir });
expect(result.loaded.map((r) => r.name).sort()).toEqual(["alpha-spec", "beta-spec"]);
});
});
@@ -0,0 +1,179 @@
// RSI v2 starter v0 — GET /api/workflow/specs route tests.
//
// Drives the new specs route against a hand-mounted Hono app with a
// bare WorkflowRuntime + the new isBuiltIn computation. Pins the
// load-bearing behaviors:
//
// - empty cache: { specs: [] }
// - mixed (built-in + operator) rows: isBuiltIn flag set per row
// - workflowBuiltinSpecsDir context unset: isBuiltIn=false for all
// (graceful fallback — surface still works, no indicator)
// - route-order: literal /specs not shadowed by /:instance_id catchall
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } 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 { streamItemsSchema } from "../src/db/migrations/023_stream_items.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 ALPHA_SPEC = `workflow:
id: alpha-spec
version: 1
objective: alpha test
roles:
a:
preferred_targets: [a@r]
steps:
- id: only
actor_role: a
allowed_exits: [handoff]
`;
function buildApp(opts: { runtime: WorkflowRuntime; eventBus: EventBus; builtinDir?: string }): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("workflowRuntime" as never, opts.runtime);
c.set("eventBus" as never, opts.eventBus);
c.set("workflowBuiltinSpecsDir" as never, opts.builtinDir);
await next();
});
app.route("/api/workflow", workflowRoutes());
return app;
}
describe("RSI v2 starter v0 — GET /api/workflow/specs", () => {
let db: Database.Database;
let runtime: WorkflowRuntime;
let eventBus: EventBus;
let cleanupRoot: string;
let builtinDir: string;
let operatorDir: string;
beforeEach(() => {
db = createDb();
migrate(db, [
coreSchema, eventsSchema, streamItemsSchema,
queueItemsSchema, queueTransitionsSchema,
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
]);
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
eventBus = new EventBus(db);
const queueRepo = new QueueRepository(db, eventBus, { validateRig: () => true });
runtime = new WorkflowRuntime({ db, eventBus, queueRepo });
cleanupRoot = mkdtempSync(join(tmpdir(), "specs-route-"));
builtinDir = join(cleanupRoot, "builtin", "workflow-specs");
operatorDir = join(cleanupRoot, "operator");
mkdirSync(builtinDir, { recursive: true });
mkdirSync(operatorDir, { recursive: true });
});
afterEach(() => {
db.close();
rmSync(cleanupRoot, { recursive: true, force: true });
});
it("returns { specs: [] } when no specs are cached", async () => {
const app = buildApp({ runtime, eventBus, builtinDir });
const res = await app.request("/api/workflow/specs");
expect(res.status).toBe(200);
const body = (await res.json()) as { specs: unknown[] };
expect(body.specs).toEqual([]);
});
it("returns each cached spec with the canonical fields", async () => {
const operatorSpec = join(operatorDir, "alpha.yaml");
writeFileSync(operatorSpec, ALPHA_SPEC);
runtime.specCache.readThrough(operatorSpec);
const app = buildApp({ runtime, eventBus, builtinDir });
const res = await app.request("/api/workflow/specs");
expect(res.status).toBe(200);
const body = (await res.json()) as { specs: Array<Record<string, unknown>> };
expect(body.specs).toHaveLength(1);
expect(body.specs[0]).toMatchObject({
name: "alpha-spec",
version: "1",
sourcePath: operatorSpec,
isBuiltIn: false,
});
});
it("computes isBuiltIn=true for specs whose sourcePath is under builtinDir", async () => {
const builtinSpec = join(builtinDir, "alpha.yaml");
writeFileSync(builtinSpec, ALPHA_SPEC);
runtime.specCache.readThrough(builtinSpec);
const app = buildApp({ runtime, eventBus, builtinDir });
const res = await app.request("/api/workflow/specs");
const body = (await res.json()) as { specs: Array<{ name: string; isBuiltIn: boolean }> };
expect(body.specs[0]?.isBuiltIn).toBe(true);
});
it("isBuiltIn=false when workflowBuiltinSpecsDir context is unset (graceful)", async () => {
const builtinSpec = join(builtinDir, "alpha.yaml");
writeFileSync(builtinSpec, ALPHA_SPEC);
runtime.specCache.readThrough(builtinSpec);
const app = buildApp({ runtime, eventBus }); // no builtinDir
const res = await app.request("/api/workflow/specs");
const body = (await res.json()) as { specs: Array<{ isBuiltIn: boolean }> };
expect(body.specs[0]?.isBuiltIn).toBe(false);
});
it("mixed built-in + operator: each row gets the correct isBuiltIn flag", async () => {
const builtinPath = join(builtinDir, "alpha.yaml");
writeFileSync(builtinPath, ALPHA_SPEC);
runtime.specCache.readThrough(builtinPath);
// Operator authors a different spec at workspace path.
const operatorPath = join(operatorDir, "beta.yaml");
writeFileSync(operatorPath, ALPHA_SPEC.replace(/alpha-spec/g, "beta-spec").replace(/alpha test/g, "beta test"));
runtime.specCache.readThrough(operatorPath);
const app = buildApp({ runtime, eventBus, builtinDir });
const res = await app.request("/api/workflow/specs");
const body = (await res.json()) as { specs: Array<{ name: string; isBuiltIn: boolean }> };
const byName = new Map(body.specs.map((s) => [s.name, s.isBuiltIn]));
expect(byName.get("alpha-spec")).toBe(true);
expect(byName.get("beta-spec")).toBe(false);
});
it("does NOT mark sibling-suffix paths as built-in (false-positive guard)", async () => {
// Spec at path /tmp/.../builtin/workflow-specs-OTHER/foo.yaml should
// NOT be marked isBuiltIn just because its prefix matches the
// builtinDir string. The route uses path.sep boundary semantics.
const siblingDir = `${builtinDir}-other`;
mkdirSync(siblingDir, { recursive: true });
const siblingPath = join(siblingDir, "alpha.yaml");
writeFileSync(siblingPath, ALPHA_SPEC);
runtime.specCache.readThrough(siblingPath);
const app = buildApp({ runtime, eventBus, builtinDir });
const res = await app.request("/api/workflow/specs");
const body = (await res.json()) as { specs: Array<{ isBuiltIn: boolean }> };
expect(body.specs[0]?.isBuiltIn).toBe(false);
});
it("route-order: literal /specs is NOT shadowed by /:instance_id catchall", async () => {
// Without the route-order fix, GET /api/workflow/specs would hit the
// /:instance_id handler which returns 404 for "specs" as a fake id.
const app = buildApp({ runtime, eventBus, builtinDir });
const res = await app.request("/api/workflow/specs");
expect(res.status).toBe(200);
const body = (await res.json()) as { specs: unknown };
// Has the specs envelope shape, not the instance-not-found error.
expect(body).toHaveProperty("specs");
expect(body).not.toHaveProperty("error");
});
});