merge(0.3.1): slice 13 founder-walk-topology-per-scope-mapping — per-mission spec graph via workflow_spec frontmatter + openrig-velocity builtin

Closes walk item 7 (topology defined where; same graph everywhere).

What ships:
- WorkflowSpecRef type + workflowSpec field on SliceRecord/SliceListEntry; parseWorkflowSpecRef helper exported as single-source-of-truth parser
- GET /api/missions/:missionId extended with workflow_spec (parsed from mission README frontmatter) + topology.specGraph (projectSpecGraph(spec, null) when cached; null when undeclared or uncached)
- UI MissionScopePage Topology tab renders projected spec graph via TopologyTab when missionData.topology.specGraph exists; falls back to ScopeTopologyRollup otherwise
- Convention spread: <work-tree>/missions/release-0.3.1/README.md frontmatter declares workflow_spec: [email protected]
- NEW builtin workflow spec: packages/daemon/src/builtins/workflow-specs/openrig-velocity.yaml (6 steps modeling slice lifecycle: orch-dispatch → advisor refine → driver implement → guard review → qa verify → orch-merge; roles preferred_targets point at real seats)

Forward-fix iterations on this slice:
- df79077 feat(daemon,ui): per-mission topology spec graph via workflow_spec frontmatter
- b6f3114 fix(workflow): revert SliceWorkflowBinding nullability — drop unused declaration path (guard #1 caught speculative declaration-fallback as dead code)
- dcf1994 feat(workflow-spec): ship [email protected] built-in spec for mission Topology (VM verify caught missing spec; aspirational declaration didn't exist in cache)
- ac7d199 fix(workflow-spec): unique roles per step in openrig-velocity + projection-shape test (guard re-review caught projector deduplication-by-role; orch split into orch-dispatch + orch-merge with same preferred_targets; linear chain 6 nodes / 5 edges / no orphans)

Test coverage:
- slice-indexer: 32/32 (+3: workflow_spec parsing path)
- slice-workflow-binding: 9/9 (live-binding only after FF1 dropped speculative declaration-fallback)
- missions-routes: 9/9 (+4: workflow_spec / null absent / specGraph projected / specGraph null when uncached)
- starter-spec-loader: 8/8 (+1 discriminator test that loads shipped openrig-velocity spec + projects via projectSpecGraph + asserts EXACT edge chain + entry [dispatch] + terminal [merge] + no orphans; discrimination verified via hardcoded-probe-in-source on pre-fix YAML)
- conveyor-starter: 5/5 (updated to include openrig-velocity in shipped builtin list)
- Full daemon: 3379/3379 PASS / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors
- git diff --check clean

VM verify path: velocity-qa first verify at b6f3114 caught the missing-spec gap (topology.specGraph null; fallback worked); FF2 added the spec; FF3 fixed projector deduplication shape. Final cumulative ac7d199 ready for VM verify on populated env (founder-walk VM at 17459 tracks main).

Triple-guard:
- redo3-driver-3 driver (Phase D + FF1 + FF2 + FF3; methodical 4-iteration arc with discriminator-verified tests at each)
- redo3-guard-3 guard (3 reverify passes: BLOCKING #1 on dead-code nullable type widening, BLOCKING #2 on shape-not-coherent under projector contract, CLEAR on linear-chain reshape)
- velocity-qa VM partial: pre-FF2 caught spec-missing gap; post-merge VM verify follows on refreshed founder-walk VM

Banking-worthy lesson (from FF3): when authoring workflow_specs that get rendered via projectSpecGraph, each step must have a UNIQUE actor_role for the projector's role→step resolution to produce a coherent graph. Two steps with the same actor_role collapse: the projector keeps the first and the second becomes orphaned. Same-human-different-graph-role is the right split (e.g., orch-dispatch + orch-merge both prefer the same operator seat; the split is purely graph hygiene). Companion to projector single-frontier contract (banked feedback_workflow_spec_validate_against_projector).

Walk-item phrasing discipline applied throughout. Slice id token retained.

WAVE 2 progress: slice 13 of 3 wave-2 slices closes (13 + 14 + 15); 14 + 15 held for next cap opening.
This commit is contained in:
mvschwarz committed 2026-05-11 08:29:37 -07:00
commit 5973140b24
9 files changed
+483 -15

No files matched your search

@@ -0,0 +1,103 @@
# V0.3.1 slice 13 walk-item 7 — openrig-velocity workflow spec.
#
# Built-in spec for the openrig-velocity rig (the slice-development
# topology this product is built with). Referenced by
# substrate/openrig-work/missions/release-0.3.1/README.md frontmatter
# `workflow_spec: [email protected]` and projected onto the mission
# Topology tab by the missions route.
#
# Flow (one slice end-to-end, linear single-frontier chain):
# orch-dispatch → advisor (refine) → driver (implement) →
# guard (review) → qa (vm-verify) → orch-merge.
#
# Roles are uniquely named per step so the projector's role→step
# resolution doesn't collapse two orch steps onto the same node (the
# projector keeps the FIRST step per actor_role; identical roles on
# two steps would orphan the second). orch-dispatch + orch-merge both
# preferred_targets to orch-lead@openrig-velocity since the same human
# seat performs both roles in practice.
workflow:
id: openrig-velocity
version: "1.0"
objective: >
Drive one slice from dispatch through merge using the
openrig-velocity rig's role chain (orch-dispatch / advisor / driver /
guard / qa / orch-merge).
target:
rig: openrig-velocity
entry:
role: orch-dispatch
coordination_terminal_turn_rule: hot_potato
roles:
orch-dispatch:
skill_refs: [openrig-user, orchestration-team]
preferred_targets: [orch-lead@openrig-velocity]
advisor:
skill_refs: [openrig-user, brainstorming, requirements-writer]
preferred_targets: [advisor-lead@openrig-velocity]
driver:
skill_refs: [openrig-user, executing-plans, test-driven-development]
preferred_targets: [redo3-driver-3@openrig-velocity]
guard:
skill_refs: [openrig-user, review-team, systematic-debugging]
preferred_targets: [redo3-guard-3@openrig-velocity]
qa:
skill_refs: [openrig-user, dogfood, verification-before-completion]
preferred_targets: [velocity-qa@openrig-velocity]
orch-merge:
skill_refs: [openrig-user, orchestration-team]
preferred_targets: [orch-lead@openrig-velocity]
steps:
- id: dispatch
actor_role: orch-dispatch
objective: Author the dispatch packet + queue handoff to advisor or driver.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [advisor]
- id: refine
actor_role: advisor
objective: Author or refine the IMPL-PRD before driver picks up.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [driver]
- id: implement
actor_role: driver
objective: TDD-first implementation; commit on slice branch; hand off to guard.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [guard]
- id: guard-review
actor_role: guard
objective: Narrow re-review of the slice branch tip; CLEAR / CONCERNING / BLOCKING.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [qa]
- id: vm-verify
actor_role: qa
objective: Exercise the slice on a disposable VM; PASS / CONCERNING / FAIL.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [orch-merge]
- id: merge
actor_role: orch-merge
objective: Merge the slice to main + close the dispatch loop.
allowed_exits: [done, failed]
next_hop:
mode: forbid
invariants:
continuation_required: true
preserve_lineage: true
closure_required: true
allowed_exits: [handoff, waiting, done, failed]
loop_guards:
max_hops: 24
spawn_budget: 0
closure:
success: Slice merged + close-out packet authored.
degraded: Slice paused with a named blocker; forward-fix queued.
failed: Slice rolled back with evidence.
@@ -45,6 +45,16 @@ export interface SliceProofPacket {
mtime: string;
}
/** V0.3.1 slice 13 walk-item 7 — `workflow_spec: <name>@<version>`
* frontmatter declaration. When set, the Topology tab projects a
* spec graph from this declaration via WorkflowSpecCache.getByName-
* Version + projectSpecGraph, even when no live workflow_instance
* is bound. Null when the field is absent or malformed. */
export interface WorkflowSpecRef {
name: string;
version: string;
}
export interface SliceRecord {
/** Folder name (canonical id). */
name: string;
@@ -70,6 +80,8 @@ export interface SliceRecord {
lastActivityAt: string | null;
/** Sources cited in frontmatter (e.g. PRDs, planner-briefs). */
files: string[];
/** Parsed `workflow_spec: <name>@<version>` frontmatter declaration; null when absent or malformed. */
workflowSpec: WorkflowSpecRef | null;
}
export interface SliceListEntry {
@@ -77,6 +89,8 @@ export interface SliceListEntry {
missionId: string | null;
displayName: string;
railItem: string | null;
/** Mirrors SliceRecord.workflowSpec; null when absent or malformed. */
workflowSpec: WorkflowSpecRef | null;
status: SliceStatus;
rawStatus: string | null;
qitemCount: number;
@@ -282,6 +296,7 @@ export class SliceIndexer {
missionId,
displayName: this.extractDisplayName(slicePath, frontmatter, name),
railItem,
workflowSpec: parseWorkflowSpecRef(frontmatter["workflow_spec"]),
status,
rawStatus: (frontmatter["status"] as string | undefined) ?? null,
qitemCount: qitemIds.length,
@@ -315,6 +330,7 @@ export class SliceIndexer {
proofPacket,
lastActivityAt,
files,
workflowSpec: parseWorkflowSpecRef(frontmatter["workflow_spec"]),
};
}
@@ -648,3 +664,20 @@ export function parseFrontmatter(content: string): Record<string, unknown> {
}
return out;
}
/** V0.3.1 slice 13 walk-item 7 — parse `workflow_spec: <name>@<version>`
* frontmatter into a structured ref. Returns null when the value is
* missing, non-string, or doesn't match the `<name>@<version>` shape.
* Exported so the missions route can reuse the same parser on the
* mission README's frontmatter. */
export function parseWorkflowSpecRef(raw: unknown): WorkflowSpecRef | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const atIdx = trimmed.lastIndexOf("@");
if (atIdx <= 0 || atIdx === trimmed.length - 1) return null;
const name = trimmed.slice(0, atIdx).trim();
const version = trimmed.slice(atIdx + 1).trim();
if (!name || !version) return null;
return { name, version };
}
+82 -11
View File
@@ -1,24 +1,40 @@
// V0.3.1 slice 12 walk-item 1 — mission scope data layer.
// V0.3.1 slice 13 walk-item 7 — extended with workflow_spec frontmatter
// + projected topology spec graph.
//
// GET /api/missions/:missionId — returns aggregated mission metadata
// (missionPath + slices in this mission). Powers the Mission Overview
// + Mission Progress tabs in the Project surface; pairs with the
// generalized useScopeMarkdown hook which reads README.md / PROGRESS.md
// content via the existing /api/files/read route.
// (missionPath + slices + optional workflow_spec declaration + optional
// projected topology). Powers the Mission Overview / Progress / Topology
// tabs in the Project surface; pairs with useScopeMarkdown for README /
// PROGRESS content via the existing /api/files/read route.
//
// Returns:
// 200 { missionId, missionPath, slices: SliceListEntry[] }
// 200 {
// missionId, missionPath, slices,
// workflow_spec: { name, version } | null,
// topology: { specGraph: SpecGraphPayload | null } | null
// }
// 404 { error: "mission_not_found" } when no slices match
// 503 { error: "slices_indexer_unavailable" } when indexer not wired
// 503 { error: "slices_root_not_configured" } when indexer not ready
//
// This route does NOT itself read frontmatter / README / PROGRESS
// content — it's the metadata layer. File content reuses
// /api/files/read so the route surface stays minimal.
// workflow_spec is parsed lazily from <missionPath>/README.md frontmatter
// using the same parser the slice-indexer uses. topology.specGraph is
// projected via projectSpecGraph(spec, null) when the spec is in the
// WorkflowSpecCache; { specGraph: null } when declared but not cached;
// null when nothing is declared.
import { Hono } from "hono";
import * as fs from "node:fs";
import * as path from "node:path";
import type { SliceIndexer, SliceListEntry } from "../domain/slices/slice-indexer.js";
import type {
SliceIndexer,
SliceListEntry,
WorkflowSpecRef,
} from "../domain/slices/slice-indexer.js";
import { parseWorkflowSpecRef } from "../domain/slices/slice-indexer.js";
import type { WorkflowSpecCache } from "../domain/workflow-spec-cache.js";
import { projectSpecGraph } from "../domain/workflow/slice-workflow-projection.js";
export function missionsRoutes(): Hono {
const app = new Hono();
@@ -50,10 +66,17 @@ export function missionsRoutes(): Hono {
return c.json({ error: "mission_not_found", missionId }, 404);
}
const missionPath = computeMissionPath(slices[0]!);
const workflowSpec = readMissionWorkflowSpec(missionPath);
const topology = computeMissionTopology(
workflowSpec,
c.get("workflowSpecCache" as never) as WorkflowSpecCache | undefined,
);
return c.json({
missionId,
missionPath,
slices,
workflow_spec: workflowSpec,
topology,
});
});
@@ -65,7 +88,55 @@ export function missionsRoutes(): Hono {
* `<missionsRoot>/<missionId>/slices/<sliceName>` per the workspace
* contract, so going up two levels yields the mission folder. */
function computeMissionPath(slice: SliceListEntry): string {
// slicePath = <root>/<missionId>/slices/<sliceName>
// up 2 levels = <root>/<missionId>
return path.resolve(slice.slicePath, "..", "..");
}
/** V0.3.1 slice 13 walk-item 7 — parse `workflow_spec` from the mission
* README's frontmatter. Returns null when the README is missing or the
* field is absent / malformed. Uses the same parseWorkflowSpecRef
* helper the slice-indexer uses so both surfaces stay in lockstep. */
function readMissionWorkflowSpec(missionPath: string): WorkflowSpecRef | null {
const readmePath = path.join(missionPath, "README.md");
if (!fs.existsSync(readmePath)) return null;
const raw = fs.readFileSync(readmePath, "utf-8");
const fm = parseSimpleFrontmatter(raw);
return parseWorkflowSpecRef(fm["workflow_spec"]);
}
/** Minimal frontmatter parser. The slice-indexer's parseFrontmatter is
* private; duplicating the v0 shape here is cheaper than exposing
* internal API and keeps the missions route's surface minimal. */
function parseSimpleFrontmatter(text: string): Record<string, string> {
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) return {};
const out: Record<string, string> = {};
for (const line of m[1]!.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const colonIdx = trimmed.indexOf(":");
if (colonIdx === -1) continue;
const key = trimmed.slice(0, colonIdx).trim();
let value = trimmed.slice(colonIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
/** V0.3.1 slice 13 walk-item 7 — project the spec graph when both the
* declaration AND the cached spec are present. Returns the topology
* envelope with `specGraph: null` when declared but not yet cached;
* returns `null` for the whole envelope when nothing is declared. */
function computeMissionTopology(
workflowSpec: WorkflowSpecRef | null,
specCache: WorkflowSpecCache | undefined,
): { specGraph: ReturnType<typeof projectSpecGraph> | null } | null {
if (!workflowSpec) return null;
if (!specCache) return { specGraph: null };
const row = specCache.getByNameVersion(workflowSpec.name, workflowSpec.version);
if (!row) return { specGraph: null };
// WorkflowSpecRow.spec is the WorkflowSpec object the projector expects.
return { specGraph: projectSpecGraph(row.spec, null) };
}
@@ -54,7 +54,15 @@ describe("0.3.0 conveyor starter workflow specs", () => {
});
expect(result.errors).toEqual([]);
expect(result.loaded.map((s) => s.name).sort()).toEqual(["basic-loop", "conveyor"]);
// V0.3.1 slice 13 walk-item 7 — [email protected] added as a
// built-in spec so release-0.3.1's mission Topology tab projects
// it. Keep this list in sync with
// packages/daemon/src/builtins/workflow-specs/*.yaml.
expect(result.loaded.map((s) => s.name).sort()).toEqual([
"basic-loop",
"conveyor",
"openrig-velocity",
]);
expect(result.skipped).toEqual([]);
});
+114 -1
View File
@@ -25,18 +25,40 @@ 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 { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
import { missionsRoutes } from "../src/routes/missions.js";
function buildApp(indexer: SliceIndexer): Hono {
function buildApp(
indexer: SliceIndexer,
specCache?: WorkflowSpecCache,
): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sliceIndexer" as never, indexer);
c.set("workflowSpecCache" as never, specCache);
await next();
});
app.route("/api/missions", missionsRoutes());
return app;
}
function writeMissionReadme(
missionsRoot: string,
missionId: string,
frontmatter: Record<string, string> = {},
): void {
const dir = path.join(missionsRoot, missionId);
fs.mkdirSync(dir, { recursive: true });
const fm = Object.entries(frontmatter)
.map(([k, v]) => `${k}: ${v}`)
.join("\n");
fs.writeFileSync(
path.join(dir, "README.md"),
`---\n${fm}\n---\n# ${missionId}\n`,
);
}
function writeSliceInMission(
missionsRoot: string,
missionId: string,
@@ -134,4 +156,95 @@ describe("GET /api/missions/:missionId", () => {
const body = (await res.json()) as { slices: Array<{ name: string }> };
expect(body.slices.map((s) => s.name)).toEqual(["slice-a"]);
});
// V0.3.1 slice 13 walk-item 7 — mission frontmatter workflow_spec
// declaration + projected topology.
describe("workflow_spec + topology (slice 13)", () => {
it("returns workflow_spec from mission README frontmatter when declared", async () => {
writeMissionReadme(missionsRoot, "getting-started", {
status: "active",
workflow_spec: "[email protected]",
});
writeSliceInMission(missionsRoot, "getting-started", "first-slice", { status: "active" });
const res = await app.request("/api/missions/getting-started");
expect(res.status).toBe(200);
const body = (await res.json()) as {
workflow_spec: { name: string; version: string } | null;
};
expect(body.workflow_spec).toEqual({ name: "openrig-velocity", version: "1.0" });
});
it("returns workflow_spec: null when mission README has no declaration", async () => {
writeMissionReadme(missionsRoot, "plain-mission", { status: "active" });
writeSliceInMission(missionsRoot, "plain-mission", "child-slice", { status: "active" });
const res = await app.request("/api/missions/plain-mission");
const body = (await res.json()) as { workflow_spec: unknown };
expect(body.workflow_spec).toBeNull();
});
it("returns topology.specGraph when workflow_spec declared AND the spec is in the cache", async () => {
// Migrate workflow_specs table for the spec cache to use.
migrate(db, [workflowSpecsSchema]);
const specCache = new WorkflowSpecCache(db);
// Hand-author a minimal spec file + cache it through.
const specPath = path.join(cleanupRoot, "openrig-velocity.workflow.yaml");
fs.writeFileSync(specPath, [
"workflow:",
" id: openrig-velocity",
" version: \"1.0\"",
" objective: \"Demo\"",
" target:",
" rig: openrig-velocity",
" roles:",
" role-a: {}",
" entry:",
" role: role-a",
" steps:",
" - id: step-a",
" actor_role: role-a",
" next_hop:",
" suggested_roles: []",
"",
].join("\n"));
specCache.readThrough(specPath);
writeMissionReadme(missionsRoot, "mission-with-spec", {
status: "active",
workflow_spec: "[email protected]",
});
writeSliceInMission(missionsRoot, "mission-with-spec", "slice-a", { status: "active" });
const appWithCache = buildApp(indexer, specCache);
const res = await appWithCache.request("/api/missions/mission-with-spec");
expect(res.status).toBe(200);
const body = (await res.json()) as {
topology: { specGraph: { specName: string; specVersion: string; nodes: unknown[] } | null };
};
expect(body.topology).not.toBeNull();
expect(body.topology.specGraph).not.toBeNull();
expect(body.topology.specGraph!.specName).toBe("openrig-velocity");
expect(body.topology.specGraph!.specVersion).toBe("1.0");
expect(body.topology.specGraph!.nodes.length).toBeGreaterThan(0);
});
it("returns topology: { specGraph: null } when workflow_spec declared but spec NOT in cache", async () => {
writeMissionReadme(missionsRoot, "unbound-mission", {
status: "active",
workflow_spec: "[email protected]",
});
writeSliceInMission(missionsRoot, "unbound-mission", "child-slice", { status: "active" });
// No spec cache wired; the route falls through to specGraph: null.
const res = await app.request("/api/missions/unbound-mission");
const body = (await res.json()) as {
workflow_spec: { name: string } | null;
topology: { specGraph: unknown } | null;
};
expect(body.workflow_spec?.name).toBe("ghost-spec");
expect(body.topology?.specGraph).toBeNull();
});
});
});
@@ -231,6 +231,41 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
});
});
// V0.3.1 slice 13 walk-item 7 — workflow_spec frontmatter parsing.
// The mission Topology tab (and slice Topology fallback) projects
// a spec graph from this declaration even when no live workflow
// instance is bound. Format: `workflow_spec: <name>@<version>`.
describe("workflow_spec frontmatter", () => {
it("parses workflow_spec: <name>@<version> from frontmatter onto SliceRecord + SliceListEntry", () => {
writeSlice(slicesRoot, "topo-slice", {
"README.md": "---\nstatus: active\nworkflow_spec: [email protected]\n---\n# Topo\n",
});
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
const list = indexer.list();
expect(list[0]!.workflowSpec).toEqual({ name: "openrig-velocity", version: "1.0" });
const record = indexer.get("topo-slice")!;
expect(record.workflowSpec).toEqual({ name: "openrig-velocity", version: "1.0" });
});
it("returns workflowSpec: null when the frontmatter field is absent", () => {
writeSlice(slicesRoot, "no-topo-slice", {
"README.md": "---\nstatus: active\n---\n# Plain\n",
});
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
const list = indexer.list();
expect(list[0]!.workflowSpec).toBeNull();
expect(indexer.get("no-topo-slice")!.workflowSpec).toBeNull();
});
it("ignores malformed workflow_spec values that do not match <name>@<version>", () => {
writeSlice(slicesRoot, "bad-topo-slice", {
"README.md": "---\nstatus: active\nworkflow_spec: not-a-valid-spec-ref\n---\n",
});
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
expect(indexer.list()[0]!.workflowSpec).toBeNull();
});
});
describe("qitem matching", () => {
it("matches qitems by slice-name body substring", () => {
writeSlice(slicesRoot, "mission-control-phase-a", { "README.md": "---\nstatus: shipped\n---\n" });
@@ -24,7 +24,8 @@ 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";
import { loadStarterWorkflowSpecs, defaultBuiltinSpecsDir } from "../src/domain/workflow/starter-spec-loader.js";
import { projectSpecGraph } from "../src/domain/workflow/slice-workflow-projection.js";
const ALPHA_SPEC = `workflow:
id: alpha-spec
@@ -154,4 +155,63 @@ describe("built-in workflow spec loader", () => {
const result = loadStarterWorkflowSpecs({ cache, builtinDir });
expect(result.loaded.map((r) => r.name).sort()).toEqual(["alpha-spec", "beta-spec"]);
});
// V0.3.1 slice 13 walk-item 7 — load the actual shipped builtins
// directory and assert [email protected] projects to a coherent
// linear graph. velocity-qa VM caught the missing-spec case on
// ef3e94d; guard re-review on 10ed741 caught the role-collision
// case (two steps sharing actor_role 'orch' → projector kept only
// the first → orphan nodes). This test is the projection-shape
// discriminator per banked feedback_poc_regression_must_discriminate:
// exact edge chain + zero orphans + entry/terminal placement.
it("shipped [email protected] projects to a 6-node linear chain with no orphans (mission Topology acceptance)", () => {
const shippedDir = defaultBuiltinSpecsDir();
const result = loadStarterWorkflowSpecs({ cache, builtinDir: shippedDir });
expect(result.errors).toEqual([]);
const row = cache.getByNameVersion("openrig-velocity", "1.0");
expect(row).not.toBeNull();
const graph = projectSpecGraph(row!.spec, null);
// Six declared steps; six projected nodes.
expect(graph.nodes.map((n) => n.stepId)).toEqual([
"dispatch",
"refine",
"implement",
"guard-review",
"vm-verify",
"merge",
]);
// Linear chain — exact edges (newer→older permitted only at the
// terminal merge step which has no outgoing edge).
expect(
graph.edges.map((e) => `${e.fromStepId}→${e.toStepId}`),
).toEqual([
"dispatch→refine",
"refine→implement",
"implement→guard-review",
"guard-review→vm-verify",
"vm-verify→merge",
]);
// Entry + terminal placement.
const entryNodes = graph.nodes.filter((n) => n.isEntry).map((n) => n.stepId);
expect(entryNodes).toEqual(["dispatch"]);
const terminalNodes = graph.nodes.filter((n) => n.isTerminal).map((n) => n.stepId);
expect(terminalNodes).toEqual(["merge"]);
// No orphans: every non-entry node has at least one incoming edge
// and every non-terminal node has at least one outgoing edge.
const incoming = new Set(graph.edges.map((e) => e.toStepId));
const outgoing = new Set(graph.edges.map((e) => e.fromStepId));
for (const node of graph.nodes) {
if (!node.isEntry) {
expect(incoming.has(node.stepId), `${node.stepId} has no incoming edge`).toBe(true);
}
if (!node.isTerminal) {
expect(outgoing.has(node.stepId), `${node.stepId} has no outgoing edge`).toBe(true);
}
}
});
});
@@ -821,7 +821,31 @@ export function MissionScopePage() {
/>
) : null}
{active === "topology" ? (
<ScopeTopologyRollup detailsByName={rollup.details.itemsByName} />
(() => {
// V0.3.1 slice 13 walk-item 7 — when the mission declares
// `workflow_spec: <name>@<version>` in its README frontmatter
// AND the spec is in the WorkflowSpecCache, the missions
// route returns a projected spec graph. Render it via
// TopologyTab (same component the slice scope uses). Fall
// back to the session-name aggregation when the declaration
// is absent or the spec isn't cached.
const missionTopology =
missionData.data && "topology" in missionData.data
? missionData.data.topology
: null;
if (missionTopology?.specGraph) {
return (
<TopologyTab
topology={{
affectedRigs: [],
totalSeats: 0,
specGraph: missionTopology.specGraph,
}}
/>
);
}
return <ScopeTopologyRollup detailsByName={rollup.details.itemsByName} />;
})()
) : null}
</ScopeShell>
);
+21
View File
@@ -7,6 +7,23 @@
import { useQuery } from "@tanstack/react-query";
import type { SliceListEntry } from "./useSlices.js";
import type { SpecGraphPayload } from "./useSlices.js";
/** V0.3.1 slice 13 walk-item 7 — mission frontmatter `workflow_spec`
* declaration. Parsed by the missions route from
* `<missionPath>/README.md` frontmatter; null when absent. */
export interface MissionWorkflowSpecRef {
name: string;
version: string;
}
/** V0.3.1 slice 13 walk-item 7 — projected mission topology. Envelope
* is null when no `workflow_spec` is declared. Inside the envelope
* `specGraph` is null when the declaration is present but the spec
* isn't yet in the cache (declared-but-unshipped). */
export interface MissionTopology {
specGraph: SpecGraphPayload | null;
}
export interface MissionDataResponse {
missionId: string;
@@ -14,6 +31,10 @@ export interface MissionDataResponse {
missionPath: string;
/** Slices in this mission (SliceListEntry[] filtered). */
slices: SliceListEntry[];
/** V0.3.1 slice 13 — workflow_spec frontmatter declaration. */
workflow_spec: MissionWorkflowSpecRef | null;
/** V0.3.1 slice 13 — projected mission topology (specGraph). */
topology: MissionTopology | null;
}
export interface MissionUnavailable {