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 6097f4e + 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:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 04:13:30 -07:00
1 parent 6097f4e633
commit 7bb892e1ba
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;
}
+12 -1
View File
@@ -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" },
]);
});
});
+1 -1
View File
@@ -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>
);
}
+84 -4
View File
@@ -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> {
+228
View File
@@ -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());
});
});