mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
1 parent
66796c21dd
commit
5bdda4f8be
11 files changed
+898
-1
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user