mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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: -df79077feat(daemon,ui): per-mission topology spec graph via workflow_spec frontmatter -b6f3114fix(workflow): revert SliceWorkflowBinding nullability — drop unused declaration path (guard #1 caught speculative declaration-fallback as dead code) -dcf1994feat(workflow-spec): ship [email protected] built-in spec for mission Topology (VM verify caught missing spec; aspirational declaration didn't exist in cache) -ac7d199fix(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 atb6f3114caught the missing-spec gap (topology.specGraph null; fallback worked); FF2 added the spec; FF3 fixed projector deduplication shape. Final cumulativeac7d199ready 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:
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 };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user