mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 11:41:37 -10:00
feat(daemon,ui): per-mission topology spec graph via workflow_spec frontmatter
V0.3.1 slice 13 walk-item 7. Mission Topology tab now projects the
declared workflow spec graph instead of unioning session-name
aggregations with a hardcoded specGraph: null. Slice frontmatter
gets the same `workflow_spec: <name>@<version>` declaration so
downstream slice-scope Topology work (slice 14) has a hook ready.
(1) Slice-indexer frontmatter parsing:
- New WorkflowSpecRef type + workflowSpec field on SliceRecord +
SliceListEntry.
- parseWorkflowSpecRef helper exported from slice-indexer so the
missions route can reuse the same parser (single source of truth).
- Malformed values that don't match `<name>@<version>` return null;
no exception path.
(2) findSliceWorkflowBinding extension:
- Optional WorkflowSpecDeclaration param. When passed AND no live
instance binds, returns a synthetic binding with
{ instanceId: null, currentStepId: null, status: "declared" } so
downstream projectSpecGraph(spec, null) can render the graph.
- Live instances always win over declarations (declared metadata is
a fallback, not an override).
- SliceWorkflowBinding.instanceId + createdAt widened to nullable for
the synthetic case.
(3) Missions route extension (GET /api/missions/:missionId):
- Returns workflow_spec parsed from <missionPath>/README.md
frontmatter via parseWorkflowSpecRef.
- Returns topology: { specGraph } when both declaration + spec cache
present; topology: { specGraph: null } when declared but uncached;
topology: null when nothing declared.
- Uses WorkflowSpecCache.getByNameVersion + projectSpecGraph(row.spec,
null) for projection.
(4) UI mission Topology tab:
- When missionData.topology.specGraph exists, render via TopologyTab
(same component the slice scope uses) with affectedRigs: [] +
totalSeats: 0 since the spec graph is the relevant projection.
- Fall back to existing ScopeTopologyRollup when no declaration /
no cached spec.
- useMission response type extended with workflow_spec +
topology fields.
Tests:
- slice-indexer.test.ts: 3 new (parse declared, null when absent,
null when malformed). Total 32/32.
- slice-workflow-binding.test.ts: 4 new (synthetic when declared +
no live; live wins over declaration; no decl + no live still null;
empty qitemIds + declaration). Total 13/13.
- missions-routes.test.ts: 4 new (workflow_spec parsed when
declared, null when absent; specGraph projected when spec cached;
topology.specGraph null when declared-but-uncached). Total 9/9.
Convention spread (per IMPL-PRD §4 — v0 scope):
- <work-tree>/missions/release-0.3.1/README.md frontmatter
declares `workflow_spec: [email protected]`. Slice-level READMEs
deferred pending walk feedback (dispatch's "list which READMEs"
question answered conservatively).
Full daemon suite: 3382 passed / 1 skipped / 0 failed on clean
re-run (banked flake-on-first-run pattern hit; resolved on retry).
UI typecheck on touched files: zero new errors. git diff --check
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
53e34b0c30
commit
df79077f31
8 files changed
+420
-18
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -25,8 +25,11 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export interface SliceWorkflowBinding {
|
||||
/** The most recent workflow_instance touching the slice's qitem set. */
|
||||
instanceId: string;
|
||||
/** The most recent workflow_instance touching the slice's qitem set.
|
||||
* V0.3.1 slice 13: nullable when binding is synthetic (declaration-
|
||||
* fallback path — slice/mission frontmatter declared workflow_spec
|
||||
* but no live instance is bound). */
|
||||
instanceId: string | null;
|
||||
workflowName: string;
|
||||
workflowVersion: string;
|
||||
status: string;
|
||||
@@ -34,7 +37,8 @@ export interface SliceWorkflowBinding {
|
||||
/** Frontier qitem_ids parsed from the JSON column. */
|
||||
currentFrontier: string[];
|
||||
hopCount: number;
|
||||
createdAt: string;
|
||||
/** ISO timestamp from the live instance row; null for synthetic bindings. */
|
||||
createdAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
@@ -56,11 +60,29 @@ interface InstanceRow {
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
/** V0.3.1 slice 13 walk-item 7 — frontmatter `workflow_spec: <name>@<version>`
|
||||
* declaration. When passed AND no live instance binds the slice's
|
||||
* qitems, findSliceWorkflowBinding returns a synthetic binding so
|
||||
* the Topology projector can render the spec graph for a slice that
|
||||
* has no live qitem activity yet. Live instances always win over
|
||||
* declarations: declared metadata is a fallback, not an override. */
|
||||
export interface WorkflowSpecDeclaration {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function findSliceWorkflowBinding(
|
||||
db: Database.Database,
|
||||
qitemIds: string[],
|
||||
declaration?: WorkflowSpecDeclaration | null,
|
||||
): SliceWorkflowBindingResult {
|
||||
if (qitemIds.length === 0) return { primary: null, additionalInstanceIds: [] };
|
||||
// V0.3.1 slice 13 — empty-qitemIds path also honors declaration so
|
||||
// mission-level callers (no qitem corpus) can still get a synthetic
|
||||
// binding from their frontmatter declaration.
|
||||
if (qitemIds.length === 0) {
|
||||
if (declaration) return { primary: syntheticBinding(declaration), additionalInstanceIds: [] };
|
||||
return { primary: null, additionalInstanceIds: [] };
|
||||
}
|
||||
|
||||
const instanceIds = new Set<string>();
|
||||
|
||||
@@ -95,7 +117,11 @@ export function findSliceWorkflowBinding(
|
||||
// workflow_instances absent — skip
|
||||
}
|
||||
|
||||
if (instanceIds.size === 0) return { primary: null, additionalInstanceIds: [] };
|
||||
// V0.3.1 slice 13 — declaration-fallback when no live instance binds.
|
||||
if (instanceIds.size === 0) {
|
||||
if (declaration) return { primary: syntheticBinding(declaration), additionalInstanceIds: [] };
|
||||
return { primary: null, additionalInstanceIds: [] };
|
||||
}
|
||||
|
||||
// Resolve full rows; sort by created_at DESC; pick most recent as primary.
|
||||
const idList = Array.from(instanceIds);
|
||||
@@ -121,6 +147,25 @@ export function findSliceWorkflowBinding(
|
||||
return { primary, additionalInstanceIds: additional };
|
||||
}
|
||||
|
||||
/** V0.3.1 slice 13 — synthetic binding for the frontmatter-declared
|
||||
* workflow_spec when no live instance is bound. The projector reads
|
||||
* `currentStepId` (null here) to skip "current step" highlighting;
|
||||
* `instanceId: null` signals to the UI that this is a declared-only
|
||||
* binding (no live instance). */
|
||||
function syntheticBinding(declaration: WorkflowSpecDeclaration): SliceWorkflowBinding {
|
||||
return {
|
||||
instanceId: null,
|
||||
workflowName: declaration.name,
|
||||
workflowVersion: declaration.version,
|
||||
status: "declared",
|
||||
currentStepId: null,
|
||||
currentFrontier: [],
|
||||
hopCount: 0,
|
||||
createdAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToBinding(row: InstanceRow): SliceWorkflowBinding {
|
||||
let frontier: string[] = [];
|
||||
try {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -187,4 +187,64 @@ describe("PL-slice-story-view-v1 findSliceWorkflowBinding", () => {
|
||||
expect(result.primary?.instanceId).toBe("inst-both");
|
||||
expect(result.additionalInstanceIds).toEqual([]);
|
||||
});
|
||||
|
||||
// V0.3.1 slice 13 walk-item 7 — declaration-fallback path. When the
|
||||
// slice/mission frontmatter declares `workflow_spec: <name>@<ver>`
|
||||
// but no live instance touches the slice, the binding falls back to
|
||||
// a synthetic record so the downstream projector can render the
|
||||
// spec graph (currentStepId: null → projectSpecGraph(spec, null)).
|
||||
describe("declaration-fallback binding (slice 13)", () => {
|
||||
it("returns synthetic binding when declaration provided + no live instance", () => {
|
||||
const result = findSliceWorkflowBinding(db, ["q-no-instance"], {
|
||||
name: "openrig-velocity",
|
||||
version: "1.0",
|
||||
});
|
||||
expect(result.primary).not.toBeNull();
|
||||
expect(result.primary?.instanceId).toBeNull();
|
||||
expect(result.primary?.workflowName).toBe("openrig-velocity");
|
||||
expect(result.primary?.workflowVersion).toBe("1.0");
|
||||
expect(result.primary?.currentStepId).toBeNull();
|
||||
expect(result.primary?.currentFrontier).toEqual([]);
|
||||
expect(result.primary?.status).toBe("declared");
|
||||
expect(result.additionalInstanceIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("declaration is IGNORED when a live instance already binds (live signal wins)", () => {
|
||||
insertInstance(db, {
|
||||
instanceId: "inst-live",
|
||||
currentFrontier: ["q-live"],
|
||||
workflowName: "different-spec",
|
||||
workflowVersion: "2.0",
|
||||
});
|
||||
insertTrail(db, {
|
||||
trailId: "t-live",
|
||||
instanceId: "inst-live",
|
||||
stepId: "x",
|
||||
stepRole: "r",
|
||||
priorQitemId: "q-live",
|
||||
});
|
||||
const result = findSliceWorkflowBinding(db, ["q-live"], {
|
||||
name: "openrig-velocity",
|
||||
version: "1.0",
|
||||
});
|
||||
// Live wins; declaration metadata is not surfaced when a real
|
||||
// instance is present.
|
||||
expect(result.primary?.instanceId).toBe("inst-live");
|
||||
expect(result.primary?.workflowName).toBe("different-spec");
|
||||
});
|
||||
|
||||
it("no declaration AND no live instance → null binding (current behavior preserved)", () => {
|
||||
const result = findSliceWorkflowBinding(db, ["q-no-instance"]);
|
||||
expect(result.primary).toBeNull();
|
||||
});
|
||||
|
||||
it("empty qitemIds + declaration provided → still returns synthetic binding (mission case)", () => {
|
||||
const result = findSliceWorkflowBinding(db, [], {
|
||||
name: "openrig-velocity",
|
||||
version: "1.0",
|
||||
});
|
||||
expect(result.primary?.workflowName).toBe("openrig-velocity");
|
||||
expect(result.primary?.instanceId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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