Merge rsi-v2-starter-workflow-spec-v0 (RSI v2 hot-potato spec as built-in starter + 2 QA dogfood fixes)

This commit is contained in:
mvschwarz committed 2026-05-04 03:53:56 -07:00
commit 2e9b821443
12 files changed
+989 -25

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]
@@ -156,9 +156,17 @@ export class WorkflowSpecCache {
)
.get(spec.id, spec.version) as SpecRow | undefined;
if (existing && existing.source_hash === sourceHash) {
return rowToWorkflowSpec(existing);
// readThrough is file-authoritative: return the freshly parsed
// file spec so validation sees non-column metadata such as
// workflow.entry and workflow.invariants.
return rowToWorkflowSpec(existing, spec);
}
const cachedAt = this.now().toISOString();
const purpose = spec.objective ?? null;
const targetRig = spec.target?.rig ?? null;
const rolesJson = JSON.stringify(spec.roles);
const stepsJson = JSON.stringify(spec.steps);
const coordinationTerminalTurnRule = spec.coordination_terminal_turn_rule ?? "hot_potato";
if (existing) {
// Update in place (same name+version, content changed).
this.db
@@ -170,17 +178,27 @@ export class WorkflowSpecCache {
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",
purpose,
targetRig,
rolesJson,
stepsJson,
coordinationTerminalTurnRule,
sourcePath,
sourceHash,
cachedAt,
existing.spec_id,
);
return this.getByIdOrThrow(existing.spec_id);
return rowToWorkflowSpec({
...existing,
purpose,
target_rig: targetRig,
roles_json: rolesJson,
steps_json: stepsJson,
coordination_terminal_turn_rule: coordinationTerminalTurnRule,
source_path: sourcePath,
source_hash: sourceHash,
cached_at: cachedAt,
}, spec);
}
const specId = ulid();
this.db
@@ -195,16 +213,28 @@ export class WorkflowSpecCache {
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",
purpose,
targetRig,
rolesJson,
stepsJson,
coordinationTerminalTurnRule,
sourcePath,
sourceHash,
cachedAt,
);
return this.getByIdOrThrow(specId);
return rowToWorkflowSpec({
spec_id: specId,
name: spec.id,
version: spec.version,
purpose,
target_rig: targetRig,
roles_json: rolesJson,
steps_json: stepsJson,
coordination_terminal_turn_rule: coordinationTerminalTurnRule,
source_path: sourcePath,
source_hash: sourceHash,
cached_at: cachedAt,
}, spec);
}
getByNameVersion(name: string, version: string): WorkflowSpecRow | null {
@@ -214,6 +244,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((row) => rowToWorkflowSpec(row));
}
getByIdOrThrow(specId: string): WorkflowSpecRow {
const row = this.db
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
@@ -229,18 +272,24 @@ export class WorkflowSpecCache {
}
}
function rowToWorkflowSpec(row: SpecRow): WorkflowSpecRow {
function rowToWorkflowSpec(row: SpecRow, parsedSpec?: WorkflowSpec): 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,
};
const spec: WorkflowSpec = parsedSpec
? {
...parsedSpec,
coordination_terminal_turn_rule:
parsedSpec.coordination_terminal_turn_rule ?? row.coordination_terminal_turn_rule,
}
: {
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,
@@ -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");
}
+45
View File
@@ -1,6 +1,8 @@
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import * as path from "node:path";
import type { EventBus } from "../domain/event-bus.js";
import { QueueRepositoryError } from "../domain/queue-repository.js";
import {
WorkflowInstanceError,
WorkflowProjectorError,
@@ -66,6 +68,13 @@ export function workflowRoutes(): Hono {
: 500;
return c.json({ error: err.code, message: err.message, ...(err.details ?? {}) }, status as 200);
}
if (err instanceof QueueRepositoryError) {
const status =
err.code === "unknown_destination_rig" ? 400
: err.code === "qitem_not_found" ? 404
: 500;
return c.json({ error: err.code, message: err.message, ...(err.meta ?? {}) }, status as 200);
}
const message = err instanceof Error ? err.message : "internal error";
return c.json({ error: "internal_error", message }, 500);
}
@@ -148,6 +157,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 +234,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");
});
});
+24 -1
View File
@@ -93,8 +93,9 @@ describe("workflow routes (PL-004 Phase D)", () => {
body: JSON.stringify({ specPath }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
const body = (await res.json()) as { ok: boolean; summary: { entryRole: string | null } };
expect(body.ok).toBe(true);
expect(body.summary.entryRole).toBe("producer");
});
it("POST /validate returns 404 for missing file", async () => {
@@ -118,6 +119,28 @@ describe("workflow routes (PL-004 Phase D)", () => {
expect(body.entryQitemId).toBeDefined();
});
it("POST /instantiate surfaces queue destination validation as 400", async () => {
const rejectingQueueRepo = new QueueRepository(db, bus, { validateRig: () => false });
const rejectingRuntime = new WorkflowRuntime({
db,
eventBus: bus,
queueRepo: rejectingQueueRepo,
});
const rejectingApp = buildApp({ eventBus: bus, runtime: rejectingRuntime });
const res = await rejectingApp.request("/api/workflow/instantiate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specPath, rootObjective: "test", createdBySession: "ops@rig" }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string; message: string };
expect(body.error).toBe("unknown_destination_rig");
expect(body.error).not.toBe("internal_error");
expect(body.message).toContain("producer@rig");
});
it("POST /project closes packet + creates next packet", async () => {
const create = await app.request("/api/workflow/instantiate", {
method: "POST",