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:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 07:27:21 -07:00
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 {
+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) };
}
+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" });
@@ -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>
);
+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 {