feat(spec-library): workflows kind + activation lens v0

PRD: slices/workflows-in-spec-library-v0/IMPLEMENTATION-PRD.md.

Daemon
- spec-library-workflow-scanner.ts: project workflow_specs cache rows as
  SpecLibraryEntry (kind: workflow) + topology graph for review payload;
  builtin classification via path.sep boundary.
- active-lens-store.ts: file-backed JSON persistence under OPENRIG_HOME;
  atomic tmp+rename; single-active invariant; survives daemon restart.
- spec-library-service.ts: extend SpecLibraryEntry with kind: "workflow"
  + workflow-only metadata; setWorkflowEntries() merges into list/get.
- spec-library.ts routes: GET/POST/DELETE /active-lens (mounted before
  /:id catchall); refresh workflow entries from cache on each list;
  /:id/review branches on kind === "workflow".
- slices.ts: new boundToWorkflow=<name>:<version> query — filter slice
  list to those whose primary workflow_instance binding matches the
  spec via findSliceWorkflowBinding.
- slice-indexer.ts: expose .db so the slices route can call the binding
  helper without re-injecting db.
- server.ts/startup.ts: wire ActiveLensStore into AppDeps + middleware.

UI
- useSpecLibrary.ts: extend SpecLibraryKind to include "workflow";
  LibraryWorkflowReview type; useActiveLens / setActiveLens /
  clearActiveLens hooks.
- useSlices.ts: optional boundToWorkflow filter param.
- SpecsPanel.tsx: Workflows filter chip + WORKFLOW row badge.
- LibraryReview.tsx: workflow review variant — header, topology graph
  (React Flow with BFS depth layout, entry/terminal accent colors),
  per-step list, terminal-turn rule callout, Activate/Deactivate Lens.
- SliceExplorerPanel.tsx: read active lens, pass boundToWorkflow to
  useSlices; render lens indicator + Clear + Show All toggle; empty
  state mentions the lens spec.

Tests
- daemon: scanner classification (builtin/user_file/path.sep boundary/
  table-absent fallback), getWorkflowReview topology projection,
  workflowLibraryId roundtrip, active-lens store CRUD + persistence,
  active-lens routes, workflow library entry list + review, slices
  boundToWorkflow filter (matches/empty/malformed/absent).
- UI: SpecsPanel Workflows filter chip + workflow row, LibraryReview
  workflow variant + Activate Lens POST, SliceExplorerPanel lens
  indicator + boundToWorkflow query param + Clear/Show All controls.

Daemon: 2878/2878. UI: 525/525. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 06:40:22 -07:00
1 parent 4f48164bd8
commit ff531aceff
20 files changed
+1980 -28

No files matched your search

@@ -0,0 +1,100 @@
// Workflows in Spec Library + Activation Lens v0 — active lens persistence.
//
// PRD § Item 4: lens selection persists daemon-side across daemon
// restarts + browser refresh. The PRD bounce conditions explicitly
// reject ephemeral state.
//
// Implementation: a small JSON file under OPENRIG_HOME/active-workflow
// -lens.json. Same env-var-aware persistence pattern as UI Enhancement
// Pack v0's file-edit-audit JSONL — no new SQLite table, no new
// migration. The file is rewritten atomically on every set so concurrent
// daemons (rare at single-host MVP) don't tear the read.
//
// Single-active-lens invariant: setting a lens replaces any prior
// lens; clearing removes the file entirely. The store does NOT
// validate that the named workflow_spec actually exists in the
// workflow_specs cache — that's an upstream concern at the route
// layer, since the spec might be uncached temporarily during a
// workspace-surface reconciliation cycle and re-appear before the
// next read.
import * as fs from "node:fs";
import * as path from "node:path";
export interface ActiveLens {
specName: string;
specVersion: string;
/** ISO timestamp of the most recent activation; useful for
* diagnostics + UI staleness display. */
activatedAt: string;
}
export interface ActiveLensStoreOpts {
/** Absolute file path. Should fall under the daemon's OPENRIG_HOME so
* the lens stays isolated per host. */
filePath: string;
/** Test seam — defaults to () => new Date(). */
now?: () => Date;
}
export class ActiveLensStore {
private readonly filePath: string;
private readonly now: () => Date;
constructor(opts: ActiveLensStoreOpts) {
this.filePath = opts.filePath;
this.now = opts.now ?? (() => new Date());
}
get(): ActiveLens | null {
let raw: string;
try {
raw = fs.readFileSync(this.filePath, "utf-8");
} catch {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<ActiveLens>;
if (typeof parsed.specName !== "string" || typeof parsed.specVersion !== "string") return null;
return {
specName: parsed.specName,
specVersion: parsed.specVersion,
activatedAt: typeof parsed.activatedAt === "string" ? parsed.activatedAt : this.now().toISOString(),
};
} catch {
// Malformed file → treat as no lens. Operator can `rm` to recover.
return null;
}
}
set(specName: string, specVersion: string): ActiveLens {
const lens: ActiveLens = {
specName,
specVersion,
activatedAt: this.now().toISOString(),
};
const dir = path.dirname(this.filePath);
fs.mkdirSync(dir, { recursive: true });
// Atomic-ish: write to temp + rename. v0 doesn't fsync (the lens
// is operator UX state, not an audit-record-keeping invariant —
// losing it across a hard crash is acceptable; the operator
// re-clicks Activate).
const tmp = `${this.filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
try {
fs.writeFileSync(tmp, JSON.stringify(lens));
fs.renameSync(tmp, this.filePath);
} catch (err) {
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
throw err;
}
return lens;
}
clear(): void {
try {
fs.unlinkSync(this.filePath);
} catch {
// Already absent — no-op.
}
}
}
@@ -120,7 +120,12 @@ const STATUS_TO_BUCKET: Record<string, SliceStatus> = {
export class SliceIndexer {
readonly slicesRoot: string;
readonly dogfoodEvidenceRoot: string | null;
private readonly db: Database.Database;
/** Workflows in Spec Library v0: exposed read-only so the slices
* route's `boundToWorkflow` lens filter can call
* `findSliceWorkflowBinding(db, qitemIds)` without re-injecting
* the db handle elsewhere. Internal callers continue to use it
* the same way; read-only consumer outside the class. */
readonly db: Database.Database;
private readonly cacheTtlMs: number;
private listingCache: CachedListing | null = null;
private detailCache: Map<string, CachedSlice> = new Map();
@@ -6,7 +6,7 @@ import { SpecReviewService } from "./spec-review-service.js";
export interface SpecLibraryEntry {
id: string;
kind: "rig" | "agent";
kind: "rig" | "agent" | "workflow";
name: string;
version: string;
sourceType: "builtin" | "user_file";
@@ -15,6 +15,12 @@ export interface SpecLibraryEntry {
updatedAt: string;
summary?: string;
hasServices?: boolean;
/** Workflows in Spec Library v0 — workflow-only metadata (kind === "workflow"). */
isBuiltIn?: boolean;
rolesCount?: number;
stepsCount?: number;
terminalTurnRule?: string;
targetRig?: string | null;
}
export interface SpecLibraryOpts {
@@ -77,6 +83,11 @@ function shouldIndexRelativePath(sourceType: "builtin" | "user_file", relPath: s
export class SpecLibraryService {
private entries = new Map<string, SpecLibraryEntry>();
/** Workflow entries are written by the route layer via
* setWorkflowEntries() — kept separate from the rig+agent scan
* because their source-of-truth is the workflow_specs SQLite cache,
* not YAML files on disk. */
private workflowEntries = new Map<string, SpecLibraryEntry>();
private readonly roots: SpecLibraryOpts["roots"];
private readonly specReviewService: SpecReviewService;
@@ -124,15 +135,37 @@ export class SpecLibraryService {
this.entries = newEntries;
}
list(filter?: { kind?: "rig" | "agent" }): SpecLibraryEntry[] {
const entries = Array.from(this.entries.values());
list(filter?: { kind?: "rig" | "agent" | "workflow" }): SpecLibraryEntry[] {
const entries = [
...Array.from(this.entries.values()),
...Array.from(this.workflowEntries.values()),
];
if (filter?.kind) {
return entries.filter((e) => e.kind === filter.kind);
}
return entries;
}
/** Workflows in Spec Library v0: replace the workflow-entry projection
* in one shot. Called by the route layer after running
* scanWorkflowSpecs() against the workflow_specs SQLite cache. */
setWorkflowEntries(entries: SpecLibraryEntry[]): void {
const next = new Map<string, SpecLibraryEntry>();
for (const entry of entries) {
if (entry.kind !== "workflow") continue;
next.set(entry.id, entry);
}
this.workflowEntries = next;
}
get(id: string): { entry: SpecLibraryEntry; yaml: string } | null {
// Workflow entries: yaml is read from the source path on demand.
const wfEntry = this.workflowEntries.get(id);
if (wfEntry) {
let yaml = "";
try { yaml = readFileSync(wfEntry.sourcePath, "utf-8"); } catch { /* tolerate */ }
return { entry: wfEntry, yaml };
}
const entry = this.entries.get(id);
if (!entry) return null;
@@ -0,0 +1,264 @@
// Workflows in Spec Library + Activation Lens v0 — workflow scanner.
//
// SpecLibraryService knows how to classify rig + agent YAML files. This
// scanner adds workflow_specs as a third library kind by reading
// directly from the workflow_specs SQLite cache (PL-004 Phase D) +
// re-parsing each cached row to extract the per-step routing for the
// review payload's topology graph.
//
// The cache is the single source of truth for which workflow_specs the
// daemon has seen — both built-in starters (seeded at startup by
// loadStarterWorkflowSpecs from rsi-v2-starter-workflow-spec-v0) and
// operator-authored specs at workspace paths that the workspace-
// surface reconciliation contract has read through. Reading from the
// cache (rather than re-walking directories) means: no new env config,
// no duplicate parse logic, and the scanner stays consistent with
// `rig workflow specs` (same rows, same source-of-truth).
//
// Built-in detection: a row's `source_path` is "built in" iff it falls
// under the daemon's workflowBuiltinSpecsDir (the same `path.sep`
// boundary check used by the existing rsi-v2-starter route at
// /api/workflow/specs).
import * as path from "node:path";
import type Database from "better-sqlite3";
import type { WorkflowSpec } from "./workflow-types.js";
export interface SpecLibraryWorkflowEntry {
id: string;
kind: "workflow";
name: string;
version: string;
/** "builtin" when source_path is under workflowBuiltinSpecsDir; else "user_file". */
sourceType: "builtin" | "user_file";
sourcePath: string;
relativePath: string;
updatedAt: string;
summary?: string;
/** True iff sourcePath is under the daemon's bundled built-in dir. */
isBuiltIn: boolean;
/** Cheap counts surfaced in the row summary line. */
rolesCount: number;
stepsCount: number;
terminalTurnRule: string;
targetRig: string | null;
}
export interface SpecLibraryWorkflowReview {
kind: "workflow";
name: string;
version: string;
purpose: string | null;
targetRig: string | null;
terminalTurnRule: string;
rolesCount: number;
stepsCount: number;
isBuiltIn: boolean;
sourcePath: string;
cachedAt: string;
/** Topology graph projection: nodes from `roles`, edges derived from
* each step's next_hop.suggested_roles → next-step ids. Same shape
* Slice Story View v1 uses for its Topology tab — consumers reuse
* the same UI primitives for rendering. */
topology: {
nodes: Array<{
stepId: string;
role: string;
objective: string | null;
preferredTarget: string | null;
isEntry: boolean;
isTerminal: boolean;
}>;
edges: Array<{
fromStepId: string;
toStepId: string;
routingType: "direct";
}>;
};
/** Per-step list rendered below the graph. */
steps: Array<{
stepId: string;
role: string;
objective: string | null;
allowedExits: string[];
/** Resolved destinations from next_hop.suggested_roles → step ids. */
allowedNextSteps: Array<{ stepId: string; role: string }>;
}>;
}
export interface ScanWorkflowSpecsOpts {
db: Database.Database;
/** Absolute path to the daemon's bundled builtin starter dir;
* null/undefined → no isBuiltIn detection (all rows render as user_file). */
workflowBuiltinSpecsDir: string | null;
}
interface SpecRow {
spec_id: string;
name: string;
version: string;
purpose: string | null;
target_rig: string | null;
roles_json: string;
steps_json: string;
coordination_terminal_turn_rule: string;
source_path: string;
source_hash: string;
cached_at: string;
}
export function scanWorkflowSpecs(opts: ScanWorkflowSpecsOpts): SpecLibraryWorkflowEntry[] {
let rows: SpecRow[] = [];
try {
rows = opts.db.prepare(
`SELECT * FROM workflow_specs ORDER BY name, version`,
).all() as SpecRow[];
} catch {
// workflow_specs table absent (test harness without the migration):
// empty library. Same graceful degradation as Slice Story View
// v0's slice indexer.
return [];
}
const out: SpecLibraryWorkflowEntry[] = [];
for (const row of rows) {
let roles: WorkflowSpec["roles"];
let steps: WorkflowSpec["steps"];
try {
roles = JSON.parse(row.roles_json) as WorkflowSpec["roles"];
steps = JSON.parse(row.steps_json) as WorkflowSpec["steps"];
} catch {
// Malformed JSON in cache — skip with no entry; the daemon's
// /api/workflow/specs surface will surface the row anyway.
continue;
}
const isBuiltIn = opts.workflowBuiltinSpecsDir
? isUnderDir(row.source_path, opts.workflowBuiltinSpecsDir)
: false;
out.push({
// Stable id derived from name+version so the SpecLibrary's review
// endpoint can resolve workflow entries the same way it resolves
// rig/agent entries by id.
id: workflowLibraryId(row.name, row.version),
kind: "workflow",
name: row.name,
version: row.version,
sourceType: isBuiltIn ? "builtin" : "user_file",
sourcePath: row.source_path,
relativePath: row.source_path,
updatedAt: row.cached_at,
summary: row.purpose ?? undefined,
isBuiltIn,
rolesCount: Object.keys(roles ?? {}).length,
stepsCount: Array.isArray(steps) ? steps.length : 0,
terminalTurnRule: row.coordination_terminal_turn_rule || "hot_potato",
targetRig: row.target_rig,
});
}
return out;
}
export function getWorkflowReview(opts: ScanWorkflowSpecsOpts & { name: string; version: string }): SpecLibraryWorkflowReview | null {
let row: SpecRow | undefined;
try {
row = opts.db.prepare(
`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`,
).get(opts.name, opts.version) as SpecRow | undefined;
} catch {
return null;
}
if (!row) return null;
let roles: WorkflowSpec["roles"];
let steps: WorkflowSpec["steps"];
try {
roles = JSON.parse(row.roles_json) as WorkflowSpec["roles"];
steps = JSON.parse(row.steps_json) as WorkflowSpec["steps"];
} catch {
return null;
}
const isBuiltIn = opts.workflowBuiltinSpecsDir
? isUnderDir(row.source_path, opts.workflowBuiltinSpecsDir)
: false;
// Project the topology — same shape as Slice Story View v1 uses.
const stepByRole = new Map<string, typeof steps[0]>();
for (const step of steps ?? []) {
if (!stepByRole.has(step.actor_role)) stepByRole.set(step.actor_role, step);
}
const entryRole = (row as unknown as { entry_role?: string }).entry_role
?? steps?.[0]?.actor_role;
const entryStepId = entryRole ? stepByRole.get(entryRole)?.id : undefined;
const topologyNodes = (steps ?? []).map((step) => {
const roleSpec = (roles as Record<string, { preferred_targets?: string[] }>)[step.actor_role] ?? {};
return {
stepId: step.id,
role: step.actor_role,
objective: step.objective ?? null,
preferredTarget: roleSpec.preferred_targets?.[0] ?? null,
isEntry: step.id === entryStepId,
isTerminal: !(step.next_hop?.suggested_roles?.length),
};
});
const topologyEdges: SpecLibraryWorkflowReview["topology"]["edges"] = [];
for (const step of steps ?? []) {
for (const role of step.next_hop?.suggested_roles ?? []) {
const target = stepByRole.get(role);
if (!target) continue;
topologyEdges.push({ fromStepId: step.id, toStepId: target.id, routingType: "direct" });
}
}
const stepDetails: SpecLibraryWorkflowReview["steps"] = (steps ?? []).map((step) => ({
stepId: step.id,
role: step.actor_role,
objective: step.objective ?? null,
allowedExits: [...(step.allowed_exits ?? [])],
allowedNextSteps: (step.next_hop?.suggested_roles ?? [])
.map((role) => {
const target = stepByRole.get(role);
return target ? { stepId: target.id, role } : null;
})
.filter((x): x is { stepId: string; role: string } => x !== null),
}));
return {
kind: "workflow",
name: row.name,
version: row.version,
purpose: row.purpose,
targetRig: row.target_rig,
terminalTurnRule: row.coordination_terminal_turn_rule || "hot_potato",
rolesCount: Object.keys(roles ?? {}).length,
stepsCount: (steps ?? []).length,
isBuiltIn,
sourcePath: row.source_path,
cachedAt: row.cached_at,
topology: { nodes: topologyNodes, edges: topologyEdges },
steps: stepDetails,
};
}
export function workflowLibraryId(name: string, version: string): string {
return `workflow:${name}:${version}`;
}
export function parseWorkflowLibraryId(id: string): { name: string; version: string } | null {
if (!id.startsWith("workflow:")) return null;
const rest = id.slice("workflow:".length);
// version may be numeric or an arbitrary string; split on the LAST `:`.
const lastColon = rest.lastIndexOf(":");
if (lastColon === -1) return null;
return { name: rest.slice(0, lastColon), version: rest.slice(lastColon + 1) };
}
function isUnderDir(childPath: string, parentDir: string): boolean {
const child = path.resolve(childPath);
const parent = path.resolve(parentDir);
if (child === parent) return false;
const parentWithSep = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
return child.startsWith(parentWithSep);
}
+37 -2
View File
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import type { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
import type { SliceDetailProjector } from "../domain/slices/slice-detail-projector.js";
import { findSliceWorkflowBinding } from "../domain/workflow/slice-workflow-binding.js";
export interface SlicesRoutesDeps {
indexer: SliceIndexer;
@@ -49,11 +50,45 @@ export function slicesRoutes(): Hono {
}, 400);
}
const all = deps.indexer.list();
const filtered = filter === "all" ? all : all.filter((s) => s.status === filter);
let filtered = filter === "all" ? all : all.filter((s) => s.status === filter);
// Workflows in Spec Library v0: optional lens filter — narrow to
// slices bound to a workflow_instance of <name>:<version>.
const boundToWorkflow = c.req.query("boundToWorkflow");
let boundDiagnostic: { specName: string; specVersion: string; matched: number; total: number } | null = null;
if (boundToWorkflow) {
const colonIdx = boundToWorkflow.lastIndexOf(":");
if (colonIdx === -1) {
return c.json({
error: "boundToWorkflow_invalid",
hint: "Format is boundToWorkflow=<specName>:<specVersion>",
}, 400);
}
const specName = boundToWorkflow.slice(0, colonIdx);
const specVersion = boundToWorkflow.slice(colonIdx + 1);
const db = deps.indexer.db;
const before = filtered.length;
filtered = filtered.filter((slice) => {
// Re-resolve binding per slice. The indexer's list payload
// doesn't carry workflowName so we do the join here. v0 cost
// is bounded by the slice count + a small SQL per slice; with
// ~109 slices this stays sub-100ms in practice.
const sliceRecord = deps.indexer.get(slice.name);
if (!sliceRecord || sliceRecord.qitemIds.length === 0) return false;
const binding = findSliceWorkflowBinding(db, sliceRecord.qitemIds);
return binding.primary?.workflowName === specName
&& binding.primary?.workflowVersion === specVersion;
});
boundDiagnostic = { specName, specVersion, matched: filtered.length, total: before };
}
// Sort by lastActivityAt DESC (most recently touched first); slices
// without activity sort to the end.
filtered.sort(compareByActivityDesc);
return c.json({ slices: filtered, totalCount: filtered.length, filter });
return c.json({
slices: filtered,
totalCount: filtered.length,
filter,
boundToWorkflow: boundDiagnostic,
});
});
// 2) Proof asset serving — registered BEFORE /:name to keep /:name from
+79 -6
View File
@@ -1,28 +1,80 @@
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { parse as parseYaml } from "yaml";
import type { ActiveLensStore } from "../domain/active-lens-store.js";
import type { SpecLibraryService } from "../domain/spec-library-service.js";
import { SpecReviewService, SpecReviewError } from "../domain/spec-review-service.js";
import {
getWorkflowReview,
parseWorkflowLibraryId,
scanWorkflowSpecs,
} from "../domain/spec-library-workflow-scanner.js";
export function specLibraryRoutes(): Hono {
const router = new Hono();
function refreshWorkflowEntries(c: { get: (k: string) => unknown }): SpecLibraryService {
const lib = c.get("specLibraryService" as never) as SpecLibraryService;
const db = c.get("rigRepoDb" as never) as Database.Database | undefined
?? (c.get("rigRepo" as never) as { db: Database.Database } | undefined)?.db;
const builtinDir = c.get("workflowBuiltinSpecsDir" as never) as string | undefined;
if (db) {
// Re-scan workflow specs on each list request — cheap (single
// SELECT against the workflow_specs cache) and means lens-driven
// surfaces always see the freshest cache state without a separate
// POST /sync.
const workflowEntries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: builtinDir ?? null });
lib.setWorkflowEntries(workflowEntries);
}
return lib;
}
// GET /active-lens — read the active workflow lens (if any).
// Mounted BEFORE /:id so the literal path doesn't get eaten by the
// bare-param catchall (Phase A R1 SSE route-order lesson).
router.get("/active-lens", (c) => {
const store = c.get("activeLensStore" as never) as ActiveLensStore | undefined;
if (!store) return c.json({ activeLens: null });
return c.json({ activeLens: store.get() });
});
// POST /active-lens — set / replace the active workflow lens.
router.post("/active-lens", async (c) => {
const store = c.get("activeLensStore" as never) as ActiveLensStore | undefined;
if (!store) return c.json({ error: "active_lens_unavailable" }, 503);
const body = await c.req.json<{ specName?: string; specVersion?: string }>().catch(() => ({} as { specName?: string; specVersion?: string }));
if (!body.specName || !body.specVersion) {
return c.json({ error: "specName and specVersion are required" }, 400);
}
const lens = store.set(body.specName, body.specVersion);
return c.json({ activeLens: lens });
});
// DELETE /active-lens — clear the active workflow lens.
router.delete("/active-lens", (c) => {
const store = c.get("activeLensStore" as never) as ActiveLensStore | undefined;
if (!store) return c.json({ error: "active_lens_unavailable" }, 503);
store.clear();
return c.json({ activeLens: null });
});
// GET / — list library entries
router.get("/", (c) => {
const lib = c.get("specLibraryService" as never) as SpecLibraryService;
const kind = c.req.query("kind") as "rig" | "agent" | undefined;
const lib = refreshWorkflowEntries(c);
const kind = c.req.query("kind") as "rig" | "agent" | "workflow" | undefined;
const entries = lib.list(kind ? { kind } : undefined);
return c.json(entries);
});
// GET /:id — entry metadata + YAML content
router.get("/:id", (c) => {
const lib = c.get("specLibraryService" as never) as SpecLibraryService;
const lib = refreshWorkflowEntries(c);
const id = c.req.param("id");
// Guard: don't match sub-paths like /review or /sync
if (id === "sync" || id === "review") return c.notFound();
// Guard: don't match sub-paths like /review or /sync or /active-lens
if (id === "sync" || id === "review" || id === "active-lens") return c.notFound();
const result = lib.get(id);
if (!result) {
@@ -33,7 +85,7 @@ export function specLibraryRoutes(): Hono {
// GET /:id/review — structured review with library provenance
router.get("/:id/review", (c) => {
const lib = c.get("specLibraryService" as never) as SpecLibraryService;
const lib = refreshWorkflowEntries(c);
const svc = c.get("specReviewService" as never) as SpecReviewService;
const id = c.req.param("id");
@@ -42,6 +94,27 @@ export function specLibraryRoutes(): Hono {
return c.json({ error: `Spec '${id}' not found in library` }, 404);
}
// Workflows in Spec Library v0: workflow review is a separate
// payload shape — topology graph + per-step list + source-path
// — projected from the workflow_specs SQLite cache.
if (result.entry.kind === "workflow") {
const parsed = parseWorkflowLibraryId(id);
if (!parsed) return c.json({ error: `Workflow library id '${id}' could not be parsed` }, 400);
const db = (c.get("rigRepo" as never) as { db: Database.Database } | undefined)?.db;
const builtinDir = c.get("workflowBuiltinSpecsDir" as never) as string | undefined;
if (!db) return c.json({ error: "workflow_specs_db_unavailable" }, 503);
const review = getWorkflowReview({
db,
workflowBuiltinSpecsDir: builtinDir ?? null,
name: parsed.name,
version: parsed.version,
});
if (!review) {
return c.json({ error: `Workflow spec '${parsed.name}' v${parsed.version} not in workflow_specs cache` }, 404);
}
return c.json({ ...review, libraryEntryId: id });
}
try {
let review: Record<string, unknown>;
if (result.entry.kind === "rig") {
+3
View File
@@ -170,6 +170,8 @@ export interface AppDeps {
missionControlBearerToken?: string | null;
specReviewService?: SpecReviewService;
specLibraryService?: SpecLibraryService;
/** Workflows in Spec Library v0 — active workflow lens persistence. */
activeLensStore?: import("./domain/active-lens-store.js").ActiveLensStore;
whoamiService?: WhoamiService;
contextUsageStore?: import("./domain/context-usage-store.js").ContextUsageStore;
contextMonitor?: { pollOnce(): Promise<void> };
@@ -344,6 +346,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("missionControlNotificationDispatcher" as never, deps.missionControlNotificationDispatcher);
c.set("specReviewService" as never, deps.specReviewService);
c.set("specLibraryService" as never, deps.specLibraryService);
c.set("activeLensStore" as never, deps.activeLensStore);
c.set("whoamiService" as never, deps.whoamiService);
c.set("contextUsageStore" as never, deps.contextUsageStore);
c.set("contextMonitor" as never, deps.contextMonitor);
+10
View File
@@ -761,6 +761,16 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// returns 503 with structured setup hint.
const { SteeringComposer, steeringOptsFromEnv } = await import("./domain/steering/steering-composer.js");
deps.steeringComposer = new SteeringComposer(steeringOptsFromEnv());
// Workflows in Spec Library + Activation Lens v0 — active lens
// persistence under OPENRIG_HOME/active-workflow-lens.json. Same
// file-backed pattern as UI Enhancement Pack v0's audit JSONL —
// honors OPENRIG_HOME so isolated test/dogfood daemons keep their
// own lens state instead of bleeding into the operator's host.
const { ActiveLensStore } = await import("./domain/active-lens-store.js");
deps.activeLensStore = new ActiveLensStore({
filePath: nodePath.join(OPENRIG_HOME, "active-workflow-lens.json"),
});
}
const sessionTransport = deps.sessionTransport;
@@ -0,0 +1,81 @@
// Workflows in Spec Library + Activation Lens v0 — active lens store tests.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ActiveLensStore } from "../src/domain/active-lens-store.js";
describe("ActiveLensStore (Workflows in Spec Library v0)", () => {
let tmp: string;
let filePath: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "active-lens-"));
filePath = join(tmp, "active-workflow-lens.json");
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("returns null when no lens file exists", () => {
const store = new ActiveLensStore({ filePath });
expect(store.get()).toBeNull();
});
it("set persists name + version + activatedAt timestamp", () => {
const store = new ActiveLensStore({
filePath,
now: () => new Date("2026-05-04T12:34:56Z"),
});
const lens = store.set("rsi-v2-hot-potato", "1");
expect(lens.specName).toBe("rsi-v2-hot-potato");
expect(lens.specVersion).toBe("1");
expect(lens.activatedAt).toBe("2026-05-04T12:34:56.000Z");
const re = store.get();
expect(re).toEqual(lens);
});
it("set replaces an existing lens (single-active invariant)", () => {
const store = new ActiveLensStore({ filePath });
store.set("first", "1");
store.set("second", "2");
const lens = store.get();
expect(lens?.specName).toBe("second");
expect(lens?.specVersion).toBe("2");
});
it("clear removes the lens file", () => {
const store = new ActiveLensStore({ filePath });
store.set("foo", "1");
expect(existsSync(filePath)).toBe(true);
store.clear();
expect(existsSync(filePath)).toBe(false);
expect(store.get()).toBeNull();
});
it("clear is a no-op when no file exists", () => {
const store = new ActiveLensStore({ filePath });
expect(() => store.clear()).not.toThrow();
});
it("returns null for malformed JSON", () => {
writeFileSync(filePath, "{not-json", "utf-8");
const store = new ActiveLensStore({ filePath });
expect(store.get()).toBeNull();
});
it("returns null when stored object is missing specName/specVersion", () => {
writeFileSync(filePath, JSON.stringify({ activatedAt: "2026-01-01T00:00:00Z" }), "utf-8");
const store = new ActiveLensStore({ filePath });
expect(store.get()).toBeNull();
});
it("creates parent directory lazily on first set", () => {
const nested = join(tmp, "does", "not", "exist", "lens.json");
const store = new ActiveLensStore({ filePath: nested });
store.set("foo", "1");
expect(existsSync(nested)).toBe(true);
});
});
@@ -0,0 +1,179 @@
// Workflows in Spec Library + Activation Lens v0 — boundToWorkflow filter.
//
// Pins that GET /api/slices?boundToWorkflow=<name>:<version> narrows the
// slice list to those whose primary workflow_instance binding matches the
// requested spec (name + version), via the same findSliceWorkflowBinding
// helper Slice Story View v1 uses for its detail projection.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path 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 { slicesRoutes } from "../src/routes/slices.js";
function buildApp(opts: { indexer: SliceIndexer; projector: SliceDetailProjector }): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sliceIndexer" as never, opts.indexer);
c.set("sliceDetailProjector" as never, opts.projector);
await next();
});
app.route("/api/slices", slicesRoutes());
return app;
}
function writeSlice(slicesRoot: string, name: string, body: string, qitemIds: string[]): void {
const dir = path.join(slicesRoot, name);
fs.mkdirSync(dir, { recursive: true });
const qitemBlock = qitemIds.length > 0
? `qitems:\n${qitemIds.map((q) => ` - ${q}`).join("\n")}\n`
: "";
fs.writeFileSync(
path.join(dir, "README.md"),
`---\nstatus: active\n${qitemBlock}---\n# ${body}`,
);
}
function ensureQitem(db: Database.Database, qitemId: string, body = "fixture"): void {
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', ?)`,
).run(qitemId, body);
}
function insertInstance(db: Database.Database, opts: {
instanceId: string;
workflowName: string;
workflowVersion: string;
currentFrontier: string[];
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', ?, 'active', ?, NULL, 0)`,
).run(
opts.instanceId,
opts.workflowName,
opts.workflowVersion,
opts.createdAt ?? "2026-05-04T00:00:00.000Z",
JSON.stringify(opts.currentFrontier),
);
}
describe("GET /api/slices?boundToWorkflow=<name>:<version>", () => {
let db: Database.Database;
let slicesRoot: string;
let cleanupDir: string;
let app: Hono;
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();
const base = fs.mkdtempSync(path.join(os.tmpdir(), "slices-bound-test-"));
cleanupDir = base;
slicesRoot = path.join(base, "slices");
fs.mkdirSync(slicesRoot, { recursive: true });
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
const projector = new SliceDetailProjector({ db, indexer });
app = buildApp({ indexer, projector });
});
afterEach(() => {
db.close();
fs.rmSync(cleanupDir, { recursive: true, force: true });
});
it("narrows the list to slices whose primary instance matches the spec", async () => {
const qBound = "01J0BOUND0000000000000000";
const qOther = "01J0OTHER0000000000000000";
// Body text mentions the slice name so SliceIndexer.matchQitems
// associates the qitem with the slice.
ensureQitem(db, qBound, "work for alpha");
ensureQitem(db, qOther, "work for beta");
writeSlice(slicesRoot, "alpha", "alpha slice", [qBound]);
writeSlice(slicesRoot, "beta", "beta slice", [qOther]);
insertInstance(db, {
instanceId: "inst-alpha",
workflowName: "rsi-v2-hot-potato",
workflowVersion: "1",
currentFrontier: [qBound],
});
insertInstance(db, {
instanceId: "inst-beta",
workflowName: "other-workflow",
workflowVersion: "1",
currentFrontier: [qOther],
});
const res = await app.request("/api/slices?boundToWorkflow=rsi-v2-hot-potato:1");
expect(res.status).toBe(200);
const body = await res.json() as {
slices: Array<{ name: string }>;
boundToWorkflow: { specName: string; specVersion: string; matched: number; total: number };
};
expect(body.slices.map((s) => s.name)).toEqual(["alpha"]);
expect(body.boundToWorkflow.specName).toBe("rsi-v2-hot-potato");
expect(body.boundToWorkflow.specVersion).toBe("1");
expect(body.boundToWorkflow.matched).toBe(1);
});
it("returns an empty list when no slice matches", async () => {
const q = "01J0NOMATCH00000000000000";
ensureQitem(db, q, "work for alpha");
writeSlice(slicesRoot, "alpha", "alpha slice", [q]);
insertInstance(db, {
instanceId: "inst-1",
workflowName: "actual",
workflowVersion: "1",
currentFrontier: [q],
});
const res = await app.request("/api/slices?boundToWorkflow=missing:1");
const body = await res.json() as { slices: unknown[]; boundToWorkflow: { matched: number } };
expect(body.slices).toHaveLength(0);
expect(body.boundToWorkflow.matched).toBe(0);
});
it("400s on malformed boundToWorkflow value (no colon)", async () => {
writeSlice(slicesRoot, "alpha", "alpha slice", []);
const res = await app.request("/api/slices?boundToWorkflow=just-name");
expect(res.status).toBe(400);
const body = await res.json() as { error: string };
expect(body.error).toBe("boundToWorkflow_invalid");
});
it("returns the unfiltered list when boundToWorkflow is absent", async () => {
writeSlice(slicesRoot, "alpha", "alpha slice", []);
writeSlice(slicesRoot, "beta", "beta slice", []);
const res = await app.request("/api/slices");
const body = await res.json() as { slices: Array<{ name: string }>; boundToWorkflow: unknown };
expect(body.slices.map((s) => s.name).sort()).toEqual(["alpha", "beta"]);
expect(body.boundToWorkflow).toBeNull();
});
});
@@ -0,0 +1,187 @@
// Workflows in Spec Library + Activation Lens v0 — route-level tests.
//
// Covers active-lens GET/POST/DELETE + workflow-kind library entry +
// /:id/review for workflow entries.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
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 { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
import { SpecLibraryService } from "../src/domain/spec-library-service.js";
import { SpecReviewService } from "../src/domain/spec-review-service.js";
import { ActiveLensStore } from "../src/domain/active-lens-store.js";
import { specLibraryRoutes } from "../src/routes/spec-library.js";
const SAMPLE_SPEC = (id: string) => `workflow:
id: ${id}
version: 1
objective: Sample workflow
target:
rig: sample-rig
entry:
role: alpha
roles:
alpha:
preferred_targets:
- alpha@sample-rig
beta:
preferred_targets:
- beta@sample-rig
steps:
- id: step-1
actor_role: alpha
objective: Start.
allowed_exits:
- handoff
next_hop:
suggested_roles:
- beta
- id: step-2
actor_role: beta
objective: End.
allowed_exits:
- done
invariants:
allowed_exits:
- handoff
- done
`;
describe("workflow library routes (Workflows in Spec Library v0)", () => {
let db: Database.Database;
let tmp: string;
let lensFilePath: string;
let builtinDir: string;
let cache: WorkflowSpecCache;
let lib: SpecLibraryService;
let lensStore: ActiveLensStore;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, workflowSpecsSchema]);
cache = new WorkflowSpecCache(db);
tmp = mkdtempSync(join(tmpdir(), "wf-lib-routes-"));
builtinDir = join(tmp, "builtins", "workflow-specs");
mkdirSync(builtinDir, { recursive: true });
lensFilePath = join(tmp, "active-workflow-lens.json");
const svc = new SpecReviewService();
lib = new SpecLibraryService({ roots: [], specReviewService: svc });
lib.scan();
lensStore = new ActiveLensStore({ filePath: lensFilePath });
});
afterEach(() => {
db.close();
rmSync(tmp, { recursive: true, force: true });
});
function createApp(): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("specLibraryService" as never, lib);
c.set("specReviewService" as never, new SpecReviewService());
c.set("activeLensStore" as never, lensStore);
c.set("rigRepo" as never, { db });
c.set("workflowBuiltinSpecsDir" as never, builtinDir);
await next();
});
app.route("/api/specs/library", specLibraryRoutes());
return app;
}
it("GET /active-lens returns null when no lens is set", async () => {
const app = createApp();
const res = await app.request("/api/specs/library/active-lens");
expect(res.status).toBe(200);
const body = await res.json() as { activeLens: unknown };
expect(body.activeLens).toBeNull();
});
it("POST /active-lens persists the lens; GET /active-lens returns it", async () => {
const app = createApp();
const post = await app.request("/api/specs/library/active-lens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specName: "rsi", specVersion: "1" }),
});
expect(post.status).toBe(200);
const postBody = await post.json() as { activeLens: { specName: string } };
expect(postBody.activeLens.specName).toBe("rsi");
const get = await app.request("/api/specs/library/active-lens");
const getBody = await get.json() as { activeLens: { specName: string; specVersion: string } };
expect(getBody.activeLens.specName).toBe("rsi");
expect(getBody.activeLens.specVersion).toBe("1");
});
it("POST /active-lens 400s when specName/specVersion missing", async () => {
const app = createApp();
const res = await app.request("/api/specs/library/active-lens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specName: "rsi" }),
});
expect(res.status).toBe(400);
});
it("DELETE /active-lens clears it", async () => {
const app = createApp();
lensStore.set("foo", "1");
const del = await app.request("/api/specs/library/active-lens", { method: "DELETE" });
expect(del.status).toBe(200);
expect(lensStore.get()).toBeNull();
});
it("GET / surfaces workflow entries from the workflow_specs cache", async () => {
const path = join(builtinDir, "alpha.yaml");
writeFileSync(path, SAMPLE_SPEC("alpha"));
cache.readThrough(path);
const app = createApp();
const res = await app.request("/api/specs/library");
expect(res.status).toBe(200);
const body = await res.json() as Array<{ id: string; kind: string; name: string; sourceType: string; isBuiltIn?: boolean }>;
const entry = body.find((e) => e.kind === "workflow")!;
expect(entry).toBeDefined();
expect(entry.id).toBe("workflow:alpha:1");
expect(entry.sourceType).toBe("builtin");
expect(entry.isBuiltIn).toBe(true);
});
it("GET /:id/review returns the topology graph for workflow entries", async () => {
const path = join(builtinDir, "alpha.yaml");
writeFileSync(path, SAMPLE_SPEC("alpha"));
cache.readThrough(path);
const app = createApp();
const res = await app.request("/api/specs/library/workflow:alpha:1/review");
expect(res.status).toBe(200);
const body = await res.json() as {
kind: string;
libraryEntryId: string;
topology: { nodes: unknown[]; edges: Array<{ fromStepId: string; toStepId: string }> };
steps: Array<{ stepId: string }>;
};
expect(body.kind).toBe("workflow");
expect(body.libraryEntryId).toBe("workflow:alpha:1");
expect(body.topology.nodes).toHaveLength(2);
expect(body.topology.edges).toEqual([
{ fromStepId: "step-1", toStepId: "step-2", routingType: "direct" },
]);
expect(body.steps.map((s) => s.stepId)).toEqual(["step-1", "step-2"]);
});
it("GET /:id/review 404s for an unknown workflow id", async () => {
const app = createApp();
const res = await app.request("/api/specs/library/workflow:missing:1/review");
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,247 @@
// Workflows in Spec Library v0 — scanner tests.
//
// Verifies that scanWorkflowSpecs reads from the workflow_specs SQLite
// cache, classifies built-in vs user_file via path.sep boundary, and
// projects the topology graph in the review payload.
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 { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
import {
scanWorkflowSpecs,
getWorkflowReview,
workflowLibraryId,
parseWorkflowLibraryId,
} from "../src/domain/spec-library-workflow-scanner.js";
const SAMPLE_SPEC = (id: string) => `workflow:
id: ${id}
version: 1
objective: Sample workflow
target:
rig: sample-rig
entry:
role: alpha
roles:
alpha:
preferred_targets:
- alpha@sample-rig
beta:
preferred_targets:
- beta@sample-rig
steps:
- id: step-1
actor_role: alpha
objective: Start the work.
allowed_exits:
- handoff
next_hop:
suggested_roles:
- beta
- id: step-2
actor_role: beta
objective: Finish the work.
allowed_exits:
- done
invariants:
allowed_exits:
- handoff
- done
`;
describe("scanWorkflowSpecs (Workflows in Spec Library v0)", () => {
let db: Database.Database;
let tmp: string;
let builtinDir: string;
let userDir: string;
let cache: WorkflowSpecCache;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, workflowSpecsSchema]);
cache = new WorkflowSpecCache(db);
tmp = mkdtempSync(join(tmpdir(), "wf-lib-scanner-"));
builtinDir = join(tmp, "builtins", "workflow-specs");
userDir = join(tmp, "user-specs");
mkdirSync(builtinDir, { recursive: true });
mkdirSync(userDir, { recursive: true });
});
afterEach(() => {
db.close();
rmSync(tmp, { recursive: true, force: true });
});
it("returns empty array when workflow_specs table is empty", () => {
const entries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: builtinDir });
expect(entries).toEqual([]);
});
it("classifies a spec under workflowBuiltinSpecsDir as builtin", () => {
const path = join(builtinDir, "alpha.yaml");
writeFileSync(path, SAMPLE_SPEC("alpha"));
cache.readThrough(path);
const entries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: builtinDir });
expect(entries).toHaveLength(1);
const entry = entries[0]!;
expect(entry.kind).toBe("workflow");
expect(entry.name).toBe("alpha");
expect(entry.sourceType).toBe("builtin");
expect(entry.isBuiltIn).toBe(true);
expect(entry.id).toBe("workflow:alpha:1");
expect(entry.rolesCount).toBe(2);
expect(entry.stepsCount).toBe(2);
expect(entry.targetRig).toBe("sample-rig");
});
it("classifies a spec outside workflowBuiltinSpecsDir as user_file", () => {
const path = join(userDir, "user.yaml");
writeFileSync(path, SAMPLE_SPEC("user-spec"));
cache.readThrough(path);
const entries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: builtinDir });
expect(entries).toHaveLength(1);
expect(entries[0]!.sourceType).toBe("user_file");
expect(entries[0]!.isBuiltIn).toBe(false);
});
it("treats sibling directory as NOT under builtin (path.sep boundary check)", () => {
// Create a spec at a sibling directory whose path STARTS with builtinDir
// but is not actually under it (e.g. /tmp/builtins-other/foo.yaml when
// builtinDir=/tmp/builtins). The scanner uses path.sep boundary so this
// must classify as user_file.
const sibling = builtinDir + "-sibling";
mkdirSync(sibling, { recursive: true });
const path = join(sibling, "spec.yaml");
writeFileSync(path, SAMPLE_SPEC("sibling"));
cache.readThrough(path);
const entries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: builtinDir });
const entry = entries.find((e) => e.name === "sibling")!;
expect(entry.sourceType).toBe("user_file");
});
it("returns null isBuiltIn classification when workflowBuiltinSpecsDir is null", () => {
const path = join(userDir, "user.yaml");
writeFileSync(path, SAMPLE_SPEC("user-spec"));
cache.readThrough(path);
const entries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: null });
expect(entries[0]!.isBuiltIn).toBe(false);
expect(entries[0]!.sourceType).toBe("user_file");
});
it("returns empty array gracefully when workflow_specs table is absent", () => {
const bareDb = createDb();
migrate(bareDb, [coreSchema]);
try {
const entries = scanWorkflowSpecs({ db: bareDb, workflowBuiltinSpecsDir: builtinDir });
expect(entries).toEqual([]);
} finally {
bareDb.close();
}
});
it("orders specs by name then version", () => {
const pathB = join(userDir, "b.yaml");
const pathA = join(userDir, "a.yaml");
writeFileSync(pathB, SAMPLE_SPEC("b-spec"));
writeFileSync(pathA, SAMPLE_SPEC("a-spec"));
cache.readThrough(pathB);
cache.readThrough(pathA);
const entries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: builtinDir });
expect(entries.map((e) => e.name)).toEqual(["a-spec", "b-spec"]);
});
});
describe("getWorkflowReview (Workflows in Spec Library v0)", () => {
let db: Database.Database;
let tmp: string;
let cache: WorkflowSpecCache;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, workflowSpecsSchema]);
cache = new WorkflowSpecCache(db);
tmp = mkdtempSync(join(tmpdir(), "wf-lib-review-"));
});
afterEach(() => {
db.close();
rmSync(tmp, { recursive: true, force: true });
});
it("projects topology with edges from next_hop.suggested_roles", () => {
const path = join(tmp, "spec.yaml");
writeFileSync(path, SAMPLE_SPEC("topo"));
cache.readThrough(path);
const review = getWorkflowReview({
db,
workflowBuiltinSpecsDir: null,
name: "topo",
version: "1",
});
expect(review).not.toBeNull();
expect(review!.topology.nodes).toHaveLength(2);
expect(review!.topology.edges).toHaveLength(1);
expect(review!.topology.edges[0]).toEqual({
fromStepId: "step-1",
toStepId: "step-2",
routingType: "direct",
});
});
it("marks the entry-role step as isEntry and the no-next-hop step as isTerminal", () => {
const path = join(tmp, "spec.yaml");
writeFileSync(path, SAMPLE_SPEC("entry-terminal"));
cache.readThrough(path);
const review = getWorkflowReview({
db,
workflowBuiltinSpecsDir: null,
name: "entry-terminal",
version: "1",
});
const step1 = review!.topology.nodes.find((n) => n.stepId === "step-1")!;
const step2 = review!.topology.nodes.find((n) => n.stepId === "step-2")!;
expect(step1.isEntry).toBe(true);
expect(step1.isTerminal).toBe(false);
expect(step2.isEntry).toBe(false);
expect(step2.isTerminal).toBe(true);
});
it("returns null for an unknown name+version", () => {
const review = getWorkflowReview({
db,
workflowBuiltinSpecsDir: null,
name: "missing",
version: "1",
});
expect(review).toBeNull();
});
});
describe("workflowLibraryId / parseWorkflowLibraryId", () => {
it("encodes and decodes name:version pairs", () => {
expect(workflowLibraryId("foo", "1")).toBe("workflow:foo:1");
expect(parseWorkflowLibraryId("workflow:foo:1")).toEqual({ name: "foo", version: "1" });
});
it("splits on the LAST colon so names with colons round-trip", () => {
const id = workflowLibraryId("rsi-v2-hot-potato", "1.2.3");
expect(parseWorkflowLibraryId(id)).toEqual({ name: "rsi-v2-hot-potato", version: "1.2.3" });
});
it("returns null for non-workflow ids", () => {
expect(parseWorkflowLibraryId("rig:foo:1")).toBeNull();
expect(parseWorkflowLibraryId("workflow:no-version")).toBeNull();
});
});
+263 -2
View File
@@ -1,8 +1,26 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import {
ReactFlow,
type Node,
type Edge,
Background,
Controls,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Button } from "@/components/ui/button";
import { WorkspacePage } from "./WorkspacePage.js";
import { useLibraryReview, useSpecLibrary, type LibraryRigReview, type LibraryAgentReview } from "../hooks/useSpecLibrary.js";
import {
useLibraryReview,
useSpecLibrary,
setActiveLens,
clearActiveLens,
useActiveLens,
type LibraryRigReview,
type LibraryAgentReview,
type LibraryWorkflowReview,
} from "../hooks/useSpecLibrary.js";
import { useQueryClient } from "@tanstack/react-query";
import {
WorkflowHeader,
WorkflowSummaryCard,
@@ -197,5 +215,248 @@ export function LibraryReview({ entryId }: LibraryReviewProps) {
return <LibraryAgentReviewPage review={review as LibraryAgentReview} />;
}
if (review.kind === "workflow") {
return <LibraryWorkflowReviewPage review={review as LibraryWorkflowReview} />;
}
return <LibraryRigReviewContent review={review as LibraryRigReview} />;
}
// --- Workflows in Spec Library v0: workflow review variant ---
const WF_NODE_WIDTH = 180;
const WF_NODE_HEIGHT = 56;
const WF_H_SPACING = 220;
const WF_V_SPACING = 110;
function WorkflowTopologyGraph({
topology,
testId,
}: {
topology: LibraryWorkflowReview["topology"];
testId?: string;
}) {
const { nodes, edges } = useMemo(() => {
// Simple BFS-based depth assignment from entry nodes.
const adj = new Map<string, string[]>();
for (const e of topology.edges) {
if (!adj.has(e.fromStepId)) adj.set(e.fromStepId, []);
adj.get(e.fromStepId)!.push(e.toStepId);
}
const depth = new Map<string, number>();
const queue: Array<{ id: string; d: number }> = [];
for (const n of topology.nodes) {
if (n.isEntry) {
depth.set(n.stepId, 0);
queue.push({ id: n.stepId, d: 0 });
}
}
while (queue.length > 0) {
const { id, d } = queue.shift()!;
const next = adj.get(id) ?? [];
for (const child of next) {
if (!depth.has(child) || (depth.get(child) ?? 0) < d + 1) {
depth.set(child, d + 1);
queue.push({ id: child, d: d + 1 });
}
}
}
// Fallback for orphan nodes.
let fallbackDepth = 0;
for (const n of topology.nodes) {
if (!depth.has(n.stepId)) {
depth.set(n.stepId, fallbackDepth++);
}
}
// Group by depth → assign x by index within depth.
const byDepth = new Map<number, string[]>();
for (const n of topology.nodes) {
const d = depth.get(n.stepId) ?? 0;
if (!byDepth.has(d)) byDepth.set(d, []);
byDepth.get(d)!.push(n.stepId);
}
const positions = new Map<string, { x: number; y: number }>();
for (const [d, ids] of byDepth) {
ids.forEach((id, idx) => {
positions.set(id, { x: idx * WF_H_SPACING, y: d * WF_V_SPACING });
});
}
const rfNodes: Node[] = topology.nodes.map((n) => {
const pos = positions.get(n.stepId) ?? { x: 0, y: 0 };
const accent = n.isEntry ? "#a8c8d4" : n.isTerminal ? "#d4b8a8" : "#d4c4a8";
return {
id: n.stepId,
type: "default",
position: pos,
data: {
label: (
<div className="font-mono text-[10px] leading-tight">
<div className="font-bold">{n.stepId}</div>
<div className="text-stone-600">{n.role}</div>
{n.preferredTarget && <div className="text-[8px] text-stone-500">→ {n.preferredTarget}</div>}
</div>
),
},
style: {
backgroundColor: accent,
border: "1px solid #8a8577",
fontSize: 11,
fontFamily: "monospace",
width: WF_NODE_WIDTH,
height: WF_NODE_HEIGHT,
padding: 6,
},
};
});
const rfEdges: Edge[] = topology.edges.map((e, i) => ({
id: `e-${i}`,
source: e.fromStepId,
target: e.toStepId,
style: { stroke: "#8a8577" },
}));
return { nodes: rfNodes, edges: rfEdges };
}, [topology]);
return (
<div data-testid={testId ?? "workflow-topology-graph"} className="w-full h-[400px] bg-stone-50 border border-stone-200">
<ReactFlow
nodes={nodes}
edges={edges}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
>
<Background gap={20} size={0.5} color="#d4d0c8" />
<Controls showInteractive={false} />
</ReactFlow>
</div>
);
}
function LibraryWorkflowReviewPage({ review }: { review: LibraryWorkflowReview }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: activeLens } = useActiveLens();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const isThisLensActive = activeLens?.specName === review.name && activeLens?.specVersion === review.version;
const activate = async () => {
setBusy(true);
setError(null);
try {
await setActiveLens(review.name, review.version);
await queryClient.invalidateQueries({ queryKey: ["spec-library", "active-lens"] });
await queryClient.invalidateQueries({ queryKey: ["slices"] });
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
const deactivate = async () => {
setBusy(true);
setError(null);
try {
await clearActiveLens();
await queryClient.invalidateQueries({ queryKey: ["spec-library", "active-lens"] });
await queryClient.invalidateQueries({ queryKey: ["slices"] });
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<WorkspacePage>
<div data-testid="library-review-workflow" className="space-y-6">
<WorkflowHeader
eyebrow={review.isBuiltIn ? "Library — Workflow (Built-in)" : "Library — Workflow"}
title={`${review.name} v${review.version}`}
description={review.purpose ?? "Workflow spec from library."}
actions={
<div className="flex gap-2 items-center">
{isThisLensActive ? (
<Button
variant="outline"
size="sm"
data-testid="workflow-deactivate-lens"
onClick={() => void deactivate()}
disabled={busy}
>
{busy ? "..." : "Deactivate Lens"}
</Button>
) : (
<Button
variant="outline"
size="sm"
data-testid="workflow-activate-lens"
onClick={() => void activate()}
disabled={busy}
>
{busy ? "..." : "Activate as Lens"}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back</Button>
</div>
}
/>
<ProvenanceBadge sourcePath={review.sourcePath} sourceState="library_item" />
{error && <div data-testid="workflow-lens-error" className="font-mono text-[10px] text-red-600">{error}</div>}
<WorkflowSummaryGrid>
<WorkflowSummaryCard label="Format" value="WorkflowSpec" testId="lib-wf-format" />
<WorkflowSummaryCard label="Version" value={review.version} testId="lib-wf-version" />
<WorkflowSummaryCard label="Roles" value={review.rolesCount} testId="lib-wf-roles" />
<WorkflowSummaryCard label="Steps" value={review.stepsCount} testId="lib-wf-steps" />
<WorkflowSummaryCard label="Target Rig" value={review.targetRig ?? "(any)"} testId="lib-wf-target-rig" />
<WorkflowSummaryCard label="Source" value={review.isBuiltIn ? "built-in" : "user file"} testId="lib-wf-source" />
</WorkflowSummaryGrid>
<div data-testid="workflow-terminal-rule" className="border border-stone-300/40 bg-white/10 px-3 py-2 font-mono text-[10px] text-stone-700">
<span className="text-stone-500 uppercase tracking-[0.16em] text-[8px] mr-2">Coordination Terminal Turn:</span>
{review.terminalTurnRule}
</div>
<WorkflowTopologyGraph topology={review.topology} />
<div className="space-y-2">
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">Steps</div>
<div className="space-y-1">
{review.steps.map((step) => (
<div
key={step.stepId}
data-testid={`workflow-step-${step.stepId}`}
className="border border-stone-300/40 bg-white/5 px-3 py-2"
>
<div className="flex items-center justify-between">
<span className="font-mono text-[11px] font-bold text-stone-800">{step.stepId}</span>
<span className="font-mono text-[9px] text-stone-500">{step.role}</span>
</div>
{step.objective && <div className="mt-1 text-[10px] text-stone-600 leading-tight">{step.objective}</div>}
{step.allowedNextSteps.length > 0 && (
<div className="mt-1 font-mono text-[9px] text-stone-500">
next: {step.allowedNextSteps.map((n) => `${n.stepId} (${n.role})`).join(", ")}
</div>
)}
{step.allowedExits.length > 0 && (
<div className="font-mono text-[9px] text-stone-400">
exits: {step.allowedExits.join(", ")}
</div>
)}
</div>
))}
</div>
</div>
</div>
</WorkspacePage>
);
}
+5 -3
View File
@@ -7,7 +7,7 @@ import { usePsEntries } from "../hooks/usePsEntries.js";
import { useExpandRig, useRemoveLibrarySpec, useRenameLibrarySpec, type ExpandRigResult } from "../hooks/mutations.js";
import { ExpansionOutcome } from "./ExpansionOutcome.js";
type LibraryFilter = "all" | "apps" | "rigs" | "agents";
type LibraryFilter = "all" | "apps" | "rigs" | "agents" | "workflows";
function deriveStability(entry: SpecLibraryEntry): "Stable" | "Experimental" | "Community" {
if (entry.sourceType !== "builtin") return "Community";
@@ -16,7 +16,8 @@ function deriveStability(entry: SpecLibraryEntry): "Stable" | "Experimental" | "
return "Experimental";
}
function deriveTypeBadge(entry: SpecLibraryEntry): "APP" | "RIG" | "AGENT" {
function deriveTypeBadge(entry: SpecLibraryEntry): "APP" | "RIG" | "AGENT" | "WORKFLOW" {
if (entry.kind === "workflow") return "WORKFLOW";
if (entry.kind === "agent") return "AGENT";
if (entry.hasServices) return "APP";
return "RIG";
@@ -27,6 +28,7 @@ function filterEntries(entries: SpecLibraryEntry[], filter: LibraryFilter): Spec
case "apps": return entries.filter((e) => e.kind === "rig" && e.hasServices);
case "rigs": return entries.filter((e) => e.kind === "rig" && !e.hasServices);
case "agents": return entries.filter((e) => e.kind === "agent");
case "workflows": return entries.filter((e) => e.kind === "workflow");
default: return entries;
}
}
@@ -463,7 +465,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
<div className="space-y-2">
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">Library</div>
<div className="flex gap-1">
{(["all", "apps", "rigs", "agents"] as LibraryFilter[]).map((f) => (
{(["all", "apps", "rigs", "agents", "workflows"] as LibraryFilter[]).map((f) => (
<button
key={f}
data-testid={`filter-${f}`}
@@ -6,7 +6,9 @@
import { useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { useSlices, type SliceFilter, type SliceListEntry, type SliceListResponse, type SlicesUnavailable } from "../../hooks/useSlices.js";
import { useActiveLens, clearActiveLens } from "../../hooks/useSpecLibrary.js";
const FILTERS: SliceFilter[] = ["all", "active", "done", "blocked"];
@@ -18,7 +20,20 @@ export function SliceExplorerPanel({ onNavigate = () => {} }: { onNavigate?: ()
const params = useParams({ strict: false }) as { name?: string };
const selectedName = params.name ?? null;
const [filter, setFilter] = useState<SliceFilter>("active");
const list = useSlices(filter);
const [showAll, setShowAll] = useState(false);
const queryClient = useQueryClient();
const { data: activeLens } = useActiveLens();
const lensActive = !!activeLens && !showAll;
const list = useSlices(
filter,
lensActive ? { specName: activeLens!.specName, specVersion: activeLens!.specVersion } : null,
);
const onClearLens = async () => {
await clearActiveLens();
await queryClient.invalidateQueries({ queryKey: ["spec-library", "active-lens"] });
await queryClient.invalidateQueries({ queryKey: ["slices"] });
};
return (
<div data-testid="slice-list-pane" className="flex h-full flex-col">
@@ -42,6 +57,34 @@ export function SliceExplorerPanel({ onNavigate = () => {} }: { onNavigate?: ()
</button>
))}
</div>
{activeLens && (
<div
data-testid="slice-lens-indicator"
className="mt-2 border border-stone-300 bg-stone-50 px-2 py-1 font-mono text-[9px] text-stone-700 space-y-1"
>
<div className="flex items-center justify-between">
<span className="truncate">
Lens: {activeLens.specName} v{activeLens.specVersion}
</span>
<button
type="button"
data-testid="slice-lens-clear"
onClick={() => void onClearLens()}
className="ml-2 shrink-0 border border-stone-300 px-1 py-0.5 text-[8px] uppercase tracking-[0.10em] text-stone-600 hover:bg-stone-200"
>
Clear
</button>
</div>
<button
type="button"
data-testid="slice-lens-show-all-toggle"
onClick={() => setShowAll((v) => !v)}
className="w-full border border-stone-300 px-1 py-0.5 text-[8px] uppercase tracking-[0.10em] text-stone-600 hover:bg-stone-200"
>
{showAll ? "Apply Lens Filter" : "Show All Slices"}
</button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto" data-testid="slice-list">
@@ -54,7 +97,11 @@ export function SliceExplorerPanel({ onNavigate = () => {} }: { onNavigate?: ()
</div>
)}
{!isUnavailable(list.data) && list.data && list.data.slices.length === 0 && (
<div className="p-3 font-mono text-[10px] text-stone-400">No slices match.</div>
<div className="p-3 font-mono text-[10px] text-stone-400" data-testid="slice-list-empty">
{lensActive
? `No slices bound to ${activeLens?.specName} v${activeLens?.specVersion}.`
: "No slices match."}
</div>
)}
{!isUnavailable(list.data) && list.data && list.data.slices.map((slice) => (
<SliceListRow
+29 -5
View File
@@ -25,6 +25,13 @@ export interface SliceListResponse {
slices: SliceListEntry[];
totalCount: number;
filter: SliceFilter;
// Workflows in Spec Library v0 — present only when boundToWorkflow filter applied.
boundToWorkflow?: {
specName: string;
specVersion: string;
matched: number;
total: number;
};
}
export interface SlicesUnavailable {
@@ -33,8 +40,20 @@ export interface SlicesUnavailable {
hint?: string;
}
async function fetchSlicesList(filter: SliceFilter): Promise<SliceListResponse | SlicesUnavailable> {
const res = await fetch(`/api/slices?filter=${encodeURIComponent(filter)}`);
export interface BoundToWorkflowFilter {
specName: string;
specVersion: string;
}
async function fetchSlicesList(
filter: SliceFilter,
boundToWorkflow: BoundToWorkflowFilter | null,
): Promise<SliceListResponse | SlicesUnavailable> {
const params = new URLSearchParams({ filter });
if (boundToWorkflow) {
params.set("boundToWorkflow", `${boundToWorkflow.specName}:${boundToWorkflow.specVersion}`);
}
const res = await fetch(`/api/slices?${params.toString()}`);
if (res.status === 503) {
const body = (await res.json().catch(() => ({}))) as Partial<SlicesUnavailable> & { error?: string; hint?: string };
return {
@@ -47,10 +66,15 @@ async function fetchSlicesList(filter: SliceFilter): Promise<SliceListResponse |
return (await res.json()) as SliceListResponse;
}
export function useSlices(filter: SliceFilter) {
export function useSlices(filter: SliceFilter, boundToWorkflow: BoundToWorkflowFilter | null = null) {
return useQuery({
queryKey: ["slices", "list", filter],
queryFn: () => fetchSlicesList(filter),
queryKey: [
"slices",
"list",
filter,
boundToWorkflow ? `${boundToWorkflow.specName}:${boundToWorkflow.specVersion}` : "all",
],
queryFn: () => fetchSlicesList(filter, boundToWorkflow),
staleTime: 30_000,
});
}
+89 -4
View File
@@ -1,9 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import type { RigSpecReview, AgentSpecReview } from "./useSpecReview.js";
export type SpecLibraryKind = "rig" | "agent" | "workflow";
export interface SpecLibraryEntry {
id: string;
kind: "rig" | "agent";
kind: SpecLibraryKind;
name: string;
version: string;
sourceType: "builtin" | "user_file";
@@ -12,6 +14,12 @@ export interface SpecLibraryEntry {
updatedAt: string;
summary?: string;
hasServices?: boolean;
// Workflows in Spec Library v0 — workflow-only metadata.
isBuiltIn?: boolean;
rolesCount?: number;
stepsCount?: number;
terminalTurnRule?: string;
targetRig?: string | null;
}
export interface LibraryReview {
@@ -23,14 +31,52 @@ export interface LibraryReview {
export type LibraryRigReview = RigSpecReview & LibraryReview;
export type LibraryAgentReview = AgentSpecReview & LibraryReview;
async function fetchLibraryEntries(kind?: "rig" | "agent"): Promise<SpecLibraryEntry[]> {
// Workflows in Spec Library v0 — workflow review payload shape.
export interface LibraryWorkflowReview {
kind: "workflow";
libraryEntryId: string;
name: string;
version: string;
purpose: string | null;
targetRig: string | null;
terminalTurnRule: string;
rolesCount: number;
stepsCount: number;
isBuiltIn: boolean;
sourcePath: string;
cachedAt: string;
topology: {
nodes: Array<{
stepId: string;
role: string;
objective: string | null;
preferredTarget: string | null;
isEntry: boolean;
isTerminal: boolean;
}>;
edges: Array<{
fromStepId: string;
toStepId: string;
routingType: "direct";
}>;
};
steps: Array<{
stepId: string;
role: string;
objective: string | null;
allowedExits: string[];
allowedNextSteps: Array<{ stepId: string; role: string }>;
}>;
}
async function fetchLibraryEntries(kind?: SpecLibraryKind): Promise<SpecLibraryEntry[]> {
const url = kind ? `/api/specs/library?kind=${kind}` : "/api/specs/library";
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function fetchLibraryReview(id: string): Promise<LibraryRigReview | LibraryAgentReview> {
async function fetchLibraryReview(id: string): Promise<LibraryRigReview | LibraryAgentReview | LibraryWorkflowReview> {
const res = await fetch(`/api/specs/library/${encodeURIComponent(id)}/review`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -39,7 +85,7 @@ async function fetchLibraryReview(id: string): Promise<LibraryRigReview | Librar
return res.json();
}
export function useSpecLibrary(kind?: "rig" | "agent") {
export function useSpecLibrary(kind?: SpecLibraryKind) {
return useQuery({
queryKey: ["spec-library", kind ?? "all"],
queryFn: () => fetchLibraryEntries(kind),
@@ -53,3 +99,42 @@ export function useLibraryReview(id: string | null) {
enabled: !!id,
});
}
// --- Workflows in Spec Library v0: active lens hook ---
export interface ActiveLensPayload {
specName: string;
specVersion: string;
activatedAt: string;
}
async function fetchActiveLens(): Promise<ActiveLensPayload | null> {
const res = await fetch("/api/specs/library/active-lens");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { activeLens: ActiveLensPayload | null };
return body.activeLens ?? null;
}
export function useActiveLens() {
return useQuery({
queryKey: ["spec-library", "active-lens"],
queryFn: fetchActiveLens,
staleTime: 0,
});
}
export async function setActiveLens(specName: string, specVersion: string): Promise<ActiveLensPayload | null> {
const res = await fetch("/api/specs/library/active-lens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ specName, specVersion }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { activeLens: ActiveLensPayload | null };
return body.activeLens ?? null;
}
export async function clearActiveLens(): Promise<void> {
const res = await fetch("/api/specs/library/active-lens", { method: "DELETE" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
+78
View File
@@ -233,6 +233,84 @@ describe("LibraryReview", () => {
});
});
it("renders workflow review with topology graph + steps + Activate as Lens (Workflows in Spec Library v0)", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/specs/library/active-lens" && (!init || !init.method || init.method === "GET")) {
return new Response(JSON.stringify({ activeLens: null }), { status: 200 });
}
if (url === "/api/specs/library/active-lens" && init?.method === "POST") {
return new Response(JSON.stringify({
activeLens: { specName: "rsi-v2-hot-potato", specVersion: "1", activatedAt: "2026-05-04T00:00:00Z" },
}), { status: 200 });
}
const decoded = decodeURIComponent(url);
if (decoded === "/api/specs/library/workflow:rsi-v2-hot-potato:1/review") {
return new Response(JSON.stringify({
kind: "workflow",
libraryEntryId: "workflow:rsi-v2-hot-potato:1",
name: "rsi-v2-hot-potato",
version: "1",
purpose: "Hot-potato workflow for RSI",
targetRig: null,
terminalTurnRule: "hot_potato",
rolesCount: 2,
stepsCount: 2,
isBuiltIn: true,
sourcePath: "/builtins/workflow-specs/rsi-v2-hot-potato.yaml",
cachedAt: "2026-05-04T00:00:00Z",
topology: {
nodes: [
{ stepId: "step-1", role: "alpha", objective: "Start", preferredTarget: "alpha@rig", isEntry: true, isTerminal: false },
{ stepId: "step-2", role: "beta", objective: "End", preferredTarget: null, isEntry: false, isTerminal: true },
],
edges: [{ fromStepId: "step-1", toStepId: "step-2", routingType: "direct" }],
},
steps: [
{ stepId: "step-1", role: "alpha", objective: "Start", allowedExits: ["handoff"], allowedNextSteps: [{ stepId: "step-2", role: "beta" }] },
{ stepId: "step-2", role: "beta", objective: "End", allowedExits: ["done"], allowedNextSteps: [] },
],
}), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url} (method=${init?.method ?? "GET"})`);
});
vi.stubGlobal("fetch", fetchMock);
render(createAppTestRouter({
initialPath: "/specs/library/workflow:rsi-v2-hot-potato:1",
routes: [
{ path: "/specs/library/workflow:rsi-v2-hot-potato:1", component: () => <LibraryReview entryId="workflow:rsi-v2-hot-potato:1" /> },
{ path: "/specs", component: () => <div data-testid="back-route">back</div> },
],
}));
await waitFor(() => {
expect(screen.getByTestId("library-review-workflow")).toBeDefined();
});
expect(screen.getByText("rsi-v2-hot-potato v1")).toBeDefined();
expect(screen.getByText("Hot-potato workflow for RSI")).toBeDefined();
expect(screen.getByTestId("workflow-topology-graph")).toBeDefined();
expect(screen.getByTestId("workflow-step-step-1")).toBeDefined();
expect(screen.getByTestId("workflow-step-step-2")).toBeDefined();
const terminal = screen.getByTestId("workflow-terminal-rule");
expect(terminal.textContent).toContain("hot_potato");
const activateBtn = screen.getByTestId("workflow-activate-lens");
expect(activateBtn).toBeDefined();
fireEvent.click(activateBtn);
await waitFor(() => {
expect(fetchMock.mock.calls.some((call) =>
String(call[0]) === "/api/specs/library/active-lens"
&& (call[1] as RequestInit | undefined)?.method === "POST",
)).toBe(true);
});
});
it("opens the matching agent spec from a rig member row", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
@@ -0,0 +1,134 @@
// Workflows in Spec Library + Activation Lens v0 — lens consumption.
//
// Verifies that SliceExplorerPanel reads /api/specs/library/active-lens,
// passes boundToWorkflow=<name>:<version> to /api/slices when a lens is
// active, renders the lens indicator + Clear button + Show All toggle.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { createTestRouter } from "./helpers/test-router.js";
import { SliceExplorerPanel } from "../src/components/slices/SliceExplorerPanel.js";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Response;
}
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
mockFetch.mockReset();
});
afterEach(() => cleanup());
describe("SliceExplorerPanel — active lens consumption (Workflows in Spec Library v0)", () => {
it("does not render the lens indicator when no lens is active", async () => {
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
return jsonResponse({ activeLens: null });
}
return jsonResponse({ slices: [], totalCount: 0, filter: "active" });
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-list-pane")).toBeDefined());
expect(screen.queryByTestId("slice-lens-indicator")).toBeNull();
});
it("renders lens indicator + sends boundToWorkflow to /api/slices when lens is active", async () => {
const sliceCalls: string[] = [];
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
return jsonResponse({
activeLens: { specName: "rsi-v2-hot-potato", specVersion: "1", activatedAt: "2026-05-04T00:00:00Z" },
});
}
if (url.includes("/api/slices")) {
sliceCalls.push(url);
return jsonResponse({
slices: [{
name: "alpha", displayName: "Alpha", railItem: null, status: "active", rawStatus: "active",
qitemCount: 1, hasProofPacket: false, lastActivityAt: "2026-05-04T00:00:00Z",
}],
totalCount: 1,
filter: "active",
boundToWorkflow: { specName: "rsi-v2-hot-potato", specVersion: "1", matched: 1, total: 5 },
});
}
return jsonResponse({});
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-lens-indicator")).toBeDefined());
expect(screen.getByTestId("slice-lens-indicator").textContent).toContain("rsi-v2-hot-potato");
expect(screen.getByTestId("slice-lens-indicator").textContent).toContain("v1");
await waitFor(() => {
expect(sliceCalls.some((u) => u.includes("boundToWorkflow=rsi-v2-hot-potato%3A1") || u.includes("boundToWorkflow=rsi-v2-hot-potato:1"))).toBe(true);
});
});
it("Show All toggle clears the boundToWorkflow filter param", async () => {
const sliceCalls: string[] = [];
mockFetch.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
return jsonResponse({
activeLens: { specName: "rsi", specVersion: "1", activatedAt: "2026-05-04T00:00:00Z" },
});
}
if (url.includes("/api/slices")) {
sliceCalls.push(url);
return jsonResponse({ slices: [], totalCount: 0, filter: "active" });
}
return jsonResponse({});
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-lens-show-all-toggle")).toBeDefined());
sliceCalls.length = 0;
fireEvent.click(screen.getByTestId("slice-lens-show-all-toggle"));
await waitFor(() => {
// After toggle, expect at least one slice fetch without boundToWorkflow
expect(sliceCalls.some((u) => !u.includes("boundToWorkflow"))).toBe(true);
});
});
it("Clear button calls DELETE /api/specs/library/active-lens", async () => {
let deleteCalled = false;
mockFetch.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/specs/library/active-lens")) {
if (init?.method === "DELETE") {
deleteCalled = true;
return jsonResponse({ activeLens: null });
}
return jsonResponse({
activeLens: { specName: "rsi", specVersion: "1", activatedAt: "2026-05-04T00:00:00Z" },
});
}
if (url.includes("/api/slices")) {
return jsonResponse({ slices: [], totalCount: 0, filter: "active" });
}
return jsonResponse({});
});
render(createTestRouter({ component: () => <SliceExplorerPanel />, path: "/slices" }));
await waitFor(() => expect(screen.getByTestId("slice-lens-clear")).toBeDefined());
fireEvent.click(screen.getByTestId("slice-lens-clear"));
await waitFor(() => expect(deleteCalled).toBe(true));
});
});
+104
View File
@@ -281,3 +281,107 @@ describe("SpecsPanel filter chips and richer rows", () => {
});
});
});
describe("SpecsPanel Workflows filter chip (Workflows in Spec Library v0)", () => {
const origFetch = globalThis.fetch;
const mockFetch2 = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch2 as unknown as typeof fetch;
mockFetch2.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/api/specs/library") && !url.includes("kind=")) {
return {
ok: true,
json: async () => [
{
id: "rig-1",
kind: "rig",
name: "demo",
version: "0.2",
sourceType: "builtin",
sourcePath: "/specs/rigs/launch/demo/rig.yaml",
relativePath: "rigs/launch/demo/rig.yaml",
updatedAt: "2026-04-09T00:00:00Z",
},
{
id: "agent-1",
kind: "agent",
name: "implementer",
version: "1.0",
sourceType: "builtin",
sourcePath: "/specs/agents/development/implementer/agent.yaml",
relativePath: "agents/development/implementer/agent.yaml",
updatedAt: "2026-04-09T00:00:00Z",
},
{
id: "workflow:rsi-v2-hot-potato:1",
kind: "workflow",
name: "rsi-v2-hot-potato",
version: "1",
sourceType: "builtin",
sourcePath: "/builtins/workflow-specs/rsi-v2-hot-potato.yaml",
relativePath: "rsi-v2-hot-potato.yaml",
updatedAt: "2026-05-04T00:00:00Z",
isBuiltIn: true,
rolesCount: 3,
stepsCount: 4,
terminalTurnRule: "hot_potato",
targetRig: null,
},
],
};
}
if (typeof url === "string" && url.includes("/api/specs/library") && url.includes("kind=")) {
return { ok: true, json: async () => [] };
}
return { ok: true, json: async () => [] };
});
});
afterEach(() => {
globalThis.fetch = origFetch;
window.localStorage.clear();
cleanup();
});
function renderFilterPanel() {
const onClose = vi.fn();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={qc}>
{createAppTestRouter({
initialPath: "/",
routes: [
{ path: "/", component: () => <div>home</div> },
{ path: "/specs/library/$entryId", component: () => <div data-testid="library-review">review</div> },
],
rootComponent: ({ children }) => (
<SpecsWorkspaceProvider>
<SpecsPanel onClose={onClose} />
{children}
</SpecsWorkspaceProvider>
),
})}
</QueryClientProvider>
);
}
it("renders the Workflows filter chip and a workflow row with WORKFLOW badge", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("rsi-v2-hot-potato")).toBeDefined());
expect(screen.getByTestId("filter-workflows")).toBeDefined();
const wfRow = screen.getByTestId("library-entry-workflow:rsi-v2-hot-potato:1");
expect(wfRow.textContent).toContain("WORKFLOW");
});
it("clicking Workflows filter shows only workflow entries", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("rsi-v2-hot-potato")).toBeDefined());
fireEvent.click(screen.getByTestId("filter-workflows"));
await waitFor(() => {
expect(screen.getByText("rsi-v2-hot-potato")).toBeDefined();
expect(screen.queryByText("demo")).toBeNull();
expect(screen.queryByText("implementer")).toBeNull();
});
});
});