feat(slice-11): workflow-spec folder discovery + diagnostic rows

release-0.3.1 slice 11 (workflow-spec-folder-discovery) — workflow
specs as an installable user primitive. Operators drop YAML files in
`<workspace.specs_root>/workflows/`; the daemon's spec-library route
opportunistically scans the folder on each list request, parses +
validates each file, populates the workflow_specs cache, and surfaces
both valid + diagnostic rows so the Library UI can render in-place
errors for malformed YAML.

SC-29 #10 (verbatim declaration):
"Slice 11 (workflow-spec-folder-discovery) requires schema migration
040_workflow_specs_diagnostic.ts adding status TEXT DEFAULT 'valid' +
error_message TEXT columns to the workflow_specs cache. No new table,
no constraint changes beyond default; ALTER TABLE ADD COLUMN preserves
existing rows (default 'valid' fills retroactively for already-cached
rows). Read-only diagnostic surface — the cache stores parser/
validator errors so the Library UI can render them; daemon does not
act on the diagnostic state. Per IMPL-PRD §HG-8 'unless provenance /
status columns require migration — declare upfront if so': declared
upfront in this slice's ACK + commit body."

Behavior contract (4 OQs):
- OQ-1: canonical roots — folder = `<workspace.specs_root>/workflows`.
- OQ-2: diagnostic surface — workflow_specs cache rows carry status +
  error_message; scanner surfaces error rows with synthetic id
  `workflow:error:<source_path>` and zero counts.
- OQ-3: scanner trigger — opportunistic on each GET /api/specs/library;
  mtime check at second-resolution skips unchanged files.
- OQ-4: deletion representation — folder-scope-bounded removal; rows
  whose source_path falls under the scanned folder but no longer on
  disk are deleted from the cache.

Implementation:
- packages/daemon/src/db/migrations/040_workflow_specs_diagnostic.ts
  — idempotent ALTER TABLE ADD COLUMN (status, error_message).
- packages/daemon/src/domain/workflow-spec-cache.ts — writeDiagnostic +
  removeBySourcePath; diagnostic rows store basename as name when YAML
  unparseable.
- packages/daemon/src/domain/spec-library-workflow-scanner.ts —
  scanWorkflowSpecFolder filesystem walk; mtime skip; folder-scope
  deletion; extended scanWorkflowSpecs to emit diagnostic entries with
  status + errorMessage on SpecLibraryWorkflowEntry.
- packages/daemon/src/routes/spec-library.ts — refreshWorkflowEntries
  now triggers scanWorkflowSpecFolder before scanWorkflowSpecs when
  the workflowSpecCache + workflowsFolderDir context vars are wired.
- packages/daemon/src/startup.ts + server.ts — wire the new context
  vars (workflowSpecCache = workflowRuntime.specCache; workflowsFolderDir
  = `<workspace.specs_root>/workflows`) from SettingsStore.
- packages/ui/src/components/specs/SpecsLibraryPage.tsx +
  hooks/useSpecLibrary.ts — diagnostic rows render non-navigable with
  error styling + errorMessage inline; valid rows still link to
  /specs/library/$entryId.

Tests (all green):
- packages/daemon/test/workflow-specs-diagnostic-migration.test.ts (9)
- packages/daemon/test/workflow-spec-folder-scanner.test.ts (8)
- packages/daemon/test/spec-library-workflow-folder-route.test.ts (5)
- packages/ui/test/library-page-workflow-diagnostic.test.tsx (2)
- daemon full sweep: 3158/3158 green.

HG-13 (dogfood-time, NOT this commit): per banked
feedback_daemon_migration_db_isolation, the Tart VM dogfood pass MUST
set OPENRIG_DB explicitly before any daemon start that runs migration
040 — OPENRIG_HOME alone is not sufficient for migration-touching
slices.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 23:09:21 -07:00
1 parent a2d45687fa
commit f68f453aec
13 files changed
+1107 -5

No files matched your search

@@ -0,0 +1,38 @@
import type { Migration } from "../migrate.js";
/**
* Slice 11 (release-0.3.1 workflow-spec-folder-discovery) —
* workflow_specs diagnostic columns.
*
* SC-29 #10 (verbatim, declared in commit body):
* "Slice 11 (workflow-spec-folder-discovery) requires schema
* migration 040_workflow_specs_diagnostic.ts adding
* status TEXT DEFAULT 'valid' + error_message TEXT columns to the
* workflow_specs cache. No new table, no constraint changes beyond
* default; ALTER TABLE ADD COLUMN preserves existing rows (default
* 'valid' fills retroactively for already-cached rows). Read-only
* diagnostic surface — the cache stores parser/validator errors
* so the Library UI can render them; daemon does not act on the
* diagnostic state. Per IMPL-PRD §HG-8 'unless provenance / status
* columns require migration — declare upfront if so': declared
* upfront in slice 11 ACK + commit body."
*
* Columns:
* - status TEXT NOT NULL DEFAULT 'valid' — one of 'valid' | 'error'.
* Existing rows pre-040 retroactively gain 'valid' via the
* DEFAULT clause when ALTER TABLE ADD COLUMN populates them.
* - error_message TEXT NULL — populated only when status='error';
* carries the parse/validation diagnostic for the UI to render.
*
* Provenance is NOT a new column — derived at scan time from
* source_path relative to the daemon's bundled-builtin starter dir
* (existing scanner logic at spec-library-workflow-scanner.ts
* already does isUnderDir(source_path, workflowBuiltinSpecsDir)).
*/
export const workflowSpecsDiagnosticSchema: Migration = {
name: "040_workflow_specs_diagnostic.sql",
sql: `
ALTER TABLE workflow_specs ADD COLUMN status TEXT NOT NULL DEFAULT 'valid';
ALTER TABLE workflow_specs ADD COLUMN error_message TEXT;
`,
};
@@ -21,6 +21,12 @@ export interface SpecLibraryEntry {
stepsCount?: number;
terminalTurnRule?: string;
targetRig?: string | null;
/** Slice 11 (workflow-spec-folder-discovery) — diagnostic state for
* workflow entries surfaced from the folder scan. "error" rows came
* from malformed YAML and carry the parse/validate reason in
* errorMessage so the Library UI can render a diagnostic row. */
status?: "valid" | "error";
errorMessage?: string | null;
}
export interface SpecLibraryOpts {
@@ -20,8 +20,11 @@
// boundary check used by /api/workflow/specs).
import * as path from "node:path";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import type Database from "better-sqlite3";
import type { WorkflowSpec } from "./workflow-types.js";
import type { WorkflowSpecCache } from "./workflow-spec-cache.js";
export interface SpecLibraryWorkflowEntry {
id: string;
@@ -41,6 +44,16 @@ export interface SpecLibraryWorkflowEntry {
stepsCount: number;
terminalTurnRule: string;
targetRig: string | null;
/**
* Slice 11 (workflow-spec-folder-discovery) — diagnostic state.
* "valid" rows have parsed payload and are operable.
* "error" rows came from a malformed YAML in the workflows folder
* scan; Library UI renders them with error styling and surfaces
* the errorMessage so the operator can fix the file in place.
*/
status: "valid" | "error";
/** Populated only when status === "error" — concrete parse/validate diagnostic. */
errorMessage: string | null;
}
export interface SpecLibraryWorkflowReview {
@@ -104,6 +117,11 @@ interface SpecRow {
source_path: string;
source_hash: string;
cached_at: string;
// Slice 11 — diagnostic columns from migration 040. May be missing
// when the test harness applies only the 033 schema; treat absence
// as status='valid', error_message=null to preserve back-compat.
status?: string;
error_message?: string | null;
}
export function scanWorkflowSpecs(opts: ScanWorkflowSpecsOpts): SpecLibraryWorkflowEntry[] {
@@ -121,6 +139,39 @@ export function scanWorkflowSpecs(opts: ScanWorkflowSpecsOpts): SpecLibraryWorkf
const out: SpecLibraryWorkflowEntry[] = [];
for (const row of rows) {
const status: "valid" | "error" = row.status === "error" ? "error" : "valid";
const isBuiltIn = opts.workflowBuiltinSpecsDir
? isUnderDir(row.source_path, opts.workflowBuiltinSpecsDir)
: false;
// Slice 11 — diagnostic rows render without parsed payload. Use the
// file basename as the row label (already stored in name by
// writeDiagnostic) and zero counts; errorMessage carries the reason.
if (status === "error") {
out.push({
// Diagnostic rows don't have stable name+version (version is
// empty when YAML couldn't be parsed); the library id falls
// back to source_path so the UI can route uniquely.
id: `workflow:error:${row.source_path}`,
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.error_message ?? undefined,
isBuiltIn,
rolesCount: 0,
stepsCount: 0,
terminalTurnRule: row.coordination_terminal_turn_rule || "hot_potato",
targetRig: null,
status: "error",
errorMessage: row.error_message ?? null,
});
continue;
}
let roles: WorkflowSpec["roles"];
let steps: WorkflowSpec["steps"];
try {
@@ -131,9 +182,6 @@ export function scanWorkflowSpecs(opts: ScanWorkflowSpecsOpts): SpecLibraryWorkf
// /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
@@ -152,6 +200,8 @@ export function scanWorkflowSpecs(opts: ScanWorkflowSpecsOpts): SpecLibraryWorkf
stepsCount: Array.isArray(steps) ? steps.length : 0,
terminalTurnRule: row.coordination_terminal_turn_rule || "hot_potato",
targetRig: row.target_rig,
status: "valid",
errorMessage: null,
});
}
return out;
@@ -261,3 +311,153 @@ function isUnderDir(childPath: string, parentDir: string): boolean {
const parentWithSep = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
return child.startsWith(parentWithSep);
}
// =================================================================
// Slice 11 (release-0.3.1 workflow-spec-folder-discovery)
// =================================================================
//
// scanWorkflowSpecFolder — filesystem walk that turns workspace.specs_root/
// workflows/ into an installable user primitive. The Library route calls
// this opportunistically on each list request (OQ-3 decision); valid YAML
// gets cached via WorkflowSpecCache.readThrough (existing path), invalid
// YAML gets cached via writeDiagnostic (slice 11 path), and files that
// disappear since the last scan get removed via removeBySourcePath
// (OQ-4 decision; deletion + audit log).
//
// OQ-3 mtime check: a file is skipped when its mtime is <= the cache row's
// cached_at (no parse work needed). Otherwise it's re-parsed via the cache
// (which itself hashes the content and returns the prior row when the
// hash matches — second layer of skip for content-stable files whose
// mtime nonetheless advanced, e.g., touch).
export interface ScanWorkflowSpecFolderOpts {
/** SQLite handle (used for direct lookups). */
db: Database.Database;
/** Cache handle for readThrough / writeDiagnostic / removeBySourcePath. */
cache: WorkflowSpecCache;
/** Absolute path to the workspace's workflows folder
* (typically `<workspace.specs_root>/workflows`). Missing folder
* → empty scan summary; not an error. */
folder: string;
/** Daemon's bundled builtin starter dir; used to skip removal logic
* for built-in rows (the scanner only owns the folder it walks). */
builtinDir: string | null;
}
export interface ScanWorkflowSpecFolderResult {
/** Total YAML/YML files found in folder. */
scanned: number;
/** Files parsed + cached successfully on this scan. */
valid: number;
/** Files that failed parse/validate; recorded as diagnostic rows. */
errors: number;
/** Cache rows removed because their source_path no longer exists. */
removed: number;
/** Files skipped via mtime check (unchanged since last scan). */
skipped: number;
}
function isWorkflowYamlFile(name: string): boolean {
return /\.ya?ml$/i.test(name);
}
export function scanWorkflowSpecFolder(
opts: ScanWorkflowSpecFolderOpts,
): ScanWorkflowSpecFolderResult {
const result: ScanWorkflowSpecFolderResult = {
scanned: 0,
valid: 0,
errors: 0,
removed: 0,
skipped: 0,
};
if (!existsSync(opts.folder)) return result;
let entries: string[] = [];
try {
entries = readdirSync(opts.folder);
} catch {
return result;
}
const seenPaths = new Set<string>();
for (const entry of entries) {
if (!isWorkflowYamlFile(entry)) continue;
const filePath = path.join(opts.folder, entry);
let mtimeMs = 0;
try {
const stat = statSync(filePath);
if (!stat.isFile()) continue;
mtimeMs = stat.mtimeMs;
} catch {
continue;
}
seenPaths.add(filePath);
result.scanned += 1;
// mtime check (OQ-3): if the cache has a row at this source_path
// whose cached_at is at or after the file's mtime, the file is
// unchanged since last scan — skip the re-parse work entirely.
//
// Compare at second-resolution because some filesystems (HFS+, FAT)
// round mtime down to whole seconds while cached_at carries ms
// precision; without this floor a freshly-written file whose mtime
// is `T - 999ms` would always look "newer" than its cached_at at
// exactly `T` and never skip.
const cachedAt = opts.db
.prepare(`SELECT cached_at FROM workflow_specs WHERE source_path = ?`)
.get(filePath) as { cached_at: string } | undefined;
if (cachedAt) {
const cachedAtMs = Date.parse(cachedAt.cached_at);
const cachedAtSec = Math.floor(cachedAtMs / 1000);
const mtimeSec = Math.floor(mtimeMs / 1000);
if (Number.isFinite(cachedAtMs) && cachedAtSec >= mtimeSec) {
result.skipped += 1;
continue;
}
}
// Parse + validate via cache. If readThrough throws (parse or
// validation error), record a diagnostic row keyed by source_path
// so the Library UI can render the error inline.
try {
opts.cache.readThrough(filePath);
result.valid += 1;
} catch (err) {
// Diagnostic: hash the raw content best-effort so we can detect
// edits that fix the error (mtime alone is fine; this is for
// bookkeeping). Use the empty string when content is unreadable.
const message = err instanceof Error ? err.message : String(err);
let sourceHash = "";
try {
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
} catch {
// unreadable / disappeared between stat and read; treat as
// empty hash so the next scan re-evaluates
sourceHash = "";
}
opts.cache.writeDiagnostic({
sourcePath: filePath,
sourceHash,
errorMessage: message,
});
result.errors += 1;
}
}
// OQ-4 deletion: cache rows whose source_path lives under the scanned
// folder AND whose file is no longer present on disk get removed. We
// scope the removal by source_path prefix to avoid touching built-in
// rows or rows the scanner doesn't own (e.g., other workflows folders).
const folderPrefix = opts.folder.endsWith(path.sep) ? opts.folder : `${opts.folder}${path.sep}`;
const cachedUnderFolder = opts.db
.prepare(`SELECT source_path FROM workflow_specs WHERE source_path LIKE ?`)
.all(`${folderPrefix}%`) as Array<{ source_path: string }>;
for (const { source_path } of cachedUnderFolder) {
if (seenPaths.has(source_path)) continue;
const removed = opts.cache.removeBySourcePath(source_path);
if (removed > 0) result.removed += removed;
}
return result;
}
@@ -257,6 +257,75 @@ export class WorkflowSpecCache {
return rows.map((row) => rowToWorkflowSpec(row));
}
/**
* Slice 11 (workflow-spec-folder-discovery) — diagnostic row writer.
* Used by scanWorkflowSpecFolder when YAML parse / validation fails
* so the Library UI can render an error row at the same path the
* user dropped a malformed workflow YAML into. The row's name field
* falls back to the source file basename so the Library has a
* stable label even when the YAML couldn't be parsed.
*
* Single-row-per-source_path semantics: writeDiagnostic on a path
* that already has a row (valid or diagnostic) UPDATES the row's
* status to 'error', error_message, source_hash, cached_at, and
* resets the parsed payload fields to empty (the prior YAML is no
* longer trusted). Round-trip between 'valid' and 'error' is
* supported via the same path: a passing readThrough flips the
* row back to 'valid' with parsed payload restored.
*/
writeDiagnostic(opts: {
sourcePath: string;
sourceHash: string;
errorMessage: string;
}): void {
const cachedAt = this.now().toISOString();
const fallbackName = opts.sourcePath.split("/").pop() ?? opts.sourcePath;
const existing = this.db
.prepare(`SELECT spec_id FROM workflow_specs WHERE source_path = ?`)
.get(opts.sourcePath) as { spec_id: string } | undefined;
if (existing) {
this.db
.prepare(
`UPDATE workflow_specs SET
status = 'error',
error_message = ?,
name = ?,
version = '',
purpose = NULL,
target_rig = NULL,
roles_json = '{}',
steps_json = '[]',
coordination_terminal_turn_rule = 'hot_potato',
source_hash = ?,
cached_at = ?
WHERE spec_id = ?`,
)
.run(opts.errorMessage, fallbackName, opts.sourceHash, cachedAt, existing.spec_id);
return;
}
this.db
.prepare(
`INSERT INTO workflow_specs (
spec_id, name, version, purpose, target_rig,
roles_json, steps_json, coordination_terminal_turn_rule,
source_path, source_hash, cached_at, status, error_message
) VALUES (?, ?, '', NULL, NULL, '{}', '[]', 'hot_potato', ?, ?, ?, 'error', ?)`,
)
.run(ulid(), fallbackName, opts.sourcePath, opts.sourceHash, cachedAt, opts.errorMessage);
}
/**
* Slice 11 — remove cache row by source_path (used when scanner
* detects a workflow YAML was deleted from disk). Returns the
* number of rows removed (0 when no row exists for that path).
*/
removeBySourcePath(sourcePath: string): number {
const result = this.db
.prepare(`DELETE FROM workflow_specs WHERE source_path = ?`)
.run(sourcePath);
return result.changes;
}
getByIdOrThrow(specId: string): WorkflowSpecRow {
const row = this.db
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
@@ -10,7 +10,9 @@ import {
getWorkflowReview,
parseWorkflowLibraryId,
scanWorkflowSpecs,
scanWorkflowSpecFolder,
} from "../domain/spec-library-workflow-scanner.js";
import type { WorkflowSpecCache } from "../domain/workflow-spec-cache.js";
export function specLibraryRoutes(): Hono {
const router = new Hono();
@@ -21,6 +23,19 @@ export function specLibraryRoutes(): Hono {
?? (c.get("rigRepo" as never) as { db: Database.Database } | undefined)?.db;
const builtinDir = c.get("workflowBuiltinSpecsDir" as never) as string | undefined;
if (db) {
// Slice 11 (workflow-spec-folder-discovery) — opportunistically
// walk the operator's workspace workflows folder (if wired) so
// newly-dropped YAML files materialize as cache rows (valid or
// diagnostic) before the cache read below. Folder scan is gated
// on context wiring; pre-slice-11 callers still get the
// cache-only behavior.
const cache = c.get("workflowSpecCache" as never) as WorkflowSpecCache | undefined;
const folder = c.get("workflowsFolderDir" as never) as string | undefined;
if (cache && folder) {
try {
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null });
} catch { /* best-effort — folder scan failure must not break Library list */ }
}
// 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
+12
View File
@@ -147,6 +147,16 @@ export interface AppDeps {
* surface still works, just without the indicator).
*/
workflowBuiltinSpecsDir?: string;
/** Slice 11 (workflow-spec-folder-discovery) — workspace workflows
* folder absolute path (typically `<workspace.specs_root>/workflows`).
* When set, GET /api/specs/library opportunistically scans this dir
* on each list request and surfaces valid + diagnostic rows. Unset
* → no folder scan (cache-only behavior). */
workflowsFolderDir?: string;
/** Slice 11 — WorkflowSpecCache instance for the folder scanner to
* read-through valid YAML and writeDiagnostic for invalid YAML.
* Same singleton as workflowRuntime.specCache. */
workflowSpecCache?: import("./domain/workflow-spec-cache.js").WorkflowSpecCache;
missionControlReadLayer?: import("./domain/mission-control/mission-control-read-layer.js").MissionControlReadLayer;
missionControlWriteContract?: import("./domain/mission-control/mission-control-write-contract.js").MissionControlWriteContract;
missionControlActionLog?: import("./domain/mission-control/mission-control-action-log.js").MissionControlActionLog;
@@ -354,6 +364,8 @@ export function createApp(deps: AppDeps): Hono {
c.set("watchdogScheduler" as never, deps.watchdogScheduler);
c.set("workflowRuntime" as never, deps.workflowRuntime);
c.set("workflowBuiltinSpecsDir" as never, deps.workflowBuiltinSpecsDir);
c.set("workflowsFolderDir" as never, deps.workflowsFolderDir);
c.set("workflowSpecCache" as never, deps.workflowSpecCache);
c.set("missionControlReadLayer" as never, deps.missionControlReadLayer);
c.set("missionControlWriteContract" as never, deps.missionControlWriteContract);
c.set("missionControlActionLog" as never, deps.missionControlActionLog);
+21 -1
View File
@@ -122,6 +122,10 @@ import { watchdogPolicyEnumExtensionSchema } from "./db/migrations/036_watchdog_
import { missionControlActionsSchema } from "./db/migrations/037_mission_control_actions.js";
import { workspacePrimitiveSchema } from "./db/migrations/038_workspace_primitive.js";
import { queueTargetRepoSchema } from "./db/migrations/039_queue_target_repo.js";
// Slice 11 (release-0.3.1 workflow-spec-folder-discovery) — adds
// status + error_message columns to workflow_specs so the scanner
// can record diagnostic rows. SC-29 #10 declared verbatim in commit body.
import { workflowSpecsDiagnosticSchema } from "./db/migrations/040_workflow_specs_diagnostic.js";
import { MissionControlActionLog } from "./domain/mission-control/mission-control-action-log.js";
import { MissionControlWriteContract } from "./domain/mission-control/mission-control-write-contract.js";
import { MissionControlReadLayer } from "./domain/mission-control/mission-control-read-layer.js";
@@ -195,7 +199,7 @@ export function collectAllowlistedProviderAuthEnv(
export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult> {
const dbPath = opts?.dbPath ?? ":memory:";
const db = createDb(dbPath);
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema, workspacePrimitiveSchema, queueTargetRepoSchema]);
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema, workspacePrimitiveSchema, queueTargetRepoSchema, workflowSpecsDiagnosticSchema]);
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
@@ -731,6 +735,22 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// Surface the resolved path to the routes layer so
// GET /api/workflow/specs can compute the per-row isBuiltIn flag.
deps.workflowBuiltinSpecsDir = builtinSpecsDir;
// Slice 11 (workflow-spec-folder-discovery) — expose the shared
// WorkflowSpecCache + the resolved workspace workflows folder so
// GET /api/specs/library can opportunistically discover operator-
// dropped YAML on each list request. Folder path is
// `<workspace.specs_root>/workflows`; SettingsStore resolves
// workspaceSpecsRoot from env > config > workspace-default.
deps.workflowSpecCache = workflowRuntime.specCache;
try {
const settingsStore = new ContextPackSettingsStore();
const cfg = settingsStore.resolveConfig();
if (cfg.workspaceSpecsRoot) {
deps.workflowsFolderDir = nodePath.join(cfg.workspaceSpecsRoot, "workflows");
}
} catch { /* settings unavailable — folder scan stays disabled */ }
if (starterResult.errors.length > 0) {
console.warn(
`[starter-spec-loader] ${starterResult.errors.length} spec(s) failed to load:`,
@@ -0,0 +1,174 @@
// Slice 11 (workflow-spec-folder-discovery) — route-level TDD.
//
// GET /api/specs/library opportunistically scans
// <workspace.specs_root>/workflows/ when wired via context vars
// `workflowSpecCache` + `workflowsFolderDir`, then surfaces both
// valid + diagnostic rows alongside built-in starters (OQ-3).
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 { workflowSpecsDiagnosticSchema } from "../src/db/migrations/040_workflow_specs_diagnostic.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 VALID_YAML = (id: string) => `workflow:
id: ${id}
version: '1'
objective: Folder-scan fixture
target:
rig: folder-fix
entry:
role: producer
roles:
producer:
preferred_targets:
- producer@folder-fix
steps:
- id: produce
actor_role: producer
objective: Draft.
allowed_exits:
- done
invariants:
allowed_exits:
- done
`;
const INVALID_YAML = `workflow:
id: bad-spec
# missing required version, roles, steps
objective: This will not parse cleanly
`;
describe("spec-library route folder scan (slice 11)", () => {
let db: Database.Database;
let tmp: string;
let folder: string;
let builtinDir: string;
let cache: WorkflowSpecCache;
let lib: SpecLibraryService;
let lensStore: ActiveLensStore;
let lensFilePath: string;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
cache = new WorkflowSpecCache(db);
tmp = mkdtempSync(join(tmpdir(), "wf-folder-route-"));
folder = join(tmp, "workflows");
builtinDir = join(tmp, "builtins", "workflow-specs");
mkdirSync(folder, { recursive: true });
mkdirSync(builtinDir, { recursive: true });
lensFilePath = join(tmp, "active-workflow-lens.json");
lib = new SpecLibraryService({ roots: [], specReviewService: new SpecReviewService() });
lib.scan();
lensStore = new ActiveLensStore({ filePath: lensFilePath });
});
afterEach(() => {
db.close();
rmSync(tmp, { recursive: true, force: true });
});
function createApp(opts: { withFolder: boolean } = { withFolder: true }): 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);
c.set("workflowSpecCache" as never, cache);
if (opts.withFolder) c.set("workflowsFolderDir" as never, folder);
await next();
});
app.route("/api/specs/library", specLibraryRoutes());
return app;
}
it("GET / triggers folder scan and surfaces valid YAML as a workflow entry", async () => {
writeFileSync(join(folder, "good.yaml"), VALID_YAML("good-spec"));
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 }>;
const entry = body.find((e) => e.kind === "workflow" && e.name === "good-spec");
expect(entry).toBeDefined();
expect(entry?.id).toBe("workflow:good-spec:1");
});
it("GET / surfaces invalid YAML as a diagnostic workflow entry", async () => {
writeFileSync(join(folder, "broken.yaml"), INVALID_YAML);
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;
status?: string;
errorMessage?: string | null;
}>;
const diag = body.find((e) => e.kind === "workflow" && e.id.startsWith("workflow:error:"));
expect(diag).toBeDefined();
expect(diag?.status).toBe("error");
expect(diag?.errorMessage).toBeTruthy();
expect(diag?.name).toBe("broken.yaml");
});
it("GET / removes diagnostic row after the file disappears (OQ-4)", async () => {
const brokenPath = join(folder, "broken.yaml");
writeFileSync(brokenPath, INVALID_YAML);
let app = createApp();
let res = await app.request("/api/specs/library");
let body = await res.json() as Array<{ id: string }>;
expect(body.some((e) => e.id.startsWith("workflow:error:"))).toBe(true);
rmSync(brokenPath);
app = createApp();
res = await app.request("/api/specs/library");
body = await res.json() as Array<{ id: string }>;
expect(body.some((e) => e.id.startsWith("workflow:error:"))).toBe(false);
});
it("GET / is a no-op for the folder scan when workflowsFolderDir is not set", async () => {
writeFileSync(join(folder, "good.yaml"), VALID_YAML("ghost-spec"));
const app = createApp({ withFolder: false });
const res = await app.request("/api/specs/library");
const body = await res.json() as Array<{ name: string }>;
expect(body.some((e) => e.name === "ghost-spec")).toBe(false);
});
it("drift-discriminator: valid + invalid + previously-removed in single response", async () => {
// Pre-seed a diagnostic for a file that no longer exists.
cache.writeDiagnostic({
sourcePath: join(folder, "previously-here.yaml"),
sourceHash: "h",
errorMessage: "stale",
});
writeFileSync(join(folder, "good.yaml"), VALID_YAML("disc-good"));
writeFileSync(join(folder, "bad.yaml"), INVALID_YAML);
const app = createApp();
const res = await app.request("/api/specs/library");
const body = await res.json() as Array<{
id: string;
name: string;
kind: string;
status?: string;
}>;
expect(body.some((e) => e.name === "disc-good")).toBe(true);
expect(body.some((e) => e.name === "bad.yaml" && e.status === "error")).toBe(true);
expect(body.some((e) => e.id === `workflow:error:${join(folder, "previously-here.yaml")}`)).toBe(false);
});
});
@@ -0,0 +1,200 @@
// Slice 11 (release-0.3.1 workflow-spec-folder-discovery) — TDD for
// scanWorkflowSpecFolder. Walks workspace.specs_root/workflows/, parses
// + validates each YAML, populates the cache. Invalid YAML produces a
// diagnostic row via cache.writeDiagnostic. Deletions remove the cache
// row via cache.removeBySourcePath.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } 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 { workflowSpecsDiagnosticSchema } from "../src/db/migrations/040_workflow_specs_diagnostic.js";
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
import { scanWorkflowSpecFolder } from "../src/domain/spec-library-workflow-scanner.js";
const VALID_YAML = `workflow:
id: folder-test
version: '1'
objective: A folder-scan fixture
target:
rig: folder-fixture
entry:
role: producer
roles:
producer:
preferred_targets:
- producer@folder-fixture
steps:
- id: produce
actor_role: producer
objective: Draft.
allowed_exits:
- done
invariants:
allowed_exits:
- done
`;
const INVALID_YAML = `workflow:
id: bad-spec
# missing required 'version', 'roles', 'steps'
objective: This will not parse cleanly
`;
const VALID_YAML_TWO = `workflow:
id: folder-test-2
version: '1'
objective: second fixture
target:
rig: folder-fixture
entry:
role: producer
roles:
producer:
preferred_targets:
- producer@folder-fixture
steps:
- id: produce
actor_role: producer
objective: Draft.
allowed_exits:
- done
invariants:
allowed_exits:
- done
`;
describe("scanWorkflowSpecFolder (slice 11)", () => {
let db: Database.Database;
let cache: WorkflowSpecCache;
let folder: string;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
cache = new WorkflowSpecCache(db);
folder = mkdtempSync(join(tmpdir(), "wf-folder-"));
});
afterEach(() => {
db.close();
rmSync(folder, { recursive: true, force: true });
});
it("returns empty array when folder does not exist", () => {
rmSync(folder, { recursive: true, force: true });
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result).toEqual({ scanned: 0, valid: 0, errors: 0, removed: 0, skipped: 0 });
});
it("scans a valid YAML file → row in cache + scan summary 1/1/0/0", () => {
writeFileSync(join(folder, "wf.yaml"), VALID_YAML);
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result).toEqual({ scanned: 1, valid: 1, errors: 0, removed: 0, skipped: 0 });
const all = cache.listAll();
const row = all.find((r) => r.name === "folder-test");
expect(row).toBeDefined();
expect(row?.sourcePath).toBe(join(folder, "wf.yaml"));
});
it("scans invalid YAML → diagnostic row + scan summary 1/0/1/0", () => {
writeFileSync(join(folder, "bad.yaml"), INVALID_YAML);
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result).toEqual({ scanned: 1, valid: 0, errors: 1, removed: 0, skipped: 0 });
const row = db
.prepare(
`SELECT name, status, error_message FROM workflow_specs WHERE source_path = ?`,
)
.get(join(folder, "bad.yaml")) as {
name: string;
status: string;
error_message: string;
};
expect(row.status).toBe("error");
expect(row.error_message).toBeTruthy();
expect(row.name).toBe("bad.yaml");
});
it("skips unchanged files on second scan via mtime check (OQ-3)", () => {
writeFileSync(join(folder, "wf.yaml"), VALID_YAML);
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
// Capture original cached_at then second scan; cached_at should not change.
const before = db
.prepare(`SELECT cached_at FROM workflow_specs WHERE name = ?`)
.get("folder-test") as { cached_at: string };
// Second scan should be a no-op for unchanged files.
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result.scanned).toBe(1);
// Skipped files don't count as valid (re-parse) or errors — they're
// counted via a separate `skipped` field for observability.
expect(result.skipped).toBe(1);
expect(result.valid).toBe(0);
const after = db
.prepare(`SELECT cached_at FROM workflow_specs WHERE name = ?`)
.get("folder-test") as { cached_at: string };
expect(after.cached_at).toBe(before.cached_at);
});
it("re-parses when file mtime advances past cached_at", () => {
writeFileSync(join(folder, "wf.yaml"), VALID_YAML);
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
// Advance mtime so the file looks newer than the cache.
const future = new Date(Date.now() + 60_000);
utimesSync(join(folder, "wf.yaml"), future, future);
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result.scanned).toBe(1);
expect(result.valid).toBe(1);
expect(result.skipped).toBe(0);
});
it("removes cache row when file disappears (OQ-4)", () => {
writeFileSync(join(folder, "wf.yaml"), VALID_YAML);
writeFileSync(join(folder, "wf2.yaml"), VALID_YAML_TWO);
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(cache.listAll().filter((r) => r.sourcePath.startsWith(folder))).toHaveLength(2);
// Delete wf.yaml; wf2.yaml stays.
rmSync(join(folder, "wf.yaml"));
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result.removed).toBe(1);
const remaining = cache.listAll().filter((r) => r.sourcePath.startsWith(folder));
expect(remaining).toHaveLength(1);
expect(remaining[0]?.name).toBe("folder-test-2");
});
it("does NOT remove cache rows whose source_path is outside the scanned folder", () => {
// Pre-seed a row from a different source root (e.g., built-in starter)
// to confirm the scanner only acts on its own folder boundary.
const externalPath = join(tmpdir(), "external-wf.yaml");
writeFileSync(externalPath, VALID_YAML.replace("folder-test", "external-spec"));
cache.readThrough(externalPath);
// Now scan an empty folder — should NOT touch the external row.
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result.removed).toBe(0);
expect(cache.listAll().some((r) => r.name === "external-spec")).toBe(true);
rmSync(externalPath);
});
it("drift-discriminator: same scan emits 3 distinct outcomes for 3 distinct files", () => {
// Per banked feedback_poc_regression_must_discriminate — fixtures
// distinct enough that the scanner's per-file decision branches are
// observable.
writeFileSync(join(folder, "valid.yaml"), VALID_YAML);
writeFileSync(join(folder, "bad.yaml"), INVALID_YAML);
// Pre-cache one row with a different name (simulates "previously
// scanned but now removed") so the removal branch fires too.
cache.writeDiagnostic({
sourcePath: join(folder, "previously-here.yaml"),
sourceHash: "h",
errorMessage: "stale",
});
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
expect(result.scanned).toBe(2);
expect(result.valid).toBe(1);
expect(result.errors).toBe(1);
expect(result.removed).toBe(1);
});
});
@@ -0,0 +1,208 @@
// Slice 11 (workflow-spec-folder-discovery) — migration 040
// adds status + error_message columns to workflow_specs. WorkflowSpecCache
// gains writeDiagnostic / removeBySourcePath / queryDiagnostics methods
// so the scanner can record invalid YAML as diagnostic rows.
//
// SC-29 #10 (verbatim, declared in commit body):
// "Slice 11 (workflow-spec-folder-discovery) requires schema migration
// 040_workflow_specs_diagnostic.ts adding status TEXT DEFAULT 'valid' +
// error_message TEXT columns to the workflow_specs cache. No new table,
// no constraint changes beyond default; ALTER TABLE ADD COLUMN preserves
// existing rows (default 'valid' fills retroactively for already-cached
// rows). Read-only diagnostic surface — the cache stores parser/validator
// errors so the Library UI can render them; daemon does not act on the
// diagnostic state. Per IMPL-PRD §HG-8 'unless provenance / status
// columns require migration — declare upfront if so': declared upfront
// in this slice's ACK + commit body."
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
import { workflowSpecsDiagnosticSchema } from "../src/db/migrations/040_workflow_specs_diagnostic.js";
interface ColumnRow {
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
}
describe("migration 040 — workflow_specs diagnostic columns", () => {
let db: Database.Database;
beforeEach(() => {
db = createDb();
});
afterEach(() => {
db.close();
});
it("adds status TEXT DEFAULT 'valid' column to workflow_specs", () => {
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
const cols = db.prepare("PRAGMA table_info(workflow_specs)").all() as ColumnRow[];
const status = cols.find((c) => c.name === "status");
expect(status).toBeDefined();
expect(status?.type.toUpperCase()).toBe("TEXT");
// SQLite default value escaping: 'valid' is stored as the string literal
expect(status?.dflt_value).toMatch(/'valid'|valid/);
});
it("adds error_message TEXT (nullable) column to workflow_specs", () => {
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
const cols = db.prepare("PRAGMA table_info(workflow_specs)").all() as ColumnRow[];
const errorMessage = cols.find((c) => c.name === "error_message");
expect(errorMessage).toBeDefined();
expect(errorMessage?.type.toUpperCase()).toBe("TEXT");
expect(errorMessage?.notnull).toBe(0);
});
it("existing rows retain valid status by default after migration applies", () => {
// Apply pre-040 schema, insert a row, then apply 040; the row's
// status should default to 'valid' since the existing read-through
// path never set it.
migrate(db, [coreSchema, workflowSpecsSchema]);
db.prepare(
`INSERT INTO workflow_specs (spec_id, name, version, purpose, target_rig, roles_json, steps_json, coordination_terminal_turn_rule, source_path, source_hash, cached_at)
VALUES ('s1', 'pre-040', '1.0', null, null, '{}', '[]', 'hot_potato', '/x.yaml', 'h', '2026-05-11T00:00:00Z')`,
).run();
migrate(db, [workflowSpecsDiagnosticSchema]);
const row = db
.prepare("SELECT status, error_message FROM workflow_specs WHERE spec_id = ?")
.get("s1") as { status: string; error_message: string | null };
expect(row.status).toBe("valid");
expect(row.error_message).toBeNull();
});
it("migration is idempotent (re-applying does not error)", () => {
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
// Re-applying should be a no-op (ALTER TABLE ADD COLUMN IF NOT EXISTS).
expect(() => {
migrate(db, [workflowSpecsDiagnosticSchema]);
}).not.toThrow();
});
});
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join as joinPath } from "node:path";
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
const VALID_DIAG_SAMPLE = `workflow:
id: valid-spec
version: '1'
objective: test fixture
target:
rig: rig-fix
entry:
role: a
roles:
a:
preferred_targets:
- a@rig-fix
steps:
- id: s1
actor_role: a
objective: x
allowed_exits:
- done
invariants:
allowed_exits:
- done
`;
describe("WorkflowSpecCache diagnostic methods (slice 11)", () => {
let db: Database.Database;
let cache: WorkflowSpecCache;
let tmp: string;
beforeEach(() => {
db = createDb();
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
cache = new WorkflowSpecCache(db);
tmp = mkdtempSync(joinPath(tmpdir(), "wf-diag-"));
});
afterEach(() => {
db.close();
rmSync(tmp, { recursive: true, force: true });
});
it("writeDiagnostic stores an error row keyed by source_path", () => {
cache.writeDiagnostic({
sourcePath: "/x/broken.yaml",
sourceHash: "h1",
errorMessage: "YAML parse error at line 3",
});
const row = db
.prepare(
`SELECT name, status, error_message, source_path
FROM workflow_specs WHERE source_path = ?`,
)
.get("/x/broken.yaml") as {
name: string;
status: string;
error_message: string;
source_path: string;
};
expect(row.status).toBe("error");
expect(row.error_message).toBe("YAML parse error at line 3");
expect(row.source_path).toBe("/x/broken.yaml");
// Name uses the file basename as a fallback so the Library can render
// a row identifier even when YAML couldn't be parsed.
expect(row.name).toBe("broken.yaml");
});
it("writeDiagnostic updates an existing diagnostic row in-place by source_path", () => {
cache.writeDiagnostic({
sourcePath: "/x/broken.yaml",
sourceHash: "h1",
errorMessage: "first error",
});
cache.writeDiagnostic({
sourcePath: "/x/broken.yaml",
sourceHash: "h2",
errorMessage: "second error after edit",
});
const rows = db
.prepare(`SELECT * FROM workflow_specs WHERE source_path = ?`)
.all("/x/broken.yaml") as Array<{ error_message: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]?.error_message).toBe("second error after edit");
});
it("removeBySourcePath removes both valid and diagnostic rows", () => {
cache.writeDiagnostic({
sourcePath: "/x/gone.yaml",
sourceHash: "h",
errorMessage: "err",
});
const removed = cache.removeBySourcePath("/x/gone.yaml");
expect(removed).toBe(1);
const remaining = db
.prepare(`SELECT COUNT(*) as n FROM workflow_specs WHERE source_path = ?`)
.get("/x/gone.yaml") as { n: number };
expect(remaining.n).toBe(0);
});
it("removeBySourcePath returns 0 when no row exists", () => {
expect(cache.removeBySourcePath("/never/exists.yaml")).toBe(0);
});
it("listAll surfaces both valid and diagnostic rows", () => {
const validPath = joinPath(tmp, "valid.yaml");
writeFileSync(validPath, VALID_DIAG_SAMPLE);
cache.readThrough(validPath);
cache.writeDiagnostic({
sourcePath: "/x/broken.yaml",
sourceHash: "h",
errorMessage: "err",
});
const all = cache.listAll();
expect(all.length).toBeGreaterThanOrEqual(2);
expect(all.some((r) => r.name === "valid-spec")).toBe(true);
expect(all.some((r) => r.name === "broken.yaml")).toBe(true);
});
});
@@ -23,9 +23,23 @@ interface LibraryRow {
meta: string;
metaNode?: ReactNode;
entryId?: string;
/** Slice 11 — diagnostic state for unparseable YAML in the workflows
* folder. "error" rows render non-navigable with the parser/validator
* message inline so the operator can fix the file in place. */
status?: "valid" | "error";
}
function specRow(entry: SpecLibraryEntry): LibraryRow {
if (entry.status === "error") {
// Diagnostic row: no entryId → non-navigable; meta carries the
// parse/validate reason so the operator sees it inline.
return {
id: entry.id,
label: entry.name,
meta: entry.errorMessage ?? "Invalid workflow YAML",
status: "error",
};
}
return {
id: entry.id,
label: entry.name,
@@ -88,7 +102,11 @@ function LibrarySection({
<LibraryRowContent row={row} />
</Link>
) : (
<div data-testid={`library-row-${id}-${row.id}`} className="px-3 py-2">
<div
data-testid={`library-row-${id}-${row.id}`}
data-status={row.status}
className={`px-3 py-2 ${row.status === "error" ? "bg-red-50/40 text-red-900" : ""}`}
>
<LibraryRowContent row={row} />
</div>
)}
+4
View File
@@ -20,6 +20,10 @@ export interface SpecLibraryEntry {
stepsCount?: number;
terminalTurnRule?: string;
targetRig?: string | null;
// Slice 11 (workflow-spec-folder-discovery) — diagnostic state for
// workflow rows surfaced by the operator's specs/workflows folder.
status?: "valid" | "error";
errorMessage?: string | null;
}
export interface LibraryReview {
@@ -0,0 +1,138 @@
// Slice 11 (workflow-spec-folder-discovery) — UI Library renders diagnostic
// workflow rows when the daemon surfaces status='error' + errorMessage from
// the folder-scan walk. Diagnostic rows are NOT navigable (no review payload
// exists for unparseable YAML); the error message is surfaced inline so the
// operator can fix the file in place.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import { SpecsLibraryPage } from "../src/components/specs/SpecsLibraryPage.js";
import { createTestRouter } from "./helpers/test-router.js";
const mockFetch = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch as unknown as typeof fetch;
Object.defineProperty(window, "scrollTo", { configurable: true, value: vi.fn() });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
function emptyAux(url: string): { ok: true; json: () => Promise<unknown> } | null {
if (url === "/api/context-packs/library" || url === "/api/agent-images/library") {
return { ok: true, json: async () => [] };
}
if (url === "/api/files/roots") {
return { ok: true, json: async () => ({ roots: [] }) };
}
if (url.startsWith("/api/files/list?")) {
return { ok: true, json: async () => ({ root: "workspace", path: "", entries: [] }) };
}
return null;
}
function renderPage() {
return render(
createTestRouter({ path: "/specs", component: () => <SpecsLibraryPage /> }),
);
}
describe("Library workflow diagnostic rows (slice 11)", () => {
it("renders a diagnostic workflow row with error styling + errorMessage", async () => {
mockFetch.mockImplementation(async (url: string) => {
const aux = emptyAux(url);
if (aux) return aux;
if (url === "/api/specs/library") {
return {
ok: true,
json: async () => [
{
id: "workflow:good:1",
kind: "workflow",
name: "good-spec",
version: "1",
sourceType: "user_file",
sourcePath: "/ws/specs/workflows/good.yaml",
relativePath: "workflows/good.yaml",
updatedAt: "2026-05-09T00:00:00.000Z",
status: "valid",
errorMessage: null,
},
{
id: "workflow:error:/ws/specs/workflows/broken.yaml",
kind: "workflow",
name: "broken.yaml",
version: "",
sourceType: "user_file",
sourcePath: "/ws/specs/workflows/broken.yaml",
relativePath: "workflows/broken.yaml",
updatedAt: "2026-05-09T00:00:00.000Z",
status: "error",
errorMessage: "missing required version, roles, steps",
},
],
};
}
throw new Error(`unexpected fetch ${url}`);
});
renderPage();
const section = await waitFor(() =>
screen.getByTestId("library-section-workflow-specs"),
);
// Valid row is clickable (rendered as a Link).
const goodRow = await within(section).findByTestId("library-row-workflow-specs-workflow:good:1");
expect(goodRow.tagName.toLowerCase()).toBe("a");
// Router URL-encodes `:` as %3A.
expect(goodRow.getAttribute("href")).toContain("workflow%3Agood%3A1");
// Diagnostic row uses a distinct testid pattern and renders the
// errorMessage inline, NOT as a navigable link (there is no review
// payload for an unparseable spec).
const diagRow = await within(section).findByTestId(
"library-row-workflow-specs-workflow:error:/ws/specs/workflows/broken.yaml",
);
expect(diagRow.tagName.toLowerCase()).not.toBe("a");
expect(diagRow.getAttribute("data-status")).toBe("error");
expect(within(diagRow).getByText(/missing required version, roles, steps/)).toBeDefined();
expect(within(diagRow).getByText(/broken\.yaml/)).toBeDefined();
});
it("still navigates valid workflow rows to /specs/library/$entryId", async () => {
mockFetch.mockImplementation(async (url: string) => {
const aux = emptyAux(url);
if (aux) return aux;
if (url === "/api/specs/library") {
return {
ok: true,
json: async () => [
{
id: "workflow:nav:1",
kind: "workflow",
name: "nav-spec",
version: "1",
sourceType: "user_file",
sourcePath: "/ws/specs/workflows/nav.yaml",
relativePath: "workflows/nav.yaml",
updatedAt: "2026-05-09T00:00:00.000Z",
status: "valid",
errorMessage: null,
},
],
};
}
throw new Error(`unexpected fetch ${url}`);
});
renderPage();
const section = await waitFor(() => screen.getByTestId("library-section-workflow-specs"));
const link = await within(section).findByTestId("library-row-workflow-specs-workflow:nav:1");
expect(link.tagName.toLowerCase()).toBe("a");
expect(link.getAttribute("href")).toMatch(/workflow%3Anav%3A1/);
});
});