mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user