mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
feat(slice-story-view): v1 — spec-driven Topology / Story / Acceptance + routing-type carve-out
Slice Story View v1 — when a workflow_instance is bound to a slice, the
Slice Story View renders spec-driven UI: Topology shows the workflow_spec
graph, Story uses spec-defined phase tagging from workflow_step_trails,
Acceptance shows the live frontier + allowed next steps. Same UI works
for any spec, not just RSI v2. Four dimensions ship together as one
ambitious vertical. NO new schema, NO migrations, NO new event types —
consumes already-shipped workflow_specs / workflow_instances /
workflow_step_trails.
Daemon (4 new files + 3 edits):
- domain/workflow/slice-workflow-binding.ts (NEW): findSliceWorkflowBinding
resolves workflow_instance(s) touching a slice's qitem set via UNION
of two signals — workflow_step_trails.prior_qitem_id/next_qitem_id IN
slice qitems (historical) + workflow_instances.current_frontier_json
LIKE %qitem% (live). Returns most-recent-by-created_at as primary;
exposes additionalInstanceIds for "+N more" UI hint per PRD's multi-
instance carve-out. Graceful on malformed current_frontier_json
(parses to empty array).
- domain/workflow/slice-workflow-projection.ts (NEW): three projection
helpers consuming WorkflowSpec + bound instance state:
* projectSpecGraph — nodes per step + edges from each step's
next_hop.suggested_roles resolved to step ids; isCurrent /
isEntry / isTerminal flags; isLoopBack detected by declared step
order. v1 carve-out per audit row 6: every edge has
routingType="direct" because Phase D's WorkflowStepSpec has no
routing_type field — the routing-type metadata becomes v2+
enrichment.
* projectPhaseDefinitions — one entry per spec step; canonical id
= step.id; label = step.actor_role.
* projectCurrentStep — current_step_id resolves against spec;
returns step.objective + allowed_exits + allowed_next_steps
(next_hop suggested roles → step ids); null when current_step_id
is null (terminal) or doesn't resolve.
- domain/slices/slice-detail-projector.ts (EDITED):
* StoryEvent.phase: was hardcoded enum
"discovery"|"product-lab"|"delivery"|"lifecycle"|"qa"|"other",
now `string | null` (open-ended; spec step.id when bound + qitem
maps to a workflow_step_trail; null otherwise).
* v0's classifyPhase() heuristic (which inferred phase from session
name substrings) is REMOVED. Per PRD § Write Set Sketch:
"v0 hardcoded phase taxonomy in StoryTab.tsx is REMOVED (replaced
by spec-driven or ungrouped)" — this is the one v0 surface v1
modifies, by deletion. Hardcoded phase tags for non-qitem events
("discovery" for doc.edited; "qa" for proof_packet.emitted) also
removed; those events are now phase=null (untagged).
* SliceDetailPayload extended with workflowBinding +
story.phaseDefinitions + acceptance.currentStep + topology.specGraph.
All v1 fields are null when no instance is bound (v0 fallback).
* project() resolves binding once, fetches the bound spec from
WorkflowSpecCache, builds a qitem→step trail map for phase
tagging, then composes the four v1 dimensions into the payload.
* Graceful degradation: when constructed without workflowSpecCache,
v1 fields stay null even when an instance is bound (v0 mode).
- src/startup.ts (EDITED): pass workflowRuntime.specCache to
SliceDetailProjector.
UI (1 new file + 4 edits):
- hooks/useSlices.ts (EDITED): SliceDetail extended with v1 types —
WorkflowBindingPayload, PhaseDefinition, CurrentStepPayload,
SpecGraphPayload (nodes + edges) — plus the new fields on the
story / acceptance / topology sub-objects.
- components/slices/tabs/StoryTab.tsx (EDITED): phase chip color
palette is now assigned by the spec's declared phase index
(emerald/amber/violet/sky/rose/fuchsia/cyan/orange cycle) — v0's
hardcoded RSI-v2 phase color map is GONE. Untagged events
(phase=null) render with the neutral stone palette + "untagged"
label. When phaseDefinitions is null (unbound slice), event phase
ids fall through to the chip label verbatim (graceful for
previously-bound-then-unbound slices).
- components/slices/tabs/AcceptanceTab.tsx (EDITED): renders Current
Step panel ABOVE the v0 PROGRESS.md checkbox list when
acceptance.currentStep is non-null. Panel shows step id + role +
objective + allowed exits + allowed next steps + hop count + status.
v0 checkbox view + progress bar + closure callout all unchanged.
- components/slices/tabs/TopologyTab.tsx (EDITED): renders spec graph
panel ABOVE the v0 per-rig listing when topology.specGraph is
non-null. Per-step node + outgoing edges with isCurrent / isEntry /
isTerminal badges; loop-back edges styled with ↺ glyph + "loop"
badge; forward edges with → glyph. Spec name + version surfaced in
the aggregate header. v0 per-rig listing unchanged.
- components/slices/SliceStoryView.tsx (EDITED): pass phaseDefinitions
to StoryTab.
PRD audit reinterpretations recorded:
- Row 6 (routing_type carve-out activated): Phase D's WorkflowStepSpec
has only `next_hop?: { mode, suggested_roles }` — no routing_type
field. PRD's explicit carve-out applies: dimension #4 ships as
default-styled solid edges only with routingType="direct" baked in.
Routing-type metadata becomes v2+ enrichment.
Verification:
- daemon: 2781/2781 (was 2750 baseline at 2e9b821 + 30 new v1 tests
across 3 new test files: 9 binding + 16 projection + 5 e2e
projector. 1 v0 route test updated to include the new
workflowBinding key in the payload key-list assertion).
- ui: 468/468 (was 458 baseline + 10 new across 1 new test file:
3 StoryTab + 3 AcceptanceTab + 4 TopologyTab).
- cli: untouched by this slice; baseline 800.
- tsc clean for daemon + cli + ui.
Boundaries preserved per PRD § No-Touch Surfaces:
- v0's DecisionsTab, DocsTab, TestsVerificationTab — unchanged.
- v0's slice indexer + GET /api/slices list endpoint + sidebar — unchanged.
- v0's PROGRESS.md acceptance parsing — unchanged.
- PL-019's RigNode / Explorer / graph-projection / routes/rigs / ps
--filter / edge animation — unchanged. (v1 doesn't compose with
PL-019 inside the spec graph at v0-of-v1; that's a v2+ enhancement
per the spec graph's list-based renderer.)
- PL-004 Phase D's workflow-runtime / workflow-spec-cache /
workflow-projector / workflow-instance-store / workflow-step-trail-log
— read-only consumer; no modifications.
- All existing migrations on main — untouched. v1 adds NO migrations.
- All event bus types — no extensions. v1 reads existing events; does
not emit new ones.
The one v0 deletion: classifyPhase() heuristic + hardcoded RSI-v2
phase taxonomy in projector + StoryTab — replaced by spec-driven
trail mapping (when bound) or null (when unbound). Recorded
explicitly in commit per PRD § Write Set § No-Touch Surfaces.
Operator dogfood readiness (QA-owned per
feedback_qa_owns_dogfood_fix_loop.md):
1. Instantiate RSI v2 starter via `rig workflow instantiate
--spec-path <pkg>/dist/builtins/workflow-specs/rsi-v2-hot-potato.yaml
--root-objective "v1 dogfood" --created-by-session <my> ...`
2. Trigger queue activity touching the resulting workflow_instance's
qitems (any `rigx queue handoff` between the spec's roles).
3. Open the Slices sidebar, click the slice that intersects those
qitems → all six tabs should render the v1 enrichment:
- Topology: spec graph with 5 RSI v2 steps + loop-back edge
- Story: events grouped by Discovery/Product Lab/Delivery/
Lifecycle/QA spec phases (NOT v0 hardcoded names)
- Acceptance: Current Step panel above the PROGRESS.md
checkbox list with allowed exits + next steps
4. Open an unbound slice (no workflow_instance touches its qitems)
→ fallbacks: Topology shows per-rig listing, Story renders
ungrouped chronological, Acceptance shows checkbox list only.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
2e9b821443
commit
c24bc35938
14 files changed
+1813
-118
No files matched your search
@@ -1,25 +1,49 @@
|
||||
// Slice Story View v0 — per-tab payload projector.
|
||||
// Slice Story View v0 + v1 — per-tab payload projector.
|
||||
//
|
||||
// Given a SliceRecord (from SliceIndexer), this assembles the full
|
||||
// per-slice payload covering all six tabs: Story, Acceptance, Decisions,
|
||||
// Docs, Tests/Verification, Topology. Read-only; no mutations. Composes
|
||||
// already-shipped tables (queue_items, queue_transitions,
|
||||
// mission_control_actions) + slice docs on disk + dogfood-evidence.
|
||||
// mission_control_actions, workflow_specs, workflow_instances,
|
||||
// workflow_step_trails) + slice docs on disk + dogfood-evidence.
|
||||
//
|
||||
// Topology shape at v0 is per-rig session-name groupings (NOT a full
|
||||
// rendered subgraph — TopologyTab links through to the main topology
|
||||
// surface for the operator's deep-dive). Per PRD: "this tab is
|
||||
// read-only; clicking a node from here takes the operator to the
|
||||
// regular topology / node-drawer surface."
|
||||
// v1 enrichment (per slices/slice-story-view-v1/IMPLEMENTATION-PRD.md):
|
||||
// when a workflow_instance is bound to the slice, four dimensions
|
||||
// activate — spec-graph topology, spec-driven phase tagging, current-
|
||||
// step + allowed exits in Acceptance, routing-type edge metadata
|
||||
// (default `direct` only at v1 per audit-row-6 carve-out).
|
||||
//
|
||||
// v0 hardcoded RSI-v2 phase taxonomy ("discovery"/"product-lab"/
|
||||
// "delivery"/"lifecycle"/"qa"/"other") is REMOVED at v1 per PRD § Write
|
||||
// Set Sketch. StoryEvent.phase is now an open-ended string-or-null:
|
||||
// when bound to a workflow_instance, the value is the spec-defined
|
||||
// step.id; otherwise null (UI groups under "Untagged").
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { SliceIndexer, SliceRecord, SliceProofPacket } from "./slice-indexer.js";
|
||||
import type { WorkflowSpecCache } from "../workflow-spec-cache.js";
|
||||
import {
|
||||
findSliceWorkflowBinding,
|
||||
type SliceWorkflowBinding,
|
||||
} from "../workflow/slice-workflow-binding.js";
|
||||
import {
|
||||
projectSpecGraph,
|
||||
projectPhaseDefinitions,
|
||||
projectCurrentStep,
|
||||
type SpecGraphPayload,
|
||||
type PhaseDefinition,
|
||||
type CurrentStepPayload,
|
||||
} from "../workflow/slice-workflow-projection.js";
|
||||
|
||||
export interface StoryEvent {
|
||||
ts: string;
|
||||
phase: "discovery" | "product-lab" | "delivery" | "lifecycle" | "qa" | "other";
|
||||
/** Spec-defined step.id when the event traces back through a workflow
|
||||
* step trail; null when the event is untagged (no trail mapping or
|
||||
* the slice has no bound workflow_instance). v1 removed the v0
|
||||
* hardcoded RSI-v2 phase enum. */
|
||||
phase: string | null;
|
||||
kind: string;
|
||||
actorSession: string | null;
|
||||
qitemId: string | null;
|
||||
@@ -39,6 +63,10 @@ export interface AcceptancePayload {
|
||||
percentage: number;
|
||||
items: AcceptanceItem[];
|
||||
closureCallout: string | null;
|
||||
/** v1 dimension #3: bound workflow_instance's current step + the
|
||||
* spec-declared allowed next steps. Null when the slice has no
|
||||
* bound instance (UI falls back to PROGRESS.md checkbox view only). */
|
||||
currentStep: CurrentStepPayload | null;
|
||||
}
|
||||
|
||||
export interface DecisionRow {
|
||||
@@ -87,6 +115,26 @@ export interface TopologyPayload {
|
||||
affectedRigs: TopologyRigEntry[];
|
||||
/** Total unique seats touching the slice across all rigs. */
|
||||
totalSeats: number;
|
||||
/** v1 dimension #1: workflow_spec graph (nodes + edges) when the
|
||||
* slice is bound to a workflow_instance. Null when unbound (UI falls
|
||||
* back to the v0 per-rig session listing). */
|
||||
specGraph: SpecGraphPayload | null;
|
||||
}
|
||||
|
||||
export interface WorkflowBindingPayload {
|
||||
instanceId: string;
|
||||
workflowName: string;
|
||||
workflowVersion: string;
|
||||
status: string;
|
||||
currentStepId: string | null;
|
||||
currentFrontier: string[];
|
||||
hopCount: number;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
/** Other workflow_instances also touching this slice's qitem set;
|
||||
* v1 picks the most recent as the primary binding (per PRD), and
|
||||
* exposes the rest here so the UI can render a "+N more" indicator. */
|
||||
additionalInstanceIds: string[];
|
||||
}
|
||||
|
||||
export interface SliceDetailPayload {
|
||||
@@ -98,7 +146,15 @@ export interface SliceDetailPayload {
|
||||
qitemIds: string[];
|
||||
commitRefs: string[];
|
||||
lastActivityAt: string | null;
|
||||
story: { events: StoryEvent[] };
|
||||
/** v1: bound workflow_instance metadata (most-recent if multiple),
|
||||
* null when no workflow_instance touches this slice's qitems. */
|
||||
workflowBinding: WorkflowBindingPayload | null;
|
||||
story: {
|
||||
events: StoryEvent[];
|
||||
/** v1 dimension #2: spec-declared phase definitions when bound;
|
||||
* null when unbound (UI falls back to ungrouped chronological). */
|
||||
phaseDefinitions: PhaseDefinition[] | null;
|
||||
};
|
||||
acceptance: AcceptancePayload;
|
||||
decisions: { rows: DecisionRow[] };
|
||||
docs: { tree: DocsTreeEntry[] };
|
||||
@@ -109,18 +165,37 @@ export interface SliceDetailPayload {
|
||||
export interface SliceDetailProjectorOpts {
|
||||
db: Database.Database;
|
||||
indexer: SliceIndexer;
|
||||
/** v1: optional WorkflowSpecCache used to resolve the bound
|
||||
* workflow_instance's spec for spec-graph + phase + current-step
|
||||
* projection. When omitted, the projector silently degrades to v0
|
||||
* behavior (everything renders as before; v1 fields are null). */
|
||||
workflowSpecCache?: WorkflowSpecCache;
|
||||
}
|
||||
|
||||
export class SliceDetailProjector {
|
||||
private readonly db: Database.Database;
|
||||
private readonly indexer: SliceIndexer;
|
||||
private readonly workflowSpecCache: WorkflowSpecCache | undefined;
|
||||
|
||||
constructor(opts: SliceDetailProjectorOpts) {
|
||||
this.db = opts.db;
|
||||
this.indexer = opts.indexer;
|
||||
this.workflowSpecCache = opts.workflowSpecCache;
|
||||
}
|
||||
|
||||
project(slice: SliceRecord): SliceDetailPayload {
|
||||
// v1: resolve workflow_instance binding once; the bound spec drives
|
||||
// four downstream dimensions (story phase tags, spec graph, current
|
||||
// step, phase definitions). When unbound or when the spec is no
|
||||
// longer cached (operator deleted the spec file, etc.), all v1
|
||||
// fields return null and v0 fallbacks apply.
|
||||
const bindingResult = findSliceWorkflowBinding(this.db, slice.qitemIds);
|
||||
const binding = bindingResult.primary;
|
||||
const spec = binding && this.workflowSpecCache
|
||||
? this.tryGetSpec(binding.workflowName, binding.workflowVersion)
|
||||
: null;
|
||||
const trailQitemToStep = binding ? this.buildTrailQitemToStepMap(binding.instanceId) : new Map<string, string>();
|
||||
|
||||
return {
|
||||
name: slice.name,
|
||||
displayName: slice.displayName,
|
||||
@@ -130,19 +205,88 @@ export class SliceDetailProjector {
|
||||
qitemIds: slice.qitemIds,
|
||||
commitRefs: slice.commitRefs,
|
||||
lastActivityAt: slice.lastActivityAt,
|
||||
story: { events: this.buildStory(slice) },
|
||||
acceptance: this.buildAcceptance(slice),
|
||||
workflowBinding: binding ? {
|
||||
instanceId: binding.instanceId,
|
||||
workflowName: binding.workflowName,
|
||||
workflowVersion: binding.workflowVersion,
|
||||
status: binding.status,
|
||||
currentStepId: binding.currentStepId,
|
||||
currentFrontier: binding.currentFrontier,
|
||||
hopCount: binding.hopCount,
|
||||
createdAt: binding.createdAt,
|
||||
completedAt: binding.completedAt,
|
||||
additionalInstanceIds: bindingResult.additionalInstanceIds,
|
||||
} : null,
|
||||
story: {
|
||||
events: this.buildStory(slice, trailQitemToStep),
|
||||
phaseDefinitions: spec ? projectPhaseDefinitions(spec) : null,
|
||||
},
|
||||
acceptance: {
|
||||
...this.buildAcceptance(slice),
|
||||
currentStep: spec && binding
|
||||
? projectCurrentStep(spec, binding.currentStepId, binding.hopCount, binding.status)
|
||||
: null,
|
||||
},
|
||||
decisions: { rows: this.buildDecisions(slice) },
|
||||
docs: { tree: this.buildDocsTree(slice) },
|
||||
tests: this.buildTests(slice.proofPacket),
|
||||
topology: this.buildTopology(slice),
|
||||
topology: {
|
||||
...this.buildTopology(slice),
|
||||
specGraph: spec && binding ? projectSpecGraph(spec, binding.currentStepId) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- v1 helpers ---
|
||||
|
||||
private tryGetSpec(name: string, version: string) {
|
||||
if (!this.workflowSpecCache) return null;
|
||||
try {
|
||||
const row = this.workflowSpecCache.getByNameVersion(name, version);
|
||||
return row?.spec ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a qitem_id → step_id map by reading the bound instance's
|
||||
* workflow_step_trails. Each trail row's prior_qitem_id maps to the
|
||||
* step that closed (`step_id`), and next_qitem_id maps to the
|
||||
* subsequent step's first packet. Used by buildStory to tag each
|
||||
* StoryEvent with the spec-defined phase the qitem traces back to.
|
||||
*/
|
||||
private buildTrailQitemToStepMap(instanceId: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
try {
|
||||
const trails = this.db.prepare(
|
||||
`SELECT step_id, prior_qitem_id, next_qitem_id
|
||||
FROM workflow_step_trails
|
||||
WHERE instance_id = ?`
|
||||
).all(instanceId) as Array<{
|
||||
step_id: string;
|
||||
prior_qitem_id: string;
|
||||
next_qitem_id: string | null;
|
||||
}>;
|
||||
for (const t of trails) {
|
||||
// The prior_qitem_id is the one that closed AT step_id —
|
||||
// events for that qitem belong to step_id's phase.
|
||||
map.set(t.prior_qitem_id, t.step_id);
|
||||
}
|
||||
} catch {
|
||||
// workflow_step_trails absent — empty map (events untagged).
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- Story tab ---
|
||||
|
||||
private buildStory(slice: SliceRecord): StoryEvent[] {
|
||||
private buildStory(slice: SliceRecord, trailQitemToStep: Map<string, string>): StoryEvent[] {
|
||||
const events: StoryEvent[] = [];
|
||||
// v1: phase tag = spec step.id from trail mapping (when bound),
|
||||
// null otherwise. v0's hardcoded RSI-v2 phase taxonomy is gone.
|
||||
const phaseFor = (qitemId: string | null): string | null =>
|
||||
qitemId ? trailQitemToStep.get(qitemId) ?? null : null;
|
||||
|
||||
if (slice.qitemIds.length > 0) {
|
||||
const placeholders = slice.qitemIds.map(() => "?").join(",");
|
||||
@@ -161,7 +305,7 @@ export class SliceDetailProjector {
|
||||
for (const r of qrows) {
|
||||
events.push({
|
||||
ts: r.ts_created,
|
||||
phase: this.classifyPhase(r.destination_session),
|
||||
phase: phaseFor(r.qitem_id),
|
||||
kind: "queue.created",
|
||||
actorSession: r.source_session,
|
||||
qitemId: r.qitem_id,
|
||||
@@ -174,8 +318,7 @@ export class SliceDetailProjector {
|
||||
}
|
||||
|
||||
// queue_transitions (per-state-change log; append-only per PL-004
|
||||
// Phase A schema). Columns: transition_id, qitem_id, ts, state,
|
||||
// transition_note, actor_session, closure_reason, closure_target.
|
||||
// Phase A schema).
|
||||
try {
|
||||
const trows = this.db.prepare(
|
||||
`SELECT qitem_id, ts, state, transition_note, actor_session, closure_reason
|
||||
@@ -190,7 +333,7 @@ export class SliceDetailProjector {
|
||||
const note = t.transition_note ?? t.closure_reason;
|
||||
events.push({
|
||||
ts: t.ts,
|
||||
phase: this.classifyPhase(t.actor_session),
|
||||
phase: phaseFor(t.qitem_id),
|
||||
kind: `transition.${t.state}`,
|
||||
actorSession: t.actor_session,
|
||||
qitemId: t.qitem_id,
|
||||
@@ -202,9 +345,7 @@ export class SliceDetailProjector {
|
||||
// queue_transitions absent — skip
|
||||
}
|
||||
|
||||
// mission_control_actions (operator verbs; PL-005 Phase A migration
|
||||
// 037). Columns: action_id, action_verb, qitem_id, actor_session,
|
||||
// acted_at, before_state_json, after_state_json, reason, annotation, ...
|
||||
// mission_control_actions (operator verbs; PL-005 Phase A migration 037).
|
||||
try {
|
||||
const arows = this.db.prepare(
|
||||
`SELECT action_id, acted_at, qitem_id, action_verb, actor_session,
|
||||
@@ -221,7 +362,7 @@ export class SliceDetailProjector {
|
||||
const note = a.annotation ?? a.reason;
|
||||
events.push({
|
||||
ts: a.acted_at,
|
||||
phase: this.classifyPhase(a.actor_session),
|
||||
phase: phaseFor(a.qitem_id),
|
||||
kind: `mission_control.${a.action_verb}`,
|
||||
actorSession: a.actor_session,
|
||||
qitemId: a.qitem_id,
|
||||
@@ -234,7 +375,9 @@ export class SliceDetailProjector {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice doc edits (mtimes inside the slice folder).
|
||||
// Slice doc edits (mtimes inside the slice folder). v1: no phase tag
|
||||
// — these aren't qitem-bound so they don't trace to a spec step.
|
||||
// UI will group them under "Untagged" or render them without a tag.
|
||||
try {
|
||||
const sliceDir = path.join(this.indexer.slicesRoot, slice.name);
|
||||
const docEntries = fs.readdirSync(sliceDir, { withFileTypes: true });
|
||||
@@ -243,7 +386,7 @@ export class SliceDetailProjector {
|
||||
const st = fs.statSync(path.join(sliceDir, entry.name));
|
||||
events.push({
|
||||
ts: st.mtime.toISOString(),
|
||||
phase: "discovery",
|
||||
phase: null,
|
||||
kind: "doc.edited",
|
||||
actorSession: null,
|
||||
qitemId: null,
|
||||
@@ -256,10 +399,11 @@ export class SliceDetailProjector {
|
||||
}
|
||||
|
||||
// Proof packet emission — single event per packet using directory mtime.
|
||||
// v1: also untagged (not qitem-bound).
|
||||
if (slice.proofPacket) {
|
||||
events.push({
|
||||
ts: slice.proofPacket.mtime,
|
||||
phase: "qa",
|
||||
phase: null,
|
||||
kind: "proof_packet.emitted",
|
||||
actorSession: null,
|
||||
qitemId: null,
|
||||
@@ -278,7 +422,7 @@ export class SliceDetailProjector {
|
||||
|
||||
// --- Acceptance tab ---
|
||||
|
||||
private buildAcceptance(slice: SliceRecord): AcceptancePayload {
|
||||
private buildAcceptance(slice: SliceRecord): Omit<AcceptancePayload, "currentStep"> {
|
||||
const items: AcceptanceItem[] = [];
|
||||
// Parse README + IMPLEMENTATION-PRD + PROGRESS.md for [ ]/[x] checkbox lines.
|
||||
// Source citation = file + 1-based line number so the operator can jump.
|
||||
@@ -473,7 +617,7 @@ export class SliceDetailProjector {
|
||||
|
||||
// --- Topology tab ---
|
||||
|
||||
private buildTopology(slice: SliceRecord): TopologyPayload {
|
||||
private buildTopology(slice: SliceRecord): Omit<TopologyPayload, "specGraph"> {
|
||||
if (slice.qitemIds.length === 0) {
|
||||
return { affectedRigs: [], totalSeats: 0 };
|
||||
}
|
||||
@@ -525,18 +669,13 @@ export class SliceDetailProjector {
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private classifyPhase(session: string | null | undefined): StoryEvent["phase"] {
|
||||
if (!session) return "other";
|
||||
const lower = session.toLowerCase();
|
||||
if (lower.includes("product-lab") || lower.includes("planner")) return "product-lab";
|
||||
if (lower.includes("intake") || lower.includes("steward") || lower.includes("discovery")) return "discovery";
|
||||
if (lower.includes("qa")) return "qa";
|
||||
if (lower.includes("velocity") || lower.includes("orch") || lower.includes("driver") || lower.includes("guard")) return "delivery";
|
||||
if (lower.includes("kernel") || lower.includes("life") || lower.includes("supervisor")) return "lifecycle";
|
||||
return "other";
|
||||
}
|
||||
// v1: classifyPhase() heuristic removed — phase tagging is now
|
||||
// spec-driven (via workflow_step_trails join in buildTrailQitemToStepMap)
|
||||
// or null (untagged) when no workflow_instance is bound. The v0
|
||||
// hardcoded RSI-v2 phase taxonomy ("discovery"/"product-lab"/"delivery"/
|
||||
// "lifecycle"/"qa"/"other" inferred from session-name substrings) is
|
||||
// gone. Per slices/slice-story-view-v1/IMPLEMENTATION-PRD.md § Write
|
||||
// Set Sketch — explicit deletion noted in commit message.
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Slice Story View v1 — slice → workflow_instance binding helper.
|
||||
//
|
||||
// Given a slice's qitem set, find the workflow_instance(s) that touch
|
||||
// any of those qitems. Two signals (UNION):
|
||||
//
|
||||
// 1. workflow_step_trails.prior_qitem_id IN (slice qitems) -- step closure
|
||||
// OR workflow_step_trails.next_qitem_id IN (slice qitems) -- step projection
|
||||
// → instance has historically touched this slice
|
||||
// 2. workflow_instances.current_frontier_json LIKE '%qitemId%'
|
||||
// → instance is currently active on a slice qitem
|
||||
//
|
||||
// Returns the instance id(s) sorted by created_at DESC. v1 picks the
|
||||
// most-recent instance when multiple bind to the slice (per PRD: "when
|
||||
// a slice has more than one workflow_instance, v1 picks the most recent
|
||||
// or surfaces a 'multiple instances' indicator; driver picks the exact
|
||||
// UX with founder review at audit time"). The picked-most-recent
|
||||
// behavior is the operator-friendly default; the multiple-instance
|
||||
// indicator is exposed via the `additionalInstanceIds` field so the UI
|
||||
// can surface a "+N more" hint without losing data.
|
||||
//
|
||||
// MVP single-host context: this query runs ad-hoc per slice detail
|
||||
// fetch; not cached. The detail projector itself caches at the slice
|
||||
// indexer's TTL boundary.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export interface SliceWorkflowBinding {
|
||||
/** The most recent workflow_instance touching the slice's qitem set. */
|
||||
instanceId: string;
|
||||
workflowName: string;
|
||||
workflowVersion: string;
|
||||
status: string;
|
||||
currentStepId: string | null;
|
||||
/** Frontier qitem_ids parsed from the JSON column. */
|
||||
currentFrontier: string[];
|
||||
hopCount: number;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SliceWorkflowBindingResult {
|
||||
primary: SliceWorkflowBinding | null;
|
||||
/** Other instances touching the slice; surfaced for "+N more" UI. */
|
||||
additionalInstanceIds: string[];
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
instance_id: string;
|
||||
workflow_name: string;
|
||||
workflow_version: string;
|
||||
status: string;
|
||||
current_frontier_json: string;
|
||||
current_step_id: string | null;
|
||||
hop_count: number;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export function findSliceWorkflowBinding(
|
||||
db: Database.Database,
|
||||
qitemIds: string[],
|
||||
): SliceWorkflowBindingResult {
|
||||
if (qitemIds.length === 0) return { primary: null, additionalInstanceIds: [] };
|
||||
|
||||
const instanceIds = new Set<string>();
|
||||
|
||||
// Signal 1: trails referencing slice qitems via prior_qitem_id or next_qitem_id.
|
||||
try {
|
||||
const placeholders = qitemIds.map(() => "?").join(",");
|
||||
const trailRows = db.prepare(
|
||||
`SELECT DISTINCT instance_id FROM workflow_step_trails
|
||||
WHERE prior_qitem_id IN (${placeholders})
|
||||
OR next_qitem_id IN (${placeholders})`
|
||||
).all(...qitemIds, ...qitemIds) as Array<{ instance_id: string }>;
|
||||
for (const r of trailRows) instanceIds.add(r.instance_id);
|
||||
} catch {
|
||||
// workflow_step_trails absent — skip
|
||||
}
|
||||
|
||||
// Signal 2: live frontier on a slice qitem (active step packet).
|
||||
// current_frontier_json is a JSON array of strings; LIKE '%"qitem"%'
|
||||
// is a cheap heuristic that matches the JSON-encoded form. False
|
||||
// positives possible if a qitem id appears as a substring of an
|
||||
// unrelated id, but ULID prefix discipline (timestamps + random) makes
|
||||
// collisions astronomically unlikely at single-host MVP scale.
|
||||
try {
|
||||
for (const qid of qitemIds) {
|
||||
const liveRows = db.prepare(
|
||||
`SELECT instance_id FROM workflow_instances
|
||||
WHERE current_frontier_json LIKE ?`
|
||||
).all(`%${qid}%`) as Array<{ instance_id: string }>;
|
||||
for (const r of liveRows) instanceIds.add(r.instance_id);
|
||||
}
|
||||
} catch {
|
||||
// workflow_instances absent — skip
|
||||
}
|
||||
|
||||
if (instanceIds.size === 0) return { primary: null, additionalInstanceIds: [] };
|
||||
|
||||
// Resolve full rows; sort by created_at DESC; pick most recent as primary.
|
||||
const idList = Array.from(instanceIds);
|
||||
const idPlaceholders = idList.map(() => "?").join(",");
|
||||
let rows: InstanceRow[] = [];
|
||||
try {
|
||||
rows = db.prepare(
|
||||
`SELECT instance_id, workflow_name, workflow_version, status,
|
||||
current_frontier_json, current_step_id, hop_count,
|
||||
created_at, completed_at
|
||||
FROM workflow_instances
|
||||
WHERE instance_id IN (${idPlaceholders})
|
||||
ORDER BY created_at DESC, instance_id DESC`
|
||||
).all(...idList) as InstanceRow[];
|
||||
} catch {
|
||||
return { primary: null, additionalInstanceIds: [] };
|
||||
}
|
||||
|
||||
if (rows.length === 0) return { primary: null, additionalInstanceIds: [] };
|
||||
|
||||
const primary = rowToBinding(rows[0]!);
|
||||
const additional = rows.slice(1).map((r) => r.instance_id);
|
||||
return { primary, additionalInstanceIds: additional };
|
||||
}
|
||||
|
||||
function rowToBinding(row: InstanceRow): SliceWorkflowBinding {
|
||||
let frontier: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.current_frontier_json);
|
||||
if (Array.isArray(parsed)) frontier = parsed.filter((x): x is string => typeof x === "string");
|
||||
} catch {
|
||||
// malformed JSON — empty frontier (instance is in a degraded state;
|
||||
// the v1 UI will still render the bound workflow_name + status).
|
||||
}
|
||||
return {
|
||||
instanceId: row.instance_id,
|
||||
workflowName: row.workflow_name,
|
||||
workflowVersion: row.workflow_version,
|
||||
status: row.status,
|
||||
currentStepId: row.current_step_id,
|
||||
currentFrontier: frontier,
|
||||
hopCount: row.hop_count,
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Slice Story View v1 — workflow_spec → per-tab payload projection.
|
||||
//
|
||||
// Given a SliceWorkflowBinding (the resolved instance) and the bound
|
||||
// WorkflowSpec (read through Phase D's WorkflowSpecCache), project the
|
||||
// four v1-floor dimensions:
|
||||
//
|
||||
// 1. specGraph — nodes (one per step) + edges (from each step's
|
||||
// next_hop.suggested_roles → next-step ids). All edges carry
|
||||
// routingType: "direct" because Phase D's spec format does NOT
|
||||
// yet have a routing_type field (per audit row 6 carve-out:
|
||||
// dimension #4 ships as default-styled solid edges only;
|
||||
// routing-type metadata is v2+).
|
||||
// 2. phaseDefinitions — step.id → { label, role } so the Story tab
|
||||
// can group events by spec-declared phase tags. Replaces v0's
|
||||
// hardcoded RSI-v2 phase taxonomy with spec-driven mapping.
|
||||
// 3. currentStep — bound instance's current_step_id resolved against
|
||||
// the spec, returning step.objective + allowed_exits + the
|
||||
// enumerated next-step destinations from next_hop.
|
||||
// 4. eventPhaseFor(stepId) — convenience used by SliceDetailProjector
|
||||
// to tag each StoryEvent with the spec's phase id when the event
|
||||
// can be mapped to a step (via workflow_step_trails join). When
|
||||
// there is no mapping, the event stays untagged (UI renders it in
|
||||
// the "Other" or "Untagged" group).
|
||||
|
||||
import type { WorkflowSpec, WorkflowStepSpec } from "../workflow-types.js";
|
||||
|
||||
export interface SpecGraphNode {
|
||||
/** step.id (e.g., "discovery"). */
|
||||
stepId: string;
|
||||
/** Display label = step.role title with id fallback. */
|
||||
label: string;
|
||||
/** step.actor_role (e.g., "discovery-router"). */
|
||||
role: string;
|
||||
/** First preferred_target seat for this role, when declared. UI uses
|
||||
* this to compose PL-019 activity indicators on the spec node. */
|
||||
preferredTarget: string | null;
|
||||
/** True for the spec's entry step (the one workflow_instance starts on). */
|
||||
isEntry: boolean;
|
||||
/** True when this is the active step on the bound instance. */
|
||||
isCurrent: boolean;
|
||||
/** True when this step has no next_hop pointing forward (possible terminal). */
|
||||
isTerminal: boolean;
|
||||
}
|
||||
|
||||
export interface SpecGraphEdge {
|
||||
fromStepId: string;
|
||||
toStepId: string;
|
||||
/** "direct" only at v1 — Phase D's spec format does not carry
|
||||
* routing_type yet (audit row 6 carve-out). */
|
||||
routingType: "direct";
|
||||
/** True when this edge is the loop-back edge (target step earlier in
|
||||
* declared order than source). UI uses this to optionally curve the
|
||||
* edge differently from forward edges. */
|
||||
isLoopBack: boolean;
|
||||
}
|
||||
|
||||
export interface SpecGraphPayload {
|
||||
specName: string;
|
||||
specVersion: string;
|
||||
nodes: SpecGraphNode[];
|
||||
edges: SpecGraphEdge[];
|
||||
}
|
||||
|
||||
export interface PhaseDefinition {
|
||||
/** step.id (the canonical phase tag). */
|
||||
id: string;
|
||||
/** Human label = role title fallback to step.id. */
|
||||
label: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface CurrentStepPayload {
|
||||
stepId: string;
|
||||
role: string;
|
||||
objective: string | null;
|
||||
/** Phase D's WorkflowStepSpec.allowed_exits enum subset. */
|
||||
allowedExits: string[];
|
||||
/** Resolved next-step destinations from next_hop.suggested_roles
|
||||
* mapped back to step ids. Empty array when no next_hop or no roles. */
|
||||
allowedNextSteps: Array<{ stepId: string; role: string; reason: "next_hop" }>;
|
||||
hopCount: number;
|
||||
instanceStatus: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the spec graph for the Topology tab. Nodes are ordered by
|
||||
* the spec's declared step order; edges are derived from each step's
|
||||
* next_hop.suggested_roles list resolved back to step ids.
|
||||
*/
|
||||
export function projectSpecGraph(
|
||||
spec: WorkflowSpec,
|
||||
currentStepId: string | null,
|
||||
): SpecGraphPayload {
|
||||
const declaredOrder = spec.steps.map((s) => s.id);
|
||||
const stepByRole = new Map<string, WorkflowStepSpec>();
|
||||
for (const step of spec.steps) {
|
||||
if (!stepByRole.has(step.actor_role)) stepByRole.set(step.actor_role, step);
|
||||
}
|
||||
|
||||
const entryRole = spec.entry?.role ?? spec.steps[0]?.actor_role;
|
||||
const entryStepId = entryRole ? stepByRole.get(entryRole)?.id : undefined;
|
||||
|
||||
const nodes: SpecGraphNode[] = spec.steps.map((step) => {
|
||||
const roleSpec = (spec.roles as Record<string, { preferred_targets?: string[] }>)[step.actor_role] ?? {};
|
||||
const firstTarget = roleSpec.preferred_targets?.[0] ?? null;
|
||||
return {
|
||||
stepId: step.id,
|
||||
label: step.actor_role,
|
||||
role: step.actor_role,
|
||||
preferredTarget: firstTarget,
|
||||
isEntry: step.id === entryStepId,
|
||||
isCurrent: step.id === currentStepId,
|
||||
isTerminal: !hasNextHop(step),
|
||||
};
|
||||
});
|
||||
|
||||
const edges: SpecGraphEdge[] = [];
|
||||
for (const step of spec.steps) {
|
||||
const suggestedRoles = step.next_hop?.suggested_roles ?? [];
|
||||
for (const role of suggestedRoles) {
|
||||
const target = stepByRole.get(role);
|
||||
if (!target) continue;
|
||||
const fromIdx = declaredOrder.indexOf(step.id);
|
||||
const toIdx = declaredOrder.indexOf(target.id);
|
||||
edges.push({
|
||||
fromStepId: step.id,
|
||||
toStepId: target.id,
|
||||
routingType: "direct",
|
||||
isLoopBack: toIdx >= 0 && fromIdx >= 0 && toIdx <= fromIdx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
specName: spec.id,
|
||||
specVersion: spec.version,
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the spec's phase definitions. Each step contributes one phase
|
||||
* (canonical tag = step.id; label = step.actor_role). The Story tab
|
||||
* groups events by phase id when a step trail exists for the event's
|
||||
* qitem; events without a trail mapping render in an "untagged" group
|
||||
* (the UI decides the literal label).
|
||||
*/
|
||||
export function projectPhaseDefinitions(spec: WorkflowSpec): PhaseDefinition[] {
|
||||
return spec.steps.map((step) => ({
|
||||
id: step.id,
|
||||
label: step.actor_role,
|
||||
role: step.actor_role,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the Current Step payload from the spec + the bound instance's
|
||||
* current_step_id. Returns null when current_step_id is null (terminal
|
||||
* instance) or doesn't resolve against the spec (instance instantiated
|
||||
* against a different spec version that has since changed shape — UI
|
||||
* surfaces this as "current step unknown").
|
||||
*/
|
||||
export function projectCurrentStep(
|
||||
spec: WorkflowSpec,
|
||||
currentStepId: string | null,
|
||||
hopCount: number,
|
||||
instanceStatus: string,
|
||||
): CurrentStepPayload | null {
|
||||
if (!currentStepId) return null;
|
||||
const step = spec.steps.find((s) => s.id === currentStepId);
|
||||
if (!step) return null;
|
||||
const stepByRole = new Map<string, WorkflowStepSpec>();
|
||||
for (const s of spec.steps) {
|
||||
if (!stepByRole.has(s.actor_role)) stepByRole.set(s.actor_role, s);
|
||||
}
|
||||
const allowedNextSteps: CurrentStepPayload["allowedNextSteps"] = [];
|
||||
for (const role of step.next_hop?.suggested_roles ?? []) {
|
||||
const target = stepByRole.get(role);
|
||||
if (!target) continue;
|
||||
allowedNextSteps.push({ stepId: target.id, role, reason: "next_hop" });
|
||||
}
|
||||
return {
|
||||
stepId: step.id,
|
||||
role: step.actor_role,
|
||||
objective: step.objective ?? null,
|
||||
allowedExits: [...(step.allowed_exits ?? [])],
|
||||
allowedNextSteps,
|
||||
hopCount,
|
||||
instanceStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNextHop(step: WorkflowStepSpec): boolean {
|
||||
return (step.next_hop?.suggested_roles ?? []).length > 0;
|
||||
}
|
||||
@@ -712,7 +712,18 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
dogfoodEvidenceRoot: dogfoodRoot || null,
|
||||
db,
|
||||
});
|
||||
const sliceDetailProjector = new SliceDetailProjector({ db, indexer: sliceIndexer });
|
||||
// Slice Story View v1: pass workflowRuntime.specCache so the
|
||||
// projector can resolve a bound workflow_instance's spec for
|
||||
// spec-graph + phase + current-step projection. When the workflow
|
||||
// runtime is not constructed (queueRepo absent — same condition
|
||||
// already guards the workflowRuntime block above), the projector
|
||||
// silently degrades to v0 behavior (workflowBinding=null,
|
||||
// specGraph=null, phaseDefinitions=null, currentStep=null).
|
||||
const sliceDetailProjector = new SliceDetailProjector({
|
||||
db,
|
||||
indexer: sliceIndexer,
|
||||
workflowSpecCache: workflowRuntime?.specCache,
|
||||
});
|
||||
deps.sliceIndexer = sliceIndexer;
|
||||
deps.sliceDetailProjector = sliceDetailProjector;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// Slice Story View v1 — end-to-end projector test.
|
||||
//
|
||||
// Drives SliceDetailProjector against a temp slice fixture + a wired
|
||||
// workflow_specs row + a bound workflow_instance with trail rows.
|
||||
// Pins:
|
||||
// - workflowBinding populated when an instance touches slice qitems
|
||||
// - story.phaseDefinitions matches the bound spec
|
||||
// - acceptance.currentStep populated from the bound instance
|
||||
// - topology.specGraph populated when bound
|
||||
// - story.events have phase tags from the trail map (not the v0
|
||||
// hardcoded RSI-v2 enum)
|
||||
// - all v1 fields are null when no instance is bound (v0 fallback)
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "../src/db/migrations/035_workflow_step_trails.js";
|
||||
import { missionControlActionsSchema } from "../src/db/migrations/037_mission_control_actions.js";
|
||||
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
|
||||
import { SliceDetailProjector } from "../src/domain/slices/slice-detail-projector.js";
|
||||
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
|
||||
|
||||
const SAMPLE_SPEC = `workflow:
|
||||
id: test-loop
|
||||
version: "1"
|
||||
objective: 4-step test loop
|
||||
entry:
|
||||
role: a
|
||||
invariants:
|
||||
allowed_exits: [handoff, waiting, done, failed]
|
||||
roles:
|
||||
a:
|
||||
preferred_targets: [a@r]
|
||||
b:
|
||||
preferred_targets: [b@r]
|
||||
c:
|
||||
preferred_targets: [c@r]
|
||||
d:
|
||||
preferred_targets: [d@r]
|
||||
steps:
|
||||
- id: step-a
|
||||
actor_role: a
|
||||
allowed_exits: [handoff]
|
||||
next_hop:
|
||||
suggested_roles: [b]
|
||||
- id: step-b
|
||||
actor_role: b
|
||||
allowed_exits: [handoff]
|
||||
next_hop:
|
||||
suggested_roles: [c]
|
||||
- id: step-c
|
||||
actor_role: c
|
||||
allowed_exits: [handoff]
|
||||
next_hop:
|
||||
suggested_roles: [d]
|
||||
- id: step-d
|
||||
actor_role: d
|
||||
allowed_exits: [done]
|
||||
`;
|
||||
|
||||
function writeSliceFolder(slicesRoot: string, name: string, frontmatter: Record<string, string>): void {
|
||||
const dir = join(slicesRoot, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const fmLines = ["---", ...Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`), "---", `# ${name}`].join("\n");
|
||||
writeFileSync(join(dir, "README.md"), fmLines);
|
||||
}
|
||||
|
||||
function insertQitem(db: Database.Database, qitemId: string, body: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
|
||||
VALUES (?, '2026-05-04T00:00:00.000Z', '2026-05-04T00:00:00.000Z', 'src@r', 'dst@r', 'in-progress', 'routine', ?)`
|
||||
).run(qitemId, body);
|
||||
}
|
||||
|
||||
describe("PL-slice-story-view-v1 SliceDetailProjector — bound workflow_instance", () => {
|
||||
let db: Database.Database;
|
||||
let slicesRoot: string;
|
||||
let cleanupRoot: string;
|
||||
let indexer: SliceIndexer;
|
||||
let cache: WorkflowSpecCache;
|
||||
let projector: SliceDetailProjector;
|
||||
let specPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, eventsSchema, streamItemsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
missionControlActionsSchema,
|
||||
]);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
cleanupRoot = mkdtempSync(join(tmpdir(), "slice-projector-v1-"));
|
||||
slicesRoot = join(cleanupRoot, "slices");
|
||||
mkdirSync(slicesRoot, { recursive: true });
|
||||
indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
|
||||
cache = new WorkflowSpecCache(db);
|
||||
// Seed the spec into the cache via a real workspace path.
|
||||
specPath = join(cleanupRoot, "test-loop.yaml");
|
||||
writeFileSync(specPath, SAMPLE_SPEC);
|
||||
cache.readThrough(specPath);
|
||||
projector = new SliceDetailProjector({ db, indexer, workflowSpecCache: cache });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(cleanupRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function bindInstance(instanceId: string, qitemIds: string[], currentStepId: string | null): void {
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_instances (instance_id, workflow_name, workflow_version, created_by_session, created_at, status, current_frontier_json, current_step_id, hop_count)
|
||||
VALUES (?, 'test-loop', '1', 'creator@r', '2026-05-04T00:00:00.000Z', 'active', ?, ?, 2)`
|
||||
).run(instanceId, JSON.stringify(qitemIds.length > 0 ? [qitemIds[qitemIds.length - 1]] : []), currentStepId);
|
||||
// Trail: each qitem closed at a sequential step.
|
||||
const stepOrder = ["step-a", "step-b", "step-c", "step-d"];
|
||||
qitemIds.forEach((qid, idx) => {
|
||||
const stepId = stepOrder[Math.min(idx, stepOrder.length - 1)]!;
|
||||
const role = stepId.replace("step-", "");
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_step_trails (trail_id, instance_id, step_id, step_role, closed_at, closure_reason, actor_session, prior_qitem_id, next_qitem_id)
|
||||
VALUES (?, ?, ?, ?, ?, 'handoff', 'actor@r', ?, ?)`
|
||||
).run(`trail-${idx}`, instanceId, stepId, role, `2026-05-04T0${idx}:00:00.000Z`, qid, qitemIds[idx + 1] ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
it("populates workflowBinding + all four v1 dimensions when an instance is bound to the slice", () => {
|
||||
writeSliceFolder(slicesRoot, "bound-slice", { slice: "bound-slice", "rail-item": "PL-test", status: "active" });
|
||||
insertQitem(db, "q-1", "bound-slice initial");
|
||||
insertQitem(db, "q-2", "bound-slice handoff");
|
||||
insertQitem(db, "q-3", "bound-slice qa");
|
||||
bindInstance("inst-bound", ["q-1", "q-2", "q-3"], "step-c");
|
||||
|
||||
const slice = indexer.get("bound-slice")!;
|
||||
const payload = projector.project(slice);
|
||||
|
||||
// workflowBinding present.
|
||||
expect(payload.workflowBinding).not.toBeNull();
|
||||
expect(payload.workflowBinding!.instanceId).toBe("inst-bound");
|
||||
expect(payload.workflowBinding!.workflowName).toBe("test-loop");
|
||||
expect(payload.workflowBinding!.currentStepId).toBe("step-c");
|
||||
|
||||
// Dimension #2: spec-driven phase definitions.
|
||||
expect(payload.story.phaseDefinitions).not.toBeNull();
|
||||
expect(payload.story.phaseDefinitions!.map((p) => p.id)).toEqual(["step-a", "step-b", "step-c", "step-d"]);
|
||||
|
||||
// Dimension #3: current step.
|
||||
expect(payload.acceptance.currentStep).not.toBeNull();
|
||||
expect(payload.acceptance.currentStep!.stepId).toBe("step-c");
|
||||
expect(payload.acceptance.currentStep!.role).toBe("c");
|
||||
expect(payload.acceptance.currentStep!.allowedExits).toEqual(["handoff"]);
|
||||
expect(payload.acceptance.currentStep!.allowedNextSteps).toEqual([
|
||||
{ stepId: "step-d", role: "d", reason: "next_hop" },
|
||||
]);
|
||||
|
||||
// Dimension #1: spec graph.
|
||||
expect(payload.topology.specGraph).not.toBeNull();
|
||||
expect(payload.topology.specGraph!.nodes).toHaveLength(4);
|
||||
expect(payload.topology.specGraph!.edges).toHaveLength(3);
|
||||
expect(payload.topology.specGraph!.nodes.find((n) => n.stepId === "step-c")?.isCurrent).toBe(true);
|
||||
});
|
||||
|
||||
it("story events get phase tags from the trail map (NOT from v0 hardcoded RSI-v2 heuristic)", () => {
|
||||
writeSliceFolder(slicesRoot, "phased-slice", { slice: "phased-slice", "rail-item": "PL-test" });
|
||||
insertQitem(db, "q-A", "phased-slice work A");
|
||||
insertQitem(db, "q-B", "phased-slice work B");
|
||||
bindInstance("inst-phased", ["q-A", "q-B"], "step-b");
|
||||
|
||||
const slice = indexer.get("phased-slice")!;
|
||||
const payload = projector.project(slice);
|
||||
|
||||
const phaseByQitem = new Map<string, string | null>();
|
||||
for (const event of payload.story.events) {
|
||||
if (event.qitemId) phaseByQitem.set(event.qitemId, event.phase);
|
||||
}
|
||||
// q-A closed at step-a per the seeded trail; q-B at step-b.
|
||||
expect(phaseByQitem.get("q-A")).toBe("step-a");
|
||||
expect(phaseByQitem.get("q-B")).toBe("step-b");
|
||||
});
|
||||
|
||||
it("non-qitem events (doc edits, proof packets) get phase=null (v1: untagged)", () => {
|
||||
writeSliceFolder(slicesRoot, "doc-slice", { slice: "doc-slice", "rail-item": "PL-test" });
|
||||
insertQitem(db, "q-x", "doc-slice");
|
||||
bindInstance("inst-doc", ["q-x"], "step-a");
|
||||
|
||||
const slice = indexer.get("doc-slice")!;
|
||||
const payload = projector.project(slice);
|
||||
|
||||
const docEvent = payload.story.events.find((e) => e.kind === "doc.edited");
|
||||
expect(docEvent).toBeDefined();
|
||||
expect(docEvent!.phase).toBeNull();
|
||||
});
|
||||
|
||||
it("all v1 fields are null when NO workflow_instance touches the slice qitems (v0 fallback)", () => {
|
||||
writeSliceFolder(slicesRoot, "unbound-slice", { slice: "unbound-slice", "rail-item": "PL-test" });
|
||||
insertQitem(db, "q-orphan", "unbound-slice has qitems but no workflow_instance");
|
||||
|
||||
const slice = indexer.get("unbound-slice")!;
|
||||
const payload = projector.project(slice);
|
||||
|
||||
expect(payload.workflowBinding).toBeNull();
|
||||
expect(payload.story.phaseDefinitions).toBeNull();
|
||||
expect(payload.acceptance.currentStep).toBeNull();
|
||||
expect(payload.topology.specGraph).toBeNull();
|
||||
|
||||
// v0 functionality intact.
|
||||
expect(payload.story.events.length).toBeGreaterThan(0);
|
||||
expect(payload.acceptance.totalItems).toBeDefined();
|
||||
});
|
||||
|
||||
it("projector silently degrades when constructed without a workflowSpecCache (v0 mode)", () => {
|
||||
const v0Projector = new SliceDetailProjector({ db, indexer });
|
||||
writeSliceFolder(slicesRoot, "v0-mode-slice", { slice: "v0-mode-slice", "rail-item": "PL-test" });
|
||||
insertQitem(db, "q-v0", "v0-mode-slice");
|
||||
bindInstance("inst-v0", ["q-v0"], "step-a");
|
||||
|
||||
const slice = indexer.get("v0-mode-slice")!;
|
||||
const payload = v0Projector.project(slice);
|
||||
|
||||
// workflowBinding is still populated (binding helper runs without spec cache),
|
||||
// but the spec-driven dimensions are null because there's no cache to resolve from.
|
||||
expect(payload.workflowBinding).not.toBeNull();
|
||||
expect(payload.story.phaseDefinitions).toBeNull();
|
||||
expect(payload.acceptance.currentStep).toBeNull();
|
||||
expect(payload.topology.specGraph).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// Slice Story View v1 — slice → workflow_instance binding helper.
|
||||
//
|
||||
// Pins the load-bearing behaviors of findSliceWorkflowBinding:
|
||||
//
|
||||
// - empty qitem set → no binding
|
||||
// - no instance touches the slice qitems → no binding
|
||||
// - trail signal: instance found via prior_qitem_id / next_qitem_id
|
||||
// - live frontier signal: instance found via current_frontier_json LIKE
|
||||
// - multiple instances bind: most-recent picked as primary; rest as
|
||||
// additionalInstanceIds
|
||||
// - terminal instance with empty frontier still binds via trail history
|
||||
// - empty frontier_json parses to []
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowInstancesSchema } from "../src/db/migrations/034_workflow_instances.js";
|
||||
import { workflowStepTrailsSchema } from "../src/db/migrations/035_workflow_step_trails.js";
|
||||
import { findSliceWorkflowBinding } from "../src/domain/workflow/slice-workflow-binding.js";
|
||||
|
||||
function insertInstance(db: Database.Database, opts: {
|
||||
instanceId: string;
|
||||
workflowName?: string;
|
||||
workflowVersion?: string;
|
||||
status?: string;
|
||||
currentFrontier?: string[];
|
||||
currentStepId?: string | null;
|
||||
hopCount?: number;
|
||||
createdAt?: string;
|
||||
}): void {
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_instances (
|
||||
instance_id, workflow_name, workflow_version,
|
||||
created_by_session, created_at, status,
|
||||
current_frontier_json, current_step_id, hop_count
|
||||
) VALUES (?, ?, ?, 'creator@r', ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
opts.instanceId,
|
||||
opts.workflowName ?? "rsi-v2-hot-potato",
|
||||
opts.workflowVersion ?? "1",
|
||||
opts.createdAt ?? "2026-05-04T00:00:00.000Z",
|
||||
opts.status ?? "active",
|
||||
JSON.stringify(opts.currentFrontier ?? []),
|
||||
opts.currentStepId ?? null,
|
||||
opts.hopCount ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function ensureQitem(db: Database.Database, qitemId: string): void {
|
||||
// workflow_step_trails has FK constraints to queue_items on both
|
||||
// prior_qitem_id and next_qitem_id; FK enforcement is on, so the
|
||||
// qitem rows have to exist before the trail rows can land.
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO queue_items
|
||||
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, body)
|
||||
VALUES (?, '2026-05-04T00:00:00.000Z', '2026-05-04T00:00:00.000Z', 'src@r', 'dst@r', 'in-progress', 'routine', 'fixture')`
|
||||
).run(qitemId);
|
||||
}
|
||||
|
||||
function insertTrail(db: Database.Database, opts: {
|
||||
trailId: string;
|
||||
instanceId: string;
|
||||
stepId: string;
|
||||
stepRole: string;
|
||||
closedAt?: string;
|
||||
closureReason?: string;
|
||||
actorSession?: string;
|
||||
priorQitemId: string;
|
||||
nextQitemId?: string | null;
|
||||
}): void {
|
||||
ensureQitem(db, opts.priorQitemId);
|
||||
if (opts.nextQitemId) ensureQitem(db, opts.nextQitemId);
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_step_trails (
|
||||
trail_id, instance_id, step_id, step_role,
|
||||
closed_at, closure_reason, actor_session,
|
||||
prior_qitem_id, next_qitem_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
opts.trailId,
|
||||
opts.instanceId,
|
||||
opts.stepId,
|
||||
opts.stepRole,
|
||||
opts.closedAt ?? "2026-05-04T01:00:00.000Z",
|
||||
opts.closureReason ?? "handoff",
|
||||
opts.actorSession ?? "actor@r",
|
||||
opts.priorQitemId,
|
||||
opts.nextQitemId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PL-slice-story-view-v1 findSliceWorkflowBinding", () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, eventsSchema, streamItemsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
]);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("returns no binding for empty qitem set", () => {
|
||||
expect(findSliceWorkflowBinding(db, [])).toEqual({ primary: null, additionalInstanceIds: [] });
|
||||
});
|
||||
|
||||
it("returns no binding when no instance touches the slice qitems", () => {
|
||||
insertInstance(db, { instanceId: "inst-other", currentFrontier: ["q-other"] });
|
||||
insertTrail(db, { trailId: "t1", instanceId: "inst-other", stepId: "discovery", stepRole: "discovery-router", priorQitemId: "q-other" });
|
||||
expect(findSliceWorkflowBinding(db, ["q-slice-1", "q-slice-2"])).toEqual({ primary: null, additionalInstanceIds: [] });
|
||||
});
|
||||
|
||||
it("trail signal: binds when prior_qitem_id is in slice qitems", () => {
|
||||
insertInstance(db, { instanceId: "inst-1", currentFrontier: [], currentStepId: "qa", hopCount: 4, status: "active" });
|
||||
insertTrail(db, { trailId: "t1", instanceId: "inst-1", stepId: "discovery", stepRole: "discovery-router", priorQitemId: "q-slice-1" });
|
||||
const result = findSliceWorkflowBinding(db, ["q-slice-1"]);
|
||||
expect(result.primary?.instanceId).toBe("inst-1");
|
||||
expect(result.primary?.workflowName).toBe("rsi-v2-hot-potato");
|
||||
expect(result.primary?.currentStepId).toBe("qa");
|
||||
expect(result.primary?.hopCount).toBe(4);
|
||||
expect(result.additionalInstanceIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("trail signal: binds when next_qitem_id is in slice qitems", () => {
|
||||
insertInstance(db, { instanceId: "inst-2", currentFrontier: [] });
|
||||
insertTrail(db, { trailId: "t2", instanceId: "inst-2", stepId: "delivery", stepRole: "delivery-driver", priorQitemId: "q-prior", nextQitemId: "q-slice-1" });
|
||||
const result = findSliceWorkflowBinding(db, ["q-slice-1"]);
|
||||
expect(result.primary?.instanceId).toBe("inst-2");
|
||||
});
|
||||
|
||||
it("live frontier signal: binds when current_frontier_json contains a slice qitem", () => {
|
||||
insertInstance(db, { instanceId: "inst-live", currentFrontier: ["q-slice-active"], currentStepId: "delivery", status: "active" });
|
||||
const result = findSliceWorkflowBinding(db, ["q-slice-active"]);
|
||||
expect(result.primary?.instanceId).toBe("inst-live");
|
||||
expect(result.primary?.currentFrontier).toEqual(["q-slice-active"]);
|
||||
expect(result.primary?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("multiple instances bind: most-recent picked as primary; rest exposed as additionalInstanceIds", () => {
|
||||
insertInstance(db, { instanceId: "inst-old", createdAt: "2026-05-01T00:00:00.000Z", currentFrontier: [] });
|
||||
insertInstance(db, { instanceId: "inst-mid", createdAt: "2026-05-02T00:00:00.000Z", currentFrontier: [] });
|
||||
insertInstance(db, { instanceId: "inst-new", createdAt: "2026-05-04T00:00:00.000Z", currentFrontier: [] });
|
||||
insertTrail(db, { trailId: "t-old", instanceId: "inst-old", stepId: "x", stepRole: "r", priorQitemId: "q-1" });
|
||||
insertTrail(db, { trailId: "t-mid", instanceId: "inst-mid", stepId: "x", stepRole: "r", priorQitemId: "q-1" });
|
||||
insertTrail(db, { trailId: "t-new", instanceId: "inst-new", stepId: "x", stepRole: "r", priorQitemId: "q-1" });
|
||||
const result = findSliceWorkflowBinding(db, ["q-1"]);
|
||||
expect(result.primary?.instanceId).toBe("inst-new");
|
||||
expect(result.additionalInstanceIds.sort()).toEqual(["inst-mid", "inst-old"]);
|
||||
});
|
||||
|
||||
it("terminal instance with empty frontier still binds via trail history", () => {
|
||||
insertInstance(db, { instanceId: "inst-done", status: "completed", currentFrontier: [], currentStepId: null });
|
||||
insertTrail(db, { trailId: "t-done", instanceId: "inst-done", stepId: "qa", stepRole: "qa-tester", priorQitemId: "q-final", closureReason: "done" });
|
||||
const result = findSliceWorkflowBinding(db, ["q-final"]);
|
||||
expect(result.primary?.instanceId).toBe("inst-done");
|
||||
expect(result.primary?.status).toBe("completed");
|
||||
expect(result.primary?.currentStepId).toBeNull();
|
||||
expect(result.primary?.currentFrontier).toEqual([]);
|
||||
});
|
||||
|
||||
it("malformed current_frontier_json parses to empty array (graceful)", () => {
|
||||
db.prepare(
|
||||
`INSERT INTO workflow_instances (instance_id, workflow_name, workflow_version, created_by_session, created_at, status, current_frontier_json)
|
||||
VALUES ('inst-bad', 'x', '1', 'c@r', '2026-05-04T00:00:00.000Z', 'active', 'not-valid-json')`
|
||||
).run();
|
||||
insertTrail(db, { trailId: "t-bad", instanceId: "inst-bad", stepId: "x", stepRole: "r", priorQitemId: "q-1" });
|
||||
const result = findSliceWorkflowBinding(db, ["q-1"]);
|
||||
expect(result.primary?.currentFrontier).toEqual([]);
|
||||
});
|
||||
|
||||
it("union of trail + frontier signals deduplicates the same instance", () => {
|
||||
// Single instance reachable via BOTH signals — should not appear twice.
|
||||
insertInstance(db, { instanceId: "inst-both", currentFrontier: ["q-1"] });
|
||||
insertTrail(db, { trailId: "t-both", instanceId: "inst-both", stepId: "x", stepRole: "r", priorQitemId: "q-1" });
|
||||
const result = findSliceWorkflowBinding(db, ["q-1"]);
|
||||
expect(result.primary?.instanceId).toBe("inst-both");
|
||||
expect(result.additionalInstanceIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
// Slice Story View v1 — workflow_spec → per-tab projection helpers.
|
||||
//
|
||||
// Pins the load-bearing behaviors of:
|
||||
// - projectSpecGraph: nodes from spec.steps; edges from each step's
|
||||
// next_hop.suggested_roles; isCurrent / isEntry / isTerminal flags;
|
||||
// loop-back detection by declared step order
|
||||
// - projectPhaseDefinitions: one entry per spec step, in declared
|
||||
// order, with role label
|
||||
// - projectCurrentStep: current_step_id resolves; allowed_exits +
|
||||
// next_hop suggested_roles → allowed_next_steps; null when
|
||||
// current_step_id is null or doesn't resolve
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { WorkflowSpec } from "../src/domain/workflow-types.js";
|
||||
import {
|
||||
projectSpecGraph,
|
||||
projectPhaseDefinitions,
|
||||
projectCurrentStep,
|
||||
} from "../src/domain/workflow/slice-workflow-projection.js";
|
||||
|
||||
const RSI_LIKE_SPEC: WorkflowSpec = {
|
||||
id: "test-loop",
|
||||
version: "1",
|
||||
objective: "test",
|
||||
entry: { role: "discovery-router" },
|
||||
invariants: { allowed_exits: ["handoff", "waiting", "done", "failed"] },
|
||||
roles: {
|
||||
"discovery-router": { preferred_targets: ["intake@r"] },
|
||||
"product-lab-planner": { preferred_targets: ["planner@r"] },
|
||||
"delivery-driver": { preferred_targets: ["driver@r"] },
|
||||
"qa-tester": { preferred_targets: ["qa@r"] },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: "discovery", actor_role: "discovery-router", objective: "scope candidates",
|
||||
allowed_exits: ["handoff", "waiting", "failed"],
|
||||
next_hop: { mode: "prefer", suggested_roles: ["product-lab-planner"] },
|
||||
},
|
||||
{
|
||||
id: "product-lab", actor_role: "product-lab-planner", objective: "shape slice",
|
||||
allowed_exits: ["handoff", "waiting", "failed"],
|
||||
next_hop: { mode: "prefer", suggested_roles: ["delivery-driver"] },
|
||||
},
|
||||
{
|
||||
id: "delivery", actor_role: "delivery-driver", objective: "implement",
|
||||
allowed_exits: ["handoff", "waiting", "failed"],
|
||||
next_hop: { mode: "prefer", suggested_roles: ["qa-tester"] },
|
||||
},
|
||||
{
|
||||
id: "qa", actor_role: "qa-tester", objective: "dogfood + fix-loop",
|
||||
allowed_exits: ["handoff", "done", "waiting", "failed"],
|
||||
// loop edge — qa can hand back to discovery for follow-up signal
|
||||
next_hop: { mode: "prefer", suggested_roles: ["discovery-router"] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("PL-slice-story-view-v1 projectSpecGraph", () => {
|
||||
it("emits one node per step in declared order", () => {
|
||||
const g = projectSpecGraph(RSI_LIKE_SPEC, null);
|
||||
expect(g.nodes.map((n) => n.stepId)).toEqual(["discovery", "product-lab", "delivery", "qa"]);
|
||||
});
|
||||
|
||||
it("derives spec name + version on the payload", () => {
|
||||
const g = projectSpecGraph(RSI_LIKE_SPEC, null);
|
||||
expect(g.specName).toBe("test-loop");
|
||||
expect(g.specVersion).toBe("1");
|
||||
});
|
||||
|
||||
it("emits one edge per (step, next_hop suggested role) pair, resolving roles back to step ids", () => {
|
||||
const g = projectSpecGraph(RSI_LIKE_SPEC, null);
|
||||
expect(g.edges).toEqual([
|
||||
{ fromStepId: "discovery", toStepId: "product-lab", routingType: "direct", isLoopBack: false },
|
||||
{ fromStepId: "product-lab", toStepId: "delivery", routingType: "direct", isLoopBack: false },
|
||||
{ fromStepId: "delivery", toStepId: "qa", routingType: "direct", isLoopBack: false },
|
||||
{ fromStepId: "qa", toStepId: "discovery", routingType: "direct", isLoopBack: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags isEntry on the spec.entry step (resolved by role)", () => {
|
||||
const g = projectSpecGraph(RSI_LIKE_SPEC, null);
|
||||
expect(g.nodes.find((n) => n.stepId === "discovery")?.isEntry).toBe(true);
|
||||
expect(g.nodes.find((n) => n.stepId === "delivery")?.isEntry).toBe(false);
|
||||
});
|
||||
|
||||
it("flags isCurrent only for the named current_step_id", () => {
|
||||
const g = projectSpecGraph(RSI_LIKE_SPEC, "delivery");
|
||||
expect(g.nodes.find((n) => n.stepId === "delivery")?.isCurrent).toBe(true);
|
||||
expect(g.nodes.find((n) => n.stepId === "discovery")?.isCurrent).toBe(false);
|
||||
expect(g.nodes.filter((n) => n.isCurrent)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flags isTerminal=false for steps with next_hop suggestions; true for steps without", () => {
|
||||
const allHopSpec: WorkflowSpec = {
|
||||
...RSI_LIKE_SPEC,
|
||||
steps: [
|
||||
{ id: "step-a", actor_role: "discovery-router", allowed_exits: ["handoff"], next_hop: { suggested_roles: ["product-lab-planner"] } },
|
||||
{ id: "step-b", actor_role: "product-lab-planner", allowed_exits: ["done"] },
|
||||
],
|
||||
};
|
||||
const g = projectSpecGraph(allHopSpec, null);
|
||||
expect(g.nodes.find((n) => n.stepId === "step-a")?.isTerminal).toBe(false);
|
||||
expect(g.nodes.find((n) => n.stepId === "step-b")?.isTerminal).toBe(true);
|
||||
});
|
||||
|
||||
it("populates preferredTarget from the role's first preferred_targets entry", () => {
|
||||
const g = projectSpecGraph(RSI_LIKE_SPEC, null);
|
||||
expect(g.nodes.find((n) => n.stepId === "discovery")?.preferredTarget).toBe("intake@r");
|
||||
expect(g.nodes.find((n) => n.stepId === "qa")?.preferredTarget).toBe("qa@r");
|
||||
});
|
||||
|
||||
it("preferredTarget is null when role has no preferred_targets", () => {
|
||||
const noTargetSpec: WorkflowSpec = {
|
||||
...RSI_LIKE_SPEC,
|
||||
roles: { "x": {} },
|
||||
steps: [{ id: "s1", actor_role: "x", allowed_exits: ["done"] }],
|
||||
};
|
||||
const g = projectSpecGraph(noTargetSpec, null);
|
||||
expect(g.nodes[0]!.preferredTarget).toBeNull();
|
||||
});
|
||||
|
||||
it("v1 carve-out: every edge carries routingType=direct (Phase D has no routing_type field yet)", () => {
|
||||
const g = projectSpecGraph(RSI_LIKE_SPEC, null);
|
||||
for (const edge of g.edges) {
|
||||
expect(edge.routingType).toBe("direct");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL-slice-story-view-v1 projectPhaseDefinitions", () => {
|
||||
it("emits one phase per step in declared order", () => {
|
||||
const phases = projectPhaseDefinitions(RSI_LIKE_SPEC);
|
||||
expect(phases.map((p) => p.id)).toEqual(["discovery", "product-lab", "delivery", "qa"]);
|
||||
});
|
||||
|
||||
it("phase label = step.actor_role", () => {
|
||||
const phases = projectPhaseDefinitions(RSI_LIKE_SPEC);
|
||||
expect(phases.find((p) => p.id === "discovery")?.label).toBe("discovery-router");
|
||||
expect(phases.find((p) => p.id === "qa")?.label).toBe("qa-tester");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL-slice-story-view-v1 projectCurrentStep", () => {
|
||||
it("returns null when current_step_id is null (terminal instance)", () => {
|
||||
expect(projectCurrentStep(RSI_LIKE_SPEC, null, 5, "completed")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when current_step_id doesn't resolve against the spec", () => {
|
||||
expect(projectCurrentStep(RSI_LIKE_SPEC, "step-from-different-spec", 1, "active")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns step metadata + allowed_next_steps for an in-spec current step", () => {
|
||||
const cs = projectCurrentStep(RSI_LIKE_SPEC, "delivery", 3, "active");
|
||||
expect(cs).not.toBeNull();
|
||||
expect(cs!.stepId).toBe("delivery");
|
||||
expect(cs!.role).toBe("delivery-driver");
|
||||
expect(cs!.objective).toBe("implement");
|
||||
expect(cs!.allowedExits).toEqual(["handoff", "waiting", "failed"]);
|
||||
expect(cs!.allowedNextSteps).toEqual([
|
||||
{ stepId: "qa", role: "qa-tester", reason: "next_hop" },
|
||||
]);
|
||||
expect(cs!.hopCount).toBe(3);
|
||||
expect(cs!.instanceStatus).toBe("active");
|
||||
});
|
||||
|
||||
it("allowedNextSteps empty when step has no next_hop (terminal)", () => {
|
||||
const terminalSpec: WorkflowSpec = {
|
||||
...RSI_LIKE_SPEC,
|
||||
steps: [
|
||||
{ id: "only", actor_role: "discovery-router", allowed_exits: ["done"] },
|
||||
],
|
||||
};
|
||||
const cs = projectCurrentStep(terminalSpec, "only", 0, "active");
|
||||
expect(cs!.allowedNextSteps).toEqual([]);
|
||||
});
|
||||
|
||||
it("allowedNextSteps for qa step includes the loop-back to discovery", () => {
|
||||
const cs = projectCurrentStep(RSI_LIKE_SPEC, "qa", 4, "active");
|
||||
expect(cs!.allowedNextSteps).toEqual([
|
||||
{ stepId: "discovery", role: "discovery-router", reason: "next_hop" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
expect(Object.keys(body).sort()).toEqual([
|
||||
"acceptance", "commitRefs", "decisions", "displayName", "docs",
|
||||
"lastActivityAt", "name", "qitemIds", "railItem", "rawStatus",
|
||||
"status", "story", "tests", "topology",
|
||||
"status", "story", "tests", "topology", "workflowBinding",
|
||||
]);
|
||||
const acc = (body.acceptance as { totalItems: number; doneItems: number; percentage: number });
|
||||
expect(acc.totalItems).toBe(2);
|
||||
|
||||
@@ -86,7 +86,12 @@ export function SliceStoryView() {
|
||||
</nav>
|
||||
</header>
|
||||
<section data-testid={`slice-tab-content-${activeTab}`} className="flex-1 min-h-0 overflow-y-auto">
|
||||
{activeTab === "story" && <StoryTab events={detail.data.story.events} />}
|
||||
{activeTab === "story" && (
|
||||
<StoryTab
|
||||
events={detail.data.story.events}
|
||||
phaseDefinitions={detail.data.story.phaseDefinitions}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "acceptance" && <AcceptanceTab acceptance={detail.data.acceptance} />}
|
||||
{activeTab === "decisions" && <DecisionsTab rows={detail.data.decisions.rows} />}
|
||||
{activeTab === "docs" && <DocsTab sliceName={detail.data.name} tree={detail.data.docs.tree} />}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
// Slice Story View v0 — Acceptance tab.
|
||||
// Slice Story View v0 + v1 — Acceptance tab.
|
||||
//
|
||||
// Header progress bar + checkbox list pulled from the slice's README /
|
||||
// IMPLEMENTATION-PRD / PROGRESS.md `[ ]` / `[x]` items. Source citation
|
||||
// (file + 1-based line) lets the operator jump to the canonical
|
||||
// statement (Docs tab is the natural follow-on).
|
||||
// v0: header progress bar + checkbox list pulled from the slice's
|
||||
// README / IMPLEMENTATION-PRD / PROGRESS.md `[ ]` / `[x]` items.
|
||||
//
|
||||
// v1 dimension #3: when a workflow_instance is bound to the slice,
|
||||
// renders a Current Step panel above the v0 checkbox list — showing
|
||||
// the active step, its objective, allowed exits, and the spec-declared
|
||||
// next-step destinations. The two views compose; PROGRESS.md parsing
|
||||
// remains unchanged.
|
||||
|
||||
import type { SliceDetail } from "../../../hooks/useSlices.js";
|
||||
import type { CurrentStepPayload, SliceDetail } from "../../../hooks/useSlices.js";
|
||||
|
||||
export function AcceptanceTab({ acceptance }: { acceptance: SliceDetail["acceptance"] }) {
|
||||
const { totalItems, doneItems, percentage, items, closureCallout } = acceptance;
|
||||
const { totalItems, doneItems, percentage, items, closureCallout, currentStep } = acceptance;
|
||||
return (
|
||||
<div data-testid="acceptance-tab" className="p-4">
|
||||
{currentStep && <CurrentStepPanel currentStep={currentStep} />}
|
||||
<header className="mb-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.12em] text-stone-700">
|
||||
@@ -64,3 +69,84 @@ export function AcceptanceTab({ acceptance }: { acceptance: SliceDetail["accepta
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// v1 dimension #3: Current Step panel.
|
||||
function CurrentStepPanel({ currentStep }: { currentStep: CurrentStepPayload }) {
|
||||
return (
|
||||
<section
|
||||
data-testid="acceptance-current-step"
|
||||
data-step-id={currentStep.stepId}
|
||||
className="mb-6 border border-stone-300 bg-stone-50 p-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.12em] text-stone-700">
|
||||
Current step
|
||||
</div>
|
||||
<div className="font-mono text-[9px] text-stone-500">
|
||||
hop {currentStep.hopCount} · {currentStep.instanceStatus}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span
|
||||
data-testid="acceptance-current-step-id"
|
||||
className="font-mono text-[12px] font-bold text-stone-900"
|
||||
>
|
||||
{currentStep.stepId}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
|
||||
role: {currentStep.role}
|
||||
</span>
|
||||
</div>
|
||||
{currentStep.objective && (
|
||||
<div
|
||||
data-testid="acceptance-current-step-objective"
|
||||
className="mt-2 whitespace-pre-line font-mono text-[10px] text-stone-700"
|
||||
>
|
||||
{currentStep.objective}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div data-testid="acceptance-current-step-allowed-exits">
|
||||
<div className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">
|
||||
Allowed exits
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{currentStep.allowedExits.length === 0 ? (
|
||||
<span className="font-mono text-[9px] text-stone-400">(none)</span>
|
||||
) : (
|
||||
currentStep.allowedExits.map((exit) => (
|
||||
<span
|
||||
key={exit}
|
||||
className="border border-stone-300 bg-white px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.10em] text-stone-700"
|
||||
>
|
||||
{exit}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="acceptance-current-step-allowed-next-steps">
|
||||
<div className="font-mono text-[8px] uppercase tracking-[0.10em] text-stone-500">
|
||||
Allowed next steps
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{currentStep.allowedNextSteps.length === 0 ? (
|
||||
<span className="font-mono text-[9px] text-stone-400">(terminal)</span>
|
||||
) : (
|
||||
currentStep.allowedNextSteps.map((next) => (
|
||||
<span
|
||||
key={next.stepId}
|
||||
data-testid={`acceptance-next-step-${next.stepId}`}
|
||||
className="border border-emerald-300 bg-emerald-50 px-1.5 py-0.5 font-mono text-[9px] text-emerald-900"
|
||||
title={`role: ${next.role}`}
|
||||
>
|
||||
{next.stepId}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,94 @@
|
||||
// Slice Story View v0 — Story tab.
|
||||
// Slice Story View v0 + v1 — Story tab.
|
||||
//
|
||||
// Chronological narrative across the RSI loop: Discovery → Product Lab
|
||||
// → Delivery → Lifecycle → QA. Each row = one event with timestamp,
|
||||
// actor session, phase tag, summary, and a click-to-expand JSON detail
|
||||
// view (the daemon may attach a `detail` blob with before/after state
|
||||
// or queue tier).
|
||||
// Chronological narrative across the slice's lifecycle. Each row = one
|
||||
// event with timestamp, actor session, optional spec-driven phase tag,
|
||||
// summary, and a click-to-expand JSON detail view.
|
||||
//
|
||||
// v1 dimension #2: when the slice is bound to a workflow_instance,
|
||||
// events group under spec-declared phase headings (the spec's step
|
||||
// labels). When unbound (or for events that don't trace back through
|
||||
// a workflow_step_trail — doc edits, proof packets), events render in
|
||||
// a single chronological flow without phase grouping.
|
||||
//
|
||||
// v0's hardcoded RSI-v2 phase taxonomy ("discovery"/"product-lab"/...)
|
||||
// is GONE — the only phase tags that show are the ones the spec
|
||||
// declares. A spec for code review work would phase
|
||||
// Spec → Implementation → Code Review → Deploy → Monitor; a deployment
|
||||
// spec would phase Build → Test → Stage → Prod. Same Story tab.
|
||||
|
||||
import { useState } from "react";
|
||||
import type { StoryEvent } from "../../../hooks/useSlices.js";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { PhaseDefinition, StoryEvent } from "../../../hooks/useSlices.js";
|
||||
|
||||
const PHASE_COLORS: Record<StoryEvent["phase"], string> = {
|
||||
discovery: "bg-amber-100 text-amber-800 border-amber-300",
|
||||
"product-lab": "bg-violet-100 text-violet-800 border-violet-300",
|
||||
delivery: "bg-emerald-100 text-emerald-800 border-emerald-300",
|
||||
lifecycle: "bg-sky-100 text-sky-800 border-sky-300",
|
||||
qa: "bg-rose-100 text-rose-800 border-rose-300",
|
||||
other: "bg-stone-100 text-stone-700 border-stone-300",
|
||||
};
|
||||
// Stable color palette assigned by phase index — first phase declared
|
||||
// in the spec gets emerald, second amber, etc. Order is the spec's
|
||||
// declared step order so RSI v2 sees its own consistent coloring.
|
||||
// Cycles after eight phases (more than any v0/v1 spec is expected to
|
||||
// declare); operator-authored specs with 9+ phases would see colors
|
||||
// repeat — acceptable v1 fallback.
|
||||
const PHASE_PALETTE: string[] = [
|
||||
"bg-amber-100 text-amber-800 border-amber-300",
|
||||
"bg-violet-100 text-violet-800 border-violet-300",
|
||||
"bg-emerald-100 text-emerald-800 border-emerald-300",
|
||||
"bg-sky-100 text-sky-800 border-sky-300",
|
||||
"bg-rose-100 text-rose-800 border-rose-300",
|
||||
"bg-fuchsia-100 text-fuchsia-800 border-fuchsia-300",
|
||||
"bg-cyan-100 text-cyan-800 border-cyan-300",
|
||||
"bg-orange-100 text-orange-800 border-orange-300",
|
||||
];
|
||||
const UNTAGGED_CLASS = "bg-stone-100 text-stone-700 border-stone-300";
|
||||
|
||||
export function StoryTab({
|
||||
events,
|
||||
phaseDefinitions,
|
||||
}: {
|
||||
events: StoryEvent[];
|
||||
phaseDefinitions: PhaseDefinition[] | null;
|
||||
}) {
|
||||
// Build phase id → display label + color class map from the bound
|
||||
// spec's declared phases. Unbound (or untagged events) use the
|
||||
// stone palette + the literal phase id (when present) as the label.
|
||||
const phaseMeta = useMemo(() => {
|
||||
const map = new Map<string, { label: string; colorClass: string }>();
|
||||
if (phaseDefinitions) {
|
||||
phaseDefinitions.forEach((p, idx) => {
|
||||
map.set(p.id, {
|
||||
label: p.label,
|
||||
colorClass: PHASE_PALETTE[idx % PHASE_PALETTE.length]!,
|
||||
});
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [phaseDefinitions]);
|
||||
|
||||
export function StoryTab({ events }: { events: StoryEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
return <div className="p-4 font-mono text-[10px] text-stone-400" data-testid="story-empty">No events captured for this slice yet.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="story-tab" className="divide-y divide-stone-100">
|
||||
{events.map((event, idx) => (
|
||||
<StoryRow key={`${event.ts}-${event.kind}-${idx}`} event={event} />
|
||||
<StoryRow
|
||||
key={`${event.ts}-${event.kind}-${idx}`}
|
||||
event={event}
|
||||
phaseMeta={phaseMeta}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryRow({ event }: { event: StoryEvent }) {
|
||||
function StoryRow({
|
||||
event,
|
||||
phaseMeta,
|
||||
}: {
|
||||
event: StoryEvent;
|
||||
phaseMeta: Map<string, { label: string; colorClass: string }>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tsShort = event.ts.length > 19 ? event.ts.slice(0, 19) : event.ts;
|
||||
const meta = event.phase ? phaseMeta.get(event.phase) : undefined;
|
||||
const phaseLabel = meta?.label ?? event.phase ?? "untagged";
|
||||
const phaseClass = meta?.colorClass ?? UNTAGGED_CLASS;
|
||||
return (
|
||||
<div className="px-4 py-2 hover:bg-stone-50">
|
||||
<button
|
||||
@@ -44,8 +99,12 @@ function StoryRow({ event }: { event: StoryEvent }) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[9px] text-stone-500 shrink-0">{tsShort}</span>
|
||||
<span className={`inline-block border px-1 font-mono text-[8px] uppercase tracking-[0.10em] ${PHASE_COLORS[event.phase]}`}>
|
||||
{event.phase}
|
||||
<span
|
||||
data-testid={`story-row-phase-${event.kind}`}
|
||||
data-phase-id={event.phase ?? "untagged"}
|
||||
className={`inline-block border px-1 font-mono text-[8px] uppercase tracking-[0.10em] ${phaseClass}`}
|
||||
>
|
||||
{phaseLabel}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-stone-700 truncate">{event.kind}</span>
|
||||
{event.actorSession && (
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
// Slice Story View v0 — Topology tab.
|
||||
// Slice Story View v0 + v1 — Topology tab.
|
||||
//
|
||||
// Read-only consumer of PL-019's extended graph payload (the route at
|
||||
// /api/rigs/:rigId/graph already carries agentActivity + currentQitems
|
||||
// per PL-019 commit ee39768). v0 ships a list-based view of the seats
|
||||
// touching this slice grouped by rig — clicking through opens the main
|
||||
// topology surface for the operator's deep-dive (per PRD: "this tab is
|
||||
// read-only; clicking a node from here takes the operator to the
|
||||
// regular topology / node-drawer surface").
|
||||
// v0: per-rig session-name list grouped by rig with click-through to
|
||||
// the main topology surface (per PRD: "this tab is read-only; clicking
|
||||
// a node from here takes the operator to the regular topology /
|
||||
// node-drawer surface").
|
||||
//
|
||||
// v1 will inline the actual rendered subgraph composing PL-019's
|
||||
// RigNode + edge components, but v0's slimmer list satisfies the
|
||||
// "see the seats" intent without re-mounting React Flow inside the
|
||||
// slice context.
|
||||
// v1 dimension #1: when a workflow_instance is bound to the slice,
|
||||
// renders the spec graph (one node per spec step; edges from each
|
||||
// step's next_hop.suggested_roles). Composes with the v0 per-rig
|
||||
// listing — both views render together. The current step is
|
||||
// highlighted, the entry step is marked, terminal steps are marked,
|
||||
// and loop-back edges are styled differently from forward edges.
|
||||
//
|
||||
// v1 dimension #4: edges carry a routingType field. Phase D's spec
|
||||
// format does NOT yet have a routing_type metadata field (per audit-
|
||||
// row-6 carve-out from the PRD), so all edges are routingType=`direct`
|
||||
// at v1. The styling differentiates loop-back vs. forward; richer
|
||||
// routing-type styling (artifact-pool, async, fan-out, etc.) is
|
||||
// v2+ territory.
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { SliceDetail } from "../../../hooks/useSlices.js";
|
||||
import type { SliceDetail, SpecGraphPayload } from "../../../hooks/useSlices.js";
|
||||
|
||||
export function TopologyTab({ topology }: { topology: SliceDetail["topology"] }) {
|
||||
if (topology.affectedRigs.length === 0) {
|
||||
const { affectedRigs, totalSeats, specGraph } = topology;
|
||||
const hasAnything = affectedRigs.length > 0 || specGraph !== null;
|
||||
if (!hasAnything) {
|
||||
return (
|
||||
<div className="p-4 font-mono text-[10px] text-stone-400" data-testid="topology-empty">
|
||||
No seats found for this slice's qitem chain.
|
||||
@@ -31,40 +39,168 @@ export function TopologyTab({ topology }: { topology: SliceDetail["topology"] })
|
||||
Topology
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-stone-500" data-testid="topology-aggregate">
|
||||
{topology.totalSeats} seat{topology.totalSeats === 1 ? "" : "s"}
|
||||
{" · "}{topology.affectedRigs.length} rig{topology.affectedRigs.length === 1 ? "" : "s"}
|
||||
{totalSeats} seat{totalSeats === 1 ? "" : "s"}
|
||||
{" · "}{affectedRigs.length} rig{affectedRigs.length === 1 ? "" : "s"}
|
||||
{specGraph && (
|
||||
<>
|
||||
{" · spec "}
|
||||
<span data-testid="topology-spec-name">{specGraph.specName}</span>
|
||||
{" v"}{specGraph.specVersion}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{topology.affectedRigs.map((rig) => (
|
||||
<section
|
||||
key={rig.rigName}
|
||||
data-testid={`topology-rig-${rig.rigName}`}
|
||||
className="border border-stone-200 bg-white"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div className="font-mono text-[10px] font-bold text-stone-900">{rig.rigName}</div>
|
||||
<Link
|
||||
to="/rigs/$rigId"
|
||||
params={{ rigId: rig.rigId }}
|
||||
data-testid={`topology-rig-${rig.rigName}-open`}
|
||||
className="font-mono text-[9px] uppercase tracking-[0.10em] text-blue-700 hover:underline"
|
||||
|
||||
{specGraph && <SpecGraphPanel specGraph={specGraph} />}
|
||||
|
||||
{affectedRigs.length > 0 && (
|
||||
<div data-testid="topology-rig-listing" className="space-y-3">
|
||||
{specGraph && (
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-500">
|
||||
Active seats
|
||||
</div>
|
||||
)}
|
||||
{affectedRigs.map((rig) => (
|
||||
<section
|
||||
key={rig.rigName}
|
||||
data-testid={`topology-rig-${rig.rigName}`}
|
||||
className="border border-stone-200 bg-white"
|
||||
>
|
||||
Open topology →
|
||||
</Link>
|
||||
</header>
|
||||
<ul className="divide-y divide-stone-100">
|
||||
{rig.sessionNames.map((session) => (
|
||||
<li
|
||||
key={session}
|
||||
data-testid={`topology-seat-${session}`}
|
||||
className="px-3 py-1 font-mono text-[10px] text-stone-700"
|
||||
>
|
||||
{session}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<header className="flex items-center justify-between border-b border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div className="font-mono text-[10px] font-bold text-stone-900">{rig.rigName}</div>
|
||||
<Link
|
||||
to="/rigs/$rigId"
|
||||
params={{ rigId: rig.rigId }}
|
||||
data-testid={`topology-rig-${rig.rigName}-open`}
|
||||
className="font-mono text-[9px] uppercase tracking-[0.10em] text-blue-700 hover:underline"
|
||||
>
|
||||
Open topology →
|
||||
</Link>
|
||||
</header>
|
||||
<ul className="divide-y divide-stone-100">
|
||||
{rig.sessionNames.map((session) => (
|
||||
<li
|
||||
key={session}
|
||||
data-testid={`topology-seat-${session}`}
|
||||
className="px-3 py-1 font-mono text-[10px] text-stone-700"
|
||||
>
|
||||
{session}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// v1 dimension #1: spec graph panel — nodes (one per step) + edges
|
||||
// (derived from each step's next_hop.suggested_roles). v1 ships a
|
||||
// list-based + adjacency view (not a force-laid-out canvas) — same
|
||||
// "see the spec shape" intent at a fraction of the React Flow
|
||||
// integration cost; v2+ can swap in a rendered subgraph.
|
||||
function SpecGraphPanel({ specGraph }: { specGraph: SpecGraphPayload }) {
|
||||
return (
|
||||
<section
|
||||
data-testid="topology-spec-graph"
|
||||
data-spec-name={specGraph.specName}
|
||||
data-spec-version={specGraph.specVersion}
|
||||
className="border border-stone-300 bg-white"
|
||||
>
|
||||
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-500">
|
||||
Spec graph · {specGraph.specName} v{specGraph.specVersion}
|
||||
</div>
|
||||
</header>
|
||||
<div className="grid grid-cols-1 divide-y divide-stone-100">
|
||||
{specGraph.nodes.map((node) => {
|
||||
const outgoingEdges = specGraph.edges.filter((e) => e.fromStepId === node.stepId);
|
||||
return (
|
||||
<div
|
||||
key={node.stepId}
|
||||
data-testid={`spec-node-${node.stepId}`}
|
||||
data-is-current={node.isCurrent}
|
||||
data-is-entry={node.isEntry}
|
||||
data-is-terminal={node.isTerminal}
|
||||
className={`px-3 py-2 ${node.isCurrent ? "bg-emerald-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono text-[11px] font-bold ${
|
||||
node.isCurrent ? "text-emerald-900" : "text-stone-900"
|
||||
}`}
|
||||
>
|
||||
{node.stepId}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.10em] text-stone-500">
|
||||
role: {node.role}
|
||||
</span>
|
||||
{node.isEntry && (
|
||||
<span
|
||||
data-testid={`spec-node-${node.stepId}-entry-badge`}
|
||||
className="border border-blue-300 bg-blue-50 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-blue-900"
|
||||
>
|
||||
entry
|
||||
</span>
|
||||
)}
|
||||
{node.isCurrent && (
|
||||
<span
|
||||
data-testid={`spec-node-${node.stepId}-current-badge`}
|
||||
className="border border-emerald-400 bg-emerald-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-emerald-900"
|
||||
>
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
{node.isTerminal && (
|
||||
<span
|
||||
data-testid={`spec-node-${node.stepId}-terminal-badge`}
|
||||
className="border border-stone-300 bg-stone-100 px-1 font-mono text-[8px] uppercase tracking-[0.10em] text-stone-700"
|
||||
>
|
||||
terminal
|
||||
</span>
|
||||
)}
|
||||
{node.preferredTarget && (
|
||||
<span className="ml-auto font-mono text-[9px] text-stone-400">
|
||||
→ {node.preferredTarget}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{outgoingEdges.length > 0 && (
|
||||
<ul className="mt-1 ml-4">
|
||||
{outgoingEdges.map((edge) => (
|
||||
<li
|
||||
key={`${edge.fromStepId}-${edge.toStepId}`}
|
||||
data-testid={`spec-edge-${edge.fromStepId}-${edge.toStepId}`}
|
||||
data-routing-type={edge.routingType}
|
||||
data-is-loop-back={edge.isLoopBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
aria-label={edge.isLoopBack ? "loop-back edge" : "forward edge"}
|
||||
className={`font-mono text-[10px] ${
|
||||
edge.isLoopBack ? "text-amber-700" : "text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{edge.isLoopBack ? "↺" : "→"}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-stone-700">
|
||||
{edge.toStepId}
|
||||
</span>
|
||||
{edge.isLoopBack && (
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.10em] text-amber-700">
|
||||
loop
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,11 @@ export function useSlices(filter: SliceFilter) {
|
||||
|
||||
export interface StoryEvent {
|
||||
ts: string;
|
||||
phase: "discovery" | "product-lab" | "delivery" | "lifecycle" | "qa" | "other";
|
||||
/** Spec-defined step.id when bound to a workflow_instance + the
|
||||
* event's qitem maps to a step trail; null when untagged (no
|
||||
* binding, no trail mapping, or non-qitem event). v1 removed the v0
|
||||
* hardcoded RSI-v2 phase enum. */
|
||||
phase: string | null;
|
||||
kind: string;
|
||||
actorSession: string | null;
|
||||
qitemId: string | null;
|
||||
@@ -67,6 +71,59 @@ export interface StoryEvent {
|
||||
detail: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PhaseDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface CurrentStepPayload {
|
||||
stepId: string;
|
||||
role: string;
|
||||
objective: string | null;
|
||||
allowedExits: string[];
|
||||
allowedNextSteps: Array<{ stepId: string; role: string; reason: "next_hop" }>;
|
||||
hopCount: number;
|
||||
instanceStatus: string;
|
||||
}
|
||||
|
||||
export interface SpecGraphNode {
|
||||
stepId: string;
|
||||
label: string;
|
||||
role: string;
|
||||
preferredTarget: string | null;
|
||||
isEntry: boolean;
|
||||
isCurrent: boolean;
|
||||
isTerminal: boolean;
|
||||
}
|
||||
|
||||
export interface SpecGraphEdge {
|
||||
fromStepId: string;
|
||||
toStepId: string;
|
||||
routingType: "direct";
|
||||
isLoopBack: boolean;
|
||||
}
|
||||
|
||||
export interface SpecGraphPayload {
|
||||
specName: string;
|
||||
specVersion: string;
|
||||
nodes: SpecGraphNode[];
|
||||
edges: SpecGraphEdge[];
|
||||
}
|
||||
|
||||
export interface WorkflowBindingPayload {
|
||||
instanceId: string;
|
||||
workflowName: string;
|
||||
workflowVersion: string;
|
||||
status: string;
|
||||
currentStepId: string | null;
|
||||
currentFrontier: string[];
|
||||
hopCount: number;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
additionalInstanceIds: string[];
|
||||
}
|
||||
|
||||
export interface AcceptanceItem {
|
||||
text: string;
|
||||
done: boolean;
|
||||
@@ -117,12 +174,35 @@ export interface SliceDetail {
|
||||
qitemIds: string[];
|
||||
commitRefs: string[];
|
||||
lastActivityAt: string | null;
|
||||
story: { events: StoryEvent[] };
|
||||
acceptance: { totalItems: number; doneItems: number; percentage: number; items: AcceptanceItem[]; closureCallout: string | null };
|
||||
/** v1: bound workflow_instance metadata; null when no instance touches
|
||||
* any of this slice's qitems (UI falls back to v0 behavior). */
|
||||
workflowBinding: WorkflowBindingPayload | null;
|
||||
story: {
|
||||
events: StoryEvent[];
|
||||
/** v1: spec-declared phase definitions; null when no instance bound. */
|
||||
phaseDefinitions: PhaseDefinition[] | null;
|
||||
};
|
||||
acceptance: {
|
||||
totalItems: number;
|
||||
doneItems: number;
|
||||
percentage: number;
|
||||
items: AcceptanceItem[];
|
||||
closureCallout: string | null;
|
||||
/** v1: bound instance's current step + allowed next steps; null
|
||||
* when no instance bound. */
|
||||
currentStep: CurrentStepPayload | null;
|
||||
};
|
||||
decisions: { rows: DecisionRow[] };
|
||||
docs: { tree: DocsTreeEntry[] };
|
||||
tests: { proofPackets: ProofPacketRendered[]; aggregate: { passCount: number; failCount: number } };
|
||||
topology: { affectedRigs: TopologyRigEntry[]; totalSeats: number };
|
||||
topology: {
|
||||
affectedRigs: TopologyRigEntry[];
|
||||
totalSeats: number;
|
||||
/** v1: spec graph (nodes + edges) derived from the bound instance's
|
||||
* workflow_spec; null when unbound (UI falls back to per-rig
|
||||
* session listing). */
|
||||
specGraph: SpecGraphPayload | null;
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSliceDetail(name: string): Promise<SliceDetail> {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Slice Story View v1 — focused tab tests for the 4 v1 dimensions.
|
||||
//
|
||||
// Pins:
|
||||
// - StoryTab: spec-driven phase grouping (no v0 hardcoded RSI-v2 enum)
|
||||
// - StoryTab: untagged events render with neutral palette + label
|
||||
// - AcceptanceTab: Current Step panel renders when bound; absent when not
|
||||
// - AcceptanceTab: PROGRESS.md checkbox view still renders alongside
|
||||
// - TopologyTab: spec graph renders nodes + edges + isCurrent/isEntry/
|
||||
// isTerminal badges + loop-back edge styling when bound
|
||||
// - TopologyTab: per-rig listing still renders alongside spec graph
|
||||
// - TopologyTab: routingType="direct" carved-out — every edge has it
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import { createTestRouter } from "./helpers/test-router.js";
|
||||
import { StoryTab } from "../src/components/slices/tabs/StoryTab.js";
|
||||
import { AcceptanceTab } from "../src/components/slices/tabs/AcceptanceTab.js";
|
||||
import { TopologyTab } from "../src/components/slices/tabs/TopologyTab.js";
|
||||
import type {
|
||||
StoryEvent,
|
||||
PhaseDefinition,
|
||||
CurrentStepPayload,
|
||||
SpecGraphPayload,
|
||||
SliceDetail,
|
||||
} from "../src/hooks/useSlices.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function event(overrides: Partial<StoryEvent>): StoryEvent {
|
||||
return {
|
||||
ts: "2026-05-04T00:00:00.000Z",
|
||||
phase: null,
|
||||
kind: "queue.created",
|
||||
actorSession: "src@r",
|
||||
qitemId: "q-1",
|
||||
summary: "summary",
|
||||
detail: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SPEC_PHASES: PhaseDefinition[] = [
|
||||
{ id: "discovery", label: "discovery-router", role: "discovery-router" },
|
||||
{ id: "delivery", label: "delivery-driver", role: "delivery-driver" },
|
||||
{ id: "qa", label: "qa-tester", role: "qa-tester" },
|
||||
];
|
||||
|
||||
describe("PL-slice-story-view-v1 StoryTab", () => {
|
||||
it("groups events by spec-declared phase labels (NOT v0 hardcoded RSI-v2 names)", () => {
|
||||
const events = [
|
||||
event({ kind: "queue.created", phase: "discovery", qitemId: "q-d" }),
|
||||
event({ kind: "queue.handed_off", phase: "delivery", qitemId: "q-x" }),
|
||||
event({ kind: "transition.in-progress", phase: "qa", qitemId: "q-q" }),
|
||||
];
|
||||
render(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
|
||||
// Each row's phase chip uses the spec-declared label (which equals
|
||||
// the actor_role for v1's projector default).
|
||||
expect(screen.getByTestId("story-row-phase-queue.created").textContent).toBe("discovery-router");
|
||||
expect(screen.getByTestId("story-row-phase-queue.handed_off").textContent).toBe("delivery-driver");
|
||||
expect(screen.getByTestId("story-row-phase-transition.in-progress").textContent).toBe("qa-tester");
|
||||
});
|
||||
|
||||
it("untagged events render with the neutral 'untagged' label + stone palette", () => {
|
||||
const events = [event({ kind: "doc.edited", phase: null, qitemId: null })];
|
||||
render(<StoryTab events={events} phaseDefinitions={SPEC_PHASES} />);
|
||||
const chip = screen.getByTestId("story-row-phase-doc.edited");
|
||||
expect(chip.textContent).toBe("untagged");
|
||||
expect(chip.getAttribute("data-phase-id")).toBe("untagged");
|
||||
expect(chip.className).toContain("stone-100");
|
||||
});
|
||||
|
||||
it("when phaseDefinitions is null (unbound slice), spec phase ids on events fall through verbatim", () => {
|
||||
// Edge case: an event somehow carries a phase id but the slice isn't
|
||||
// bound. The chip falls back to displaying the raw phase id rather
|
||||
// than crashing. (Realistic v1 scenario when a previously-bound slice
|
||||
// loses its workflow_instance binding — events were tagged at fetch
|
||||
// time, definitions weren't.)
|
||||
const events = [event({ kind: "queue.created", phase: "step-x" })];
|
||||
render(<StoryTab events={events} phaseDefinitions={null} />);
|
||||
expect(screen.getByTestId("story-row-phase-queue.created").textContent).toBe("step-x");
|
||||
});
|
||||
});
|
||||
|
||||
// --- AcceptanceTab ---
|
||||
|
||||
function acceptanceShape(currentStep: CurrentStepPayload | null): SliceDetail["acceptance"] {
|
||||
return {
|
||||
totalItems: 4,
|
||||
doneItems: 2,
|
||||
percentage: 50,
|
||||
items: [
|
||||
{ text: "Item 1", done: true, source: { file: "README.md", line: 10 } },
|
||||
{ text: "Item 2", done: false, source: { file: "README.md", line: 11 } },
|
||||
],
|
||||
closureCallout: null,
|
||||
currentStep,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PL-slice-story-view-v1 AcceptanceTab", () => {
|
||||
it("renders Current Step panel when bound (above the v0 checkbox list)", () => {
|
||||
const cs: CurrentStepPayload = {
|
||||
stepId: "delivery",
|
||||
role: "delivery-driver",
|
||||
objective: "Implement the slice per packet.",
|
||||
allowedExits: ["handoff", "waiting", "failed"],
|
||||
allowedNextSteps: [
|
||||
{ stepId: "lifecycle", role: "lifecycle-router", reason: "next_hop" },
|
||||
],
|
||||
hopCount: 3,
|
||||
instanceStatus: "active",
|
||||
};
|
||||
render(<AcceptanceTab acceptance={acceptanceShape(cs)} />);
|
||||
expect(screen.getByTestId("acceptance-current-step")).toBeDefined();
|
||||
expect(screen.getByTestId("acceptance-current-step-id").textContent).toBe("delivery");
|
||||
expect(screen.getByTestId("acceptance-current-step-objective").textContent).toContain("Implement the slice");
|
||||
// allowed exits + allowed next steps both present.
|
||||
const allowed = screen.getByTestId("acceptance-current-step-allowed-exits");
|
||||
expect(allowed.textContent).toContain("handoff");
|
||||
expect(allowed.textContent).toContain("waiting");
|
||||
expect(allowed.textContent).toContain("failed");
|
||||
expect(screen.getByTestId("acceptance-next-step-lifecycle")).toBeDefined();
|
||||
// v0 checkbox view still rendering alongside.
|
||||
expect(screen.getByTestId("acceptance-list")).toBeDefined();
|
||||
expect(screen.getByTestId("acceptance-progress-bar")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render Current Step panel when unbound (currentStep=null)", () => {
|
||||
render(<AcceptanceTab acceptance={acceptanceShape(null)} />);
|
||||
expect(screen.queryByTestId("acceptance-current-step")).toBeNull();
|
||||
// v0 checkbox view still rendering — confirms fallback intact.
|
||||
expect(screen.getByTestId("acceptance-list")).toBeDefined();
|
||||
});
|
||||
|
||||
it("Current Step shows '(terminal)' marker when allowedNextSteps is empty", () => {
|
||||
const cs: CurrentStepPayload = {
|
||||
stepId: "qa",
|
||||
role: "qa-tester",
|
||||
objective: null,
|
||||
allowedExits: ["done"],
|
||||
allowedNextSteps: [],
|
||||
hopCount: 5,
|
||||
instanceStatus: "active",
|
||||
};
|
||||
render(<AcceptanceTab acceptance={acceptanceShape(cs)} />);
|
||||
const nextSteps = screen.getByTestId("acceptance-current-step-allowed-next-steps");
|
||||
expect(nextSteps.textContent).toContain("(terminal)");
|
||||
});
|
||||
});
|
||||
|
||||
// --- TopologyTab ---
|
||||
|
||||
function topologyShape(specGraph: SpecGraphPayload | null, withRigs = true): SliceDetail["topology"] {
|
||||
return {
|
||||
affectedRigs: withRigs
|
||||
? [{ rigId: "rig-1", rigName: "demo", sessionNames: ["alpha@demo", "beta@demo"] }]
|
||||
: [],
|
||||
totalSeats: withRigs ? 2 : 0,
|
||||
specGraph,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PL-slice-story-view-v1 TopologyTab", () => {
|
||||
function makeRouter(topology: SliceDetail["topology"]) {
|
||||
return createTestRouter({ component: () => <TopologyTab topology={topology} />, path: "/" });
|
||||
}
|
||||
|
||||
it("renders the spec graph panel when specGraph is present", async () => {
|
||||
const sg: SpecGraphPayload = {
|
||||
specName: "test-loop",
|
||||
specVersion: "1",
|
||||
nodes: [
|
||||
{ stepId: "discovery", label: "discovery-router", role: "discovery-router", preferredTarget: "intake@r", isEntry: true, isCurrent: false, isTerminal: false },
|
||||
{ stepId: "delivery", label: "delivery-driver", role: "delivery-driver", preferredTarget: "driver@r", isEntry: false, isCurrent: true, isTerminal: false },
|
||||
{ stepId: "qa", label: "qa-tester", role: "qa-tester", preferredTarget: "qa@r", isEntry: false, isCurrent: false, isTerminal: false },
|
||||
],
|
||||
edges: [
|
||||
{ fromStepId: "discovery", toStepId: "delivery", routingType: "direct", isLoopBack: false },
|
||||
{ fromStepId: "delivery", toStepId: "qa", routingType: "direct", isLoopBack: false },
|
||||
{ fromStepId: "qa", toStepId: "discovery", routingType: "direct", isLoopBack: true },
|
||||
],
|
||||
};
|
||||
render(makeRouter(topologyShape(sg)));
|
||||
await waitFor(() => expect(screen.getByTestId("topology-spec-graph")).toBeDefined());
|
||||
expect(screen.getByTestId("topology-spec-name").textContent).toBe("test-loop");
|
||||
// Per-step nodes rendered.
|
||||
expect(screen.getByTestId("spec-node-discovery")).toBeDefined();
|
||||
expect(screen.getByTestId("spec-node-delivery")).toBeDefined();
|
||||
expect(screen.getByTestId("spec-node-qa")).toBeDefined();
|
||||
// Entry + current badges.
|
||||
expect(screen.getByTestId("spec-node-discovery-entry-badge")).toBeDefined();
|
||||
expect(screen.getByTestId("spec-node-delivery-current-badge")).toBeDefined();
|
||||
expect(screen.queryByTestId("spec-node-qa-current-badge")).toBeNull();
|
||||
// Edges, including loop-back.
|
||||
expect(screen.getByTestId("spec-edge-discovery-delivery").getAttribute("data-is-loop-back")).toBe("false");
|
||||
expect(screen.getByTestId("spec-edge-qa-discovery").getAttribute("data-is-loop-back")).toBe("true");
|
||||
// Per-rig listing still rendering alongside.
|
||||
expect(screen.getByTestId("topology-rig-listing")).toBeDefined();
|
||||
expect(screen.getByTestId("topology-rig-demo")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render the spec graph panel when unbound (specGraph=null) — v0 fallback", async () => {
|
||||
render(makeRouter(topologyShape(null)));
|
||||
await waitFor(() => expect(screen.getByTestId("topology-rig-demo")).toBeDefined());
|
||||
expect(screen.queryByTestId("topology-spec-graph")).toBeNull();
|
||||
});
|
||||
|
||||
it("v1 carve-out: every spec edge carries data-routing-type='direct' (Phase D has no routing_type field yet)", async () => {
|
||||
const sg: SpecGraphPayload = {
|
||||
specName: "test-loop",
|
||||
specVersion: "1",
|
||||
nodes: [
|
||||
{ stepId: "a", label: "a", role: "a", preferredTarget: null, isEntry: true, isCurrent: false, isTerminal: false },
|
||||
{ stepId: "b", label: "b", role: "b", preferredTarget: null, isEntry: false, isCurrent: false, isTerminal: true },
|
||||
],
|
||||
edges: [{ fromStepId: "a", toStepId: "b", routingType: "direct", isLoopBack: false }],
|
||||
};
|
||||
render(makeRouter(topologyShape(sg, false)));
|
||||
await waitFor(() => expect(screen.getByTestId("spec-edge-a-b")).toBeDefined());
|
||||
const edge = screen.getByTestId("spec-edge-a-b");
|
||||
expect(edge.getAttribute("data-routing-type")).toBe("direct");
|
||||
});
|
||||
|
||||
it("renders empty state only when BOTH specGraph is null AND no rigs are present", async () => {
|
||||
render(makeRouter({ affectedRigs: [], totalSeats: 0, specGraph: null }));
|
||||
await waitFor(() => expect(screen.getByTestId("topology-empty")).toBeDefined());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user