mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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:
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user