feat(core): persist named workflow definitions (U1)

Add a workflows table (migration 103) storing WorkflowIr graphs plus editor
layout, with CRUD on TaskStore (create/list/get/update/delete) that validates
the IR via parseWorkflowIr on write. IDs (WF-001…) use a monotonic __meta
counter that never reuses across deletes.
This commit is contained in:
gsxdsm
2026-06-03 09:22:35 -07:00
parent 04a5cd196c
commit ffce42ff9c
5 changed files with 387 additions and 1 deletions

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { WorkflowIrError } from "../workflow-ir.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
function makeIr(overrides: Partial<WorkflowIr> = {}): WorkflowIr {
return {
version: "v1",
name: "test-workflow",
nodes: [
{ id: "start", kind: "start" },
{ id: "lint", kind: "gate", config: { scriptName: "lint" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "lint" },
{ from: "lint", to: "end" },
],
...overrides,
};
}
describe("TaskStore workflow definitions (U1)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
it("creates and round-trips a workflow with IR and layout intact", async () => {
const created = await store.createWorkflowDefinition({
name: "Quality Gate",
description: "Runs lint before merge",
ir: makeIr(),
layout: { start: { x: 0, y: 0 }, lint: { x: 120, y: 0 }, end: { x: 240, y: 0 } },
});
expect(created.id).toBe("WF-001");
const list = await store.listWorkflowDefinitions();
expect(list).toHaveLength(1);
expect(list[0].name).toBe("Quality Gate");
expect(list[0].ir.nodes).toHaveLength(3);
expect(list[0].layout.lint).toEqual({ x: 120, y: 0 });
});
it("rejects a workflow whose IR is missing start/end", async () => {
const bad = makeIr({ nodes: [{ id: "only", kind: "prompt" }], edges: [] });
await expect(
store.createWorkflowDefinition({ name: "Broken", ir: bad }),
).rejects.toBeInstanceOf(WorkflowIrError);
expect(await store.listWorkflowDefinitions()).toHaveLength(0);
});
it("requires a non-empty name", async () => {
await expect(
store.createWorkflowDefinition({ name: " ", ir: makeIr() }),
).rejects.toThrow(/name is required/i);
});
it("updates name, description, IR, and layout and advances updatedAt", async () => {
const created = await store.createWorkflowDefinition({ name: "V1", ir: makeIr() });
await new Promise((r) => setTimeout(r, 2));
const updated = await store.updateWorkflowDefinition(created.id, {
name: "V2",
description: "now with a prompt step",
ir: makeIr({
nodes: [
{ id: "start", kind: "start" },
{ id: "review", kind: "prompt", config: { prompt: "Review the change" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "review" },
{ from: "review", to: "end" },
],
}),
layout: { start: { x: 5, y: 5 } },
});
expect(updated.name).toBe("V2");
expect(updated.description).toBe("now with a prompt step");
expect(updated.ir.nodes.some((n) => n.id === "review")).toBe(true);
expect(updated.layout.start).toEqual({ x: 5, y: 5 });
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(created.updatedAt).getTime(),
);
});
it("update rejects an invalid IR without mutating the stored row", async () => {
const created = await store.createWorkflowDefinition({ name: "Keep", ir: makeIr() });
await expect(
store.updateWorkflowDefinition(created.id, {
ir: { version: "v1", name: "x", nodes: [], edges: [] } as WorkflowIr,
}),
).rejects.toBeInstanceOf(WorkflowIrError);
const reread = await store.getWorkflowDefinition(created.id);
expect(reread?.ir.nodes).toHaveLength(3);
});
it("deletes a workflow and reflects absence", async () => {
const created = await store.createWorkflowDefinition({ name: "Temp", ir: makeIr() });
await store.deleteWorkflowDefinition(created.id);
expect(await store.getWorkflowDefinition(created.id)).toBeUndefined();
expect(await store.listWorkflowDefinitions()).toHaveLength(0);
});
it("throws when deleting a non-existent workflow", async () => {
await expect(store.deleteWorkflowDefinition("WF-999")).rejects.toThrow(/not found/i);
});
it("allocates monotonic ids without reusing across deletes", async () => {
const a = await store.createWorkflowDefinition({ name: "A", ir: makeIr() });
const b = await store.createWorkflowDefinition({ name: "B", ir: makeIr() });
expect(a.id).toBe("WF-001");
expect(b.id).toBe("WF-002");
await store.deleteWorkflowDefinition(b.id);
const c = await store.createWorkflowDefinition({ name: "C", ir: makeIr() });
expect(c.id).toBe("WF-003");
});
});

View File

@@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 102;
const SCHEMA_VERSION = 103;
export { SCHEMA_VERSION };
@@ -387,6 +387,19 @@ CREATE TABLE IF NOT EXISTS workflow_steps (
updatedAt TEXT NOT NULL
);
-- Named workflow definitions authored as WorkflowIr graphs (+ editor layout).
-- The ir and layout columns are JSON-encoded TEXT; ir is validated via
-- parseWorkflowIr before persistence at the store layer.
CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
ir TEXT NOT NULL,
layout TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
-- Activity log with indexed columns for efficient queries
CREATE TABLE IF NOT EXISTS activityLog (
id TEXT PRIMARY KEY,
@@ -4037,6 +4050,23 @@ export class Database {
}
}
// Migration 103: Named workflow definitions (WorkflowIr graphs + layout).
if (version < 103) {
this.applyMigration(103, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
ir TEXT NOT NULL,
layout TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
});
}
}
/**

View File

@@ -54,6 +54,12 @@ export type {
WorkflowIrNodeKind,
} from "./workflow-ir-types.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export type {
WorkflowDefinition,
WorkflowDefinitionInput,
WorkflowDefinitionUpdate,
WorkflowNodeLayout,
} from "./workflow-definition-types.js";
// ── Engine wiring (set by @fusion/engine at module load) ────────────
export {

View File

@@ -7,6 +7,7 @@ import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry,
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js";
import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js";
import { normalizeTaskPriority } from "./task-priority.js";
import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js";
@@ -1133,6 +1134,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private lastTaskIdIntegrityLogSignature: string | null = null;
/** Cached workflow steps — invalidated on create/update/delete */
private workflowStepsCache: import("./types.js").WorkflowStep[] | null = null;
private workflowDefinitionsCache: import("./workflow-definition-types.js").WorkflowDefinition[] | null = null;
/** Plugin-contributed workflow step templates injected by engine runtime. */
private _pluginWorkflowStepTemplates: Array<{ pluginId: string; template: WorkflowStepTemplate }> = [];
/** Global settings store (`~/.fusion/settings.json`) */
@@ -10843,6 +10845,184 @@ ${stepsSection}`;
}
}
// ── Workflow definitions (named WorkflowIr graphs) ─────────────────────
/** Allocate the next workflow-definition id (WF-001, WF-002, …) using a
* monotonic counter persisted in __meta. Never reuses ids across deletes. */
private nextWorkflowDefinitionId(): string {
const row = this.db.prepare("SELECT value FROM __meta WHERE key = 'nextWorkflowDefinitionId'").get() as
| { value: string }
| undefined;
const next = row ? parseInt(row.value, 10) || 1 : 1;
this.db
.prepare(
"INSERT INTO __meta (key, value) VALUES ('nextWorkflowDefinitionId', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.run(String(next + 1));
return `WF-${String(next).padStart(3, "0")}`;
}
private toWorkflowDefinition(row: {
id: string;
name: string;
description: string;
ir: string;
layout: string;
createdAt: string;
updatedAt: string;
}): import("./workflow-definition-types.js").WorkflowDefinition {
return {
id: row.id,
name: row.name,
description: row.description,
ir: parseWorkflowIr(row.ir),
layout: this.parseWorkflowLayout(row.layout),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private parseWorkflowLayout(
raw: string,
): Record<string, import("./workflow-definition-types.js").WorkflowNodeLayout> {
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object") {
return parsed as Record<string, import("./workflow-definition-types.js").WorkflowNodeLayout>;
}
} catch {
// Corrupt layout JSON falls back to empty (auto-layout) rather than failing the read.
}
return {};
}
/** Create a named workflow definition. The IR is validated via parseWorkflowIr. */
async createWorkflowDefinition(
input: import("./workflow-definition-types.js").WorkflowDefinitionInput,
): Promise<import("./workflow-definition-types.js").WorkflowDefinition> {
return this.withConfigLock(async () => {
const name = input.name?.trim();
if (!name) throw new Error("Workflow name is required");
// Validate the IR shape up front so we never persist a malformed graph.
const ir = parseWorkflowIr(input.ir);
const layout = input.layout ?? {};
const now = new Date().toISOString();
const id = this.nextWorkflowDefinitionId();
const definition: import("./workflow-definition-types.js").WorkflowDefinition = {
id,
name,
description: input.description ?? "",
ir,
layout,
createdAt: now,
updatedAt: now,
};
this.db
.prepare(
`INSERT INTO workflows (id, name, description, ir, layout, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
definition.id,
definition.name,
definition.description,
serializeWorkflowIr(definition.ir),
JSON.stringify(definition.layout),
definition.createdAt,
definition.updatedAt,
);
this.workflowDefinitionsCache = null;
this.db.bumpLastModified();
return definition;
});
}
/** List all workflow definitions, oldest first. Cached until a mutation. */
async listWorkflowDefinitions(): Promise<import("./workflow-definition-types.js").WorkflowDefinition[]> {
if (this.workflowDefinitionsCache) return this.workflowDefinitionsCache;
const rows = this.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as Array<{
id: string;
name: string;
description: string;
ir: string;
layout: string;
createdAt: string;
updatedAt: string;
}>;
this.workflowDefinitionsCache = rows.map((row) => this.toWorkflowDefinition(row));
return this.workflowDefinitionsCache;
}
/** Get a single workflow definition by id, or undefined when absent. */
async getWorkflowDefinition(
id: string,
): Promise<import("./workflow-definition-types.js").WorkflowDefinition | undefined> {
const row = this.db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as
| {
id: string;
name: string;
description: string;
ir: string;
layout: string;
createdAt: string;
updatedAt: string;
}
| undefined;
return row ? this.toWorkflowDefinition(row) : undefined;
}
/** Update a workflow definition. The IR (when supplied) is re-validated. */
async updateWorkflowDefinition(
id: string,
updates: import("./workflow-definition-types.js").WorkflowDefinitionUpdate,
): Promise<import("./workflow-definition-types.js").WorkflowDefinition> {
return this.withConfigLock(async () => {
const existing = await this.getWorkflowDefinition(id);
if (!existing) throw new Error(`Workflow '${id}' not found`);
const name = updates.name !== undefined ? updates.name.trim() : existing.name;
if (!name) throw new Error("Workflow name is required");
const ir = updates.ir !== undefined ? parseWorkflowIr(updates.ir) : existing.ir;
const next: import("./workflow-definition-types.js").WorkflowDefinition = {
...existing,
name,
description: updates.description !== undefined ? updates.description : existing.description,
ir,
layout: updates.layout !== undefined ? updates.layout : existing.layout,
updatedAt: new Date().toISOString(),
};
this.db
.prepare(
`UPDATE workflows SET name = ?, description = ?, ir = ?, layout = ?, updatedAt = ? WHERE id = ?`,
)
.run(
next.name,
next.description,
serializeWorkflowIr(next.ir),
JSON.stringify(next.layout),
next.updatedAt,
id,
);
this.workflowDefinitionsCache = null;
this.db.bumpLastModified();
return next;
});
}
/** Delete a workflow definition. Throws when the id does not exist. */
async deleteWorkflowDefinition(id: string): Promise<void> {
const deleted = this.db.prepare("DELETE FROM workflows WHERE id = ?").run(id) as { changes?: number };
if ((deleted.changes || 0) === 0) {
throw new Error(`Workflow '${id}' not found`);
}
this.workflowDefinitionsCache = null;
this.db.bumpLastModified();
}
/**
* Close the database connection and clean up resources.
* Call this when the store is no longer needed (e.g., short-lived per-request stores).

View File

@@ -0,0 +1,43 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
/** Editor layout position for a single workflow IR node. Persisted separately
* from the IR because the v1 IR contract deliberately excludes node geometry. */
export interface WorkflowNodeLayout {
x: number;
y: number;
}
/** A named, persisted workflow authored as a WorkflowIr graph plus editor layout. */
export interface WorkflowDefinition {
/** Unique identifier (e.g., "WF-001"). */
id: string;
/** Display name. */
name: string;
/** Short description for UI display. */
description: string;
/** The validated workflow graph (v1 IR contract). */
ir: WorkflowIr;
/** Editor node positions keyed by IR node id. May be empty (auto-layout). */
layout: Record<string, WorkflowNodeLayout>;
/** ISO-8601 timestamp of creation. */
createdAt: string;
/** ISO-8601 timestamp of last update. */
updatedAt: string;
}
/** Input for creating a workflow definition. */
export interface WorkflowDefinitionInput {
name: string;
description?: string;
/** Workflow graph; validated via parseWorkflowIr on write. */
ir: WorkflowIr;
layout?: Record<string, WorkflowNodeLayout>;
}
/** Partial update for an existing workflow definition. */
export interface WorkflowDefinitionUpdate {
name?: string;
description?: string;
ir?: WorkflowIr;
layout?: Record<string, WorkflowNodeLayout>;
}