feat(FN-1201): store workflow steps in dedicated SQLite table
- Add schema v16 migration that creates workflow_steps and backfills rows from legacy config.workflowSteps data - Update legacy file-to-SQLite migration to insert workflow step definitions into workflow_steps while preserving nextWorkflowStepId - Refactor TaskStore workflow step create/list/get/update/delete paths to read and write workflow_steps instead of config JSON - Adjust db migration tests and AGENTS.md storage docs to reflect the new workflow_steps table model
This commit is contained in:
@@ -159,6 +159,18 @@ describe("migrateFromLegacy", () => {
|
||||
expect(row.nextWorkflowStepId).toBe(3);
|
||||
expect(JSON.parse(row.settings).maxConcurrent).toBe(4);
|
||||
expect(JSON.parse(row.workflowSteps)).toHaveLength(1);
|
||||
|
||||
const workflowRows = db.prepare("SELECT * FROM workflow_steps ORDER BY id ASC").all() as any[];
|
||||
expect(workflowRows).toHaveLength(1);
|
||||
expect(workflowRows[0]).toMatchObject({
|
||||
id: "WS-001",
|
||||
name: "Test",
|
||||
description: "Test step",
|
||||
mode: "prompt",
|
||||
phase: "pre-merge",
|
||||
prompt: "test",
|
||||
enabled: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { readFile, readdir, rename, stat } from "node:fs/promises";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import type { Database } from "./db.js";
|
||||
import { toJson, toJsonNullable, normalizeTaskComments } from "./db.js";
|
||||
import type { Task, BoardConfig, ActivityLogEntry, ArchivedTaskEntry } from "./types.js";
|
||||
import type { Task, BoardConfig, ActivityLogEntry, ArchivedTaskEntry, WorkflowStep } from "./types.js";
|
||||
import type { ScheduledTask } from "./automation.js";
|
||||
import { resolveGlobalDir } from "./global-settings.js";
|
||||
|
||||
@@ -130,7 +130,11 @@ async function migrateConfig(kbDir: string, db: Database): Promise<void> {
|
||||
if (!existsSync(configPath)) return;
|
||||
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
const config: BoardConfig = JSON.parse(raw);
|
||||
const config = JSON.parse(raw) as BoardConfig & {
|
||||
nextWorkflowStepId?: number;
|
||||
workflowSteps?: WorkflowStep[];
|
||||
};
|
||||
const workflowSteps = Array.isArray(config.workflowSteps) ? config.workflowSteps : [];
|
||||
|
||||
db.prepare(
|
||||
`UPDATE config SET
|
||||
@@ -144,9 +148,59 @@ async function migrateConfig(kbDir: string, db: Database): Promise<void> {
|
||||
config.nextId || 1,
|
||||
config.nextWorkflowStepId || 1,
|
||||
JSON.stringify(config.settings || {}),
|
||||
JSON.stringify(config.workflowSteps || []),
|
||||
JSON.stringify(workflowSteps),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
|
||||
const insertWorkflowStep = db.prepare(`
|
||||
INSERT OR IGNORE INTO workflow_steps (
|
||||
id,
|
||||
templateId,
|
||||
name,
|
||||
description,
|
||||
mode,
|
||||
phase,
|
||||
prompt,
|
||||
toolMode,
|
||||
scriptName,
|
||||
enabled,
|
||||
defaultOn,
|
||||
modelProvider,
|
||||
modelId,
|
||||
createdAt,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const step of workflowSteps) {
|
||||
if (!step?.id || !step.name || !step.description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mode = step.mode === "script" ? "script" : "prompt";
|
||||
const phase = step.phase === "post-merge" ? "post-merge" : "pre-merge";
|
||||
const createdAt = step.createdAt || new Date().toISOString();
|
||||
const updatedAt = step.updatedAt || createdAt;
|
||||
|
||||
insertWorkflowStep.run(
|
||||
step.id,
|
||||
step.templateId ?? null,
|
||||
step.name,
|
||||
step.description,
|
||||
mode,
|
||||
phase,
|
||||
mode === "prompt" ? step.prompt || "" : "",
|
||||
mode === "prompt" ? step.toolMode ?? null : null,
|
||||
mode === "script" ? step.scriptName ?? null : null,
|
||||
step.enabled === false ? 0 : 1,
|
||||
step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0,
|
||||
mode === "prompt" ? step.modelProvider ?? null : null,
|
||||
mode === "prompt" ? step.modelId ?? null : null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
db.bumpLastModified();
|
||||
console.log("[migrate] Migrated config.json");
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -119,7 +119,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -726,7 +726,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -751,11 +751,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -771,7 +771,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -874,7 +874,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1084,7 +1084,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(15);
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 15;
|
||||
const SCHEMA_VERSION = 16;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -181,6 +181,25 @@ CREATE TABLE IF NOT EXISTS config (
|
||||
updatedAt TEXT
|
||||
);
|
||||
|
||||
-- Workflow step definitions
|
||||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
templateId TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'prompt',
|
||||
phase TEXT NOT NULL DEFAULT 'pre-merge',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
toolMode TEXT,
|
||||
scriptName TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
defaultOn INTEGER DEFAULT 0,
|
||||
modelProvider TEXT,
|
||||
modelId TEXT,
|
||||
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,
|
||||
@@ -524,6 +543,98 @@ export class Database {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 16) {
|
||||
this.applyMigration(16, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
templateId TEXT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'prompt',
|
||||
phase TEXT NOT NULL DEFAULT 'pre-merge',
|
||||
prompt TEXT NOT NULL DEFAULT '',
|
||||
toolMode TEXT,
|
||||
scriptName TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
defaultOn INTEGER DEFAULT 0,
|
||||
modelProvider TEXT,
|
||||
modelId TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
const configRow = this.db
|
||||
.prepare("SELECT workflowSteps FROM config WHERE id = 1")
|
||||
.get() as { workflowSteps?: string | null } | undefined;
|
||||
const workflowSteps = fromJson<Array<Record<string, unknown>>>(configRow?.workflowSteps);
|
||||
|
||||
if (!Array.isArray(workflowSteps) || workflowSteps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const insertWorkflowStep = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO workflow_steps (
|
||||
id,
|
||||
templateId,
|
||||
name,
|
||||
description,
|
||||
mode,
|
||||
phase,
|
||||
prompt,
|
||||
toolMode,
|
||||
scriptName,
|
||||
enabled,
|
||||
defaultOn,
|
||||
modelProvider,
|
||||
modelId,
|
||||
createdAt,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const step of workflowSteps) {
|
||||
const id = typeof step.id === "string" ? step.id : "";
|
||||
const name = typeof step.name === "string" ? step.name : "";
|
||||
const description = typeof step.description === "string" ? step.description : "";
|
||||
|
||||
if (!id || !name || !description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mode = step.mode === "script" ? "script" : "prompt";
|
||||
const phase = step.phase === "post-merge" ? "post-merge" : "pre-merge";
|
||||
const createdAt =
|
||||
typeof step.createdAt === "string" && step.createdAt
|
||||
? step.createdAt
|
||||
: new Date().toISOString();
|
||||
const updatedAt =
|
||||
typeof step.updatedAt === "string" && step.updatedAt
|
||||
? step.updatedAt
|
||||
: createdAt;
|
||||
|
||||
insertWorkflowStep.run(
|
||||
id,
|
||||
typeof step.templateId === "string" ? step.templateId : null,
|
||||
name,
|
||||
description,
|
||||
mode,
|
||||
phase,
|
||||
typeof step.prompt === "string" ? step.prompt : "",
|
||||
step.toolMode === "coding" || step.toolMode === "readonly" ? step.toolMode : null,
|
||||
typeof step.scriptName === "string" ? step.scriptName : null,
|
||||
step.enabled === false ? 0 : 1,
|
||||
step.defaultOn === true ? 1 : 0,
|
||||
typeof step.modelProvider === "string" ? step.modelProvider : null,
|
||||
typeof step.modelId === "string" ? step.modelId : null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -612,34 +612,54 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (!row) {
|
||||
return { nextId: 1 };
|
||||
}
|
||||
const workflowSteps = fromJson<import("./types.js").WorkflowStep[]>(row.workflowSteps);
|
||||
// Normalize legacy steps that don't have a mode field — default to "prompt"
|
||||
if (workflowSteps) {
|
||||
for (const ws of workflowSteps) {
|
||||
if (!ws.mode) {
|
||||
ws.mode = "prompt";
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
const config: BoardConfig = {
|
||||
nextId: row.nextId || 1,
|
||||
settings: fromJson<Settings>(row.settings),
|
||||
workflowSteps,
|
||||
nextWorkflowStepId: row.nextWorkflowStepId || 1,
|
||||
};
|
||||
|
||||
// Backward-compatibility for internal callers/tests that still access these fields.
|
||||
// Keep them non-enumerable so config.json writes don't include workflow steps.
|
||||
const workflowSteps = this.listWorkflowSteps();
|
||||
Object.defineProperty(config, "workflowSteps", {
|
||||
value: await workflowSteps,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
Object.defineProperty(config, "nextWorkflowStepId", {
|
||||
value: row.nextWorkflowStepId || 1,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private async writeConfig(config: BoardConfig): Promise<void> {
|
||||
private async writeConfig(
|
||||
config: BoardConfig,
|
||||
options?: { nextWorkflowStepId?: number },
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const row = this.db
|
||||
.prepare("SELECT nextWorkflowStepId FROM config WHERE id = 1")
|
||||
.get() as { nextWorkflowStepId?: number } | undefined;
|
||||
const nextWorkflowStepId = options?.nextWorkflowStepId ?? row?.nextWorkflowStepId ?? 1;
|
||||
|
||||
const legacyWorkflowSteps = (config as { workflowSteps?: unknown }).workflowSteps;
|
||||
const workflowStepsJson = Array.isArray(legacyWorkflowSteps)
|
||||
? JSON.stringify(legacyWorkflowSteps)
|
||||
: "[]";
|
||||
|
||||
// Use INSERT OR REPLACE to ensure the config row exists (handles edge case where row is missing)
|
||||
this.db.prepare(
|
||||
`INSERT OR REPLACE INTO config (id, nextId, nextWorkflowStepId, settings, workflowSteps, updatedAt)
|
||||
VALUES (1, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
config.nextId || 1,
|
||||
config.nextWorkflowStepId || 1,
|
||||
nextWorkflowStepId,
|
||||
JSON.stringify(config.settings || {}),
|
||||
JSON.stringify(config.workflowSteps || []),
|
||||
workflowStepsJson,
|
||||
now,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
@@ -706,35 +726,101 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureWorkflowStepForTemplate(templateId: string): Promise<import("./types.js").WorkflowStep> {
|
||||
return this.withConfigLock(async () => {
|
||||
const template = this.getBuiltInWorkflowTemplate(templateId);
|
||||
if (!template) {
|
||||
throw new Error(`Workflow step template '${templateId}' not found`);
|
||||
}
|
||||
private toStoredWorkflowStep(row: {
|
||||
id: string;
|
||||
templateId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: string;
|
||||
phase: string | null;
|
||||
prompt: string;
|
||||
toolMode: string | null;
|
||||
scriptName: string | null;
|
||||
enabled: number;
|
||||
defaultOn: number | null;
|
||||
modelProvider: string | null;
|
||||
modelId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}): import("./types.js").WorkflowStep {
|
||||
return {
|
||||
id: row.id,
|
||||
templateId: row.templateId ?? undefined,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
mode: row.mode === "script" ? "script" : "prompt",
|
||||
phase: row.phase === "post-merge" ? "post-merge" : "pre-merge",
|
||||
prompt: row.prompt || "",
|
||||
toolMode: row.toolMode === "coding" || row.toolMode === "readonly" ? row.toolMode : undefined,
|
||||
scriptName: row.scriptName ?? undefined,
|
||||
enabled: Boolean(row.enabled),
|
||||
defaultOn: row.defaultOn === null || row.defaultOn === undefined ? undefined : Boolean(row.defaultOn),
|
||||
modelProvider: row.modelProvider ?? undefined,
|
||||
modelId: row.modelId ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const config = await this.readConfig();
|
||||
const existing = (config.workflowSteps || []).find((ws) =>
|
||||
ws.id === templateId
|
||||
|| ws.templateId === templateId
|
||||
|| ws.name.toLowerCase() === template.name.toLowerCase(),
|
||||
);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
private getLegacyWorkflowStepSnapshot(id: string, templateId?: string): Record<string, unknown> | undefined {
|
||||
const row = this.db
|
||||
.prepare("SELECT workflowSteps FROM config WHERE id = 1")
|
||||
.get() as { workflowSteps?: string | null } | undefined;
|
||||
const legacySteps = fromJson<Array<Record<string, unknown>>>(row?.workflowSteps);
|
||||
if (!Array.isArray(legacySteps)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextWsId = config.nextWorkflowStepId || 1;
|
||||
const step = this.toBuiltInWorkflowStep(template);
|
||||
step.id = `WS-${String(nextWsId).padStart(3, "0")}`;
|
||||
|
||||
if (!config.workflowSteps) {
|
||||
config.workflowSteps = [];
|
||||
}
|
||||
config.workflowSteps.push(step);
|
||||
config.nextWorkflowStepId = nextWsId + 1;
|
||||
await this.writeConfig(config);
|
||||
return legacySteps.find((legacy) => {
|
||||
if (!legacy || typeof legacy !== "object") return false;
|
||||
if (legacy.id === id) return true;
|
||||
return Boolean(templateId && legacy.templateId === templateId);
|
||||
});
|
||||
}
|
||||
|
||||
private applyLegacyWorkflowStepOverrides(step: import("./types.js").WorkflowStep): import("./types.js").WorkflowStep {
|
||||
const legacy = this.getLegacyWorkflowStepSnapshot(step.id, step.templateId);
|
||||
if (!legacy) {
|
||||
return step;
|
||||
}
|
||||
|
||||
const normalized = { ...step };
|
||||
if (!Object.prototype.hasOwnProperty.call(legacy, "mode")) {
|
||||
normalized.mode = "prompt";
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(legacy, "phase")) {
|
||||
normalized.phase = undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async ensureWorkflowStepForTemplate(templateId: string): Promise<import("./types.js").WorkflowStep> {
|
||||
const template = this.getBuiltInWorkflowTemplate(templateId);
|
||||
if (!template) {
|
||||
throw new Error(`Workflow step template '${templateId}' not found`);
|
||||
}
|
||||
|
||||
const existing = await this.getWorkflowStep(templateId);
|
||||
if (existing && existing.id !== templateId) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const allSteps = await this.listWorkflowSteps();
|
||||
const byName = allSteps.find((step) => step.name.toLowerCase() === template.name.toLowerCase());
|
||||
if (byName) {
|
||||
return byName;
|
||||
}
|
||||
|
||||
return this.createWorkflowStep({
|
||||
templateId: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
mode: "prompt",
|
||||
phase: "pre-merge",
|
||||
prompt: template.prompt,
|
||||
toolMode: template.toolMode || "readonly",
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -807,8 +893,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// When enabledWorkflowSteps is not provided at all (undefined), auto-apply default-on workflow steps
|
||||
if (input.enabledWorkflowSteps === undefined) {
|
||||
try {
|
||||
const config = await this.readConfig();
|
||||
const defaultOnSteps = (config.workflowSteps || [])
|
||||
const allSteps = await this.listWorkflowSteps();
|
||||
const defaultOnSteps = allSteps
|
||||
.filter((ws) => ws.enabled && ws.defaultOn)
|
||||
.map((ws) => ws.id);
|
||||
if (defaultOnSteps.length > 0) {
|
||||
@@ -2853,12 +2939,14 @@ ${stepsSection}`;
|
||||
|
||||
/**
|
||||
* Create a new workflow step definition.
|
||||
* Generates a unique ID (WS-001, WS-002, etc.) and stores in config.json.
|
||||
* Generates a unique ID (WS-001, WS-002, etc.) and stores in the workflow_steps table.
|
||||
*/
|
||||
async createWorkflowStep(input: import("./types.js").WorkflowStepInput): Promise<import("./types.js").WorkflowStep> {
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const nextWsId = config.nextWorkflowStepId || 1;
|
||||
const counterRow = this.db
|
||||
.prepare("SELECT nextWorkflowStepId FROM config WHERE id = 1")
|
||||
.get() as { nextWorkflowStepId?: number } | undefined;
|
||||
const nextWsId = counterRow?.nextWorkflowStepId || 1;
|
||||
const id = `WS-${String(nextWsId).padStart(3, "0")}`;
|
||||
|
||||
const mode = input.mode || "prompt";
|
||||
@@ -2880,39 +2968,131 @@ ${stepsSection}`;
|
||||
toolMode: mode === "prompt" ? (input.toolMode || "readonly") : undefined,
|
||||
scriptName: mode === "script" ? input.scriptName : undefined,
|
||||
enabled: input.enabled !== undefined ? input.enabled : true,
|
||||
defaultOn: input.defaultOn === true ? true : undefined,
|
||||
defaultOn: input.defaultOn !== undefined ? input.defaultOn : undefined,
|
||||
modelProvider: mode === "prompt" ? input.modelProvider : undefined,
|
||||
modelId: mode === "prompt" ? input.modelId : undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (!config.workflowSteps) {
|
||||
config.workflowSteps = [];
|
||||
}
|
||||
config.workflowSteps.push(step);
|
||||
config.nextWorkflowStepId = nextWsId + 1;
|
||||
await this.writeConfig(config);
|
||||
this.db.prepare(
|
||||
`INSERT INTO workflow_steps (
|
||||
id,
|
||||
templateId,
|
||||
name,
|
||||
description,
|
||||
mode,
|
||||
phase,
|
||||
prompt,
|
||||
toolMode,
|
||||
scriptName,
|
||||
enabled,
|
||||
defaultOn,
|
||||
modelProvider,
|
||||
modelId,
|
||||
createdAt,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
step.id,
|
||||
step.templateId ?? null,
|
||||
step.name,
|
||||
step.description,
|
||||
step.mode,
|
||||
step.phase || "pre-merge",
|
||||
step.prompt,
|
||||
step.toolMode ?? null,
|
||||
step.scriptName ?? null,
|
||||
step.enabled ? 1 : 0,
|
||||
step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0,
|
||||
step.modelProvider ?? null,
|
||||
step.modelId ?? null,
|
||||
step.createdAt,
|
||||
step.updatedAt,
|
||||
);
|
||||
|
||||
const config = await this.readConfig();
|
||||
await this.writeConfig(config, { nextWorkflowStepId: nextWsId + 1 });
|
||||
|
||||
return step;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all workflow step definitions from config.json.
|
||||
* List all workflow step definitions from workflow_steps.
|
||||
*/
|
||||
async listWorkflowSteps(): Promise<import("./types.js").WorkflowStep[]> {
|
||||
const config = await this.readConfig();
|
||||
return config.workflowSteps || [];
|
||||
const rows = this.db.prepare("SELECT * FROM workflow_steps ORDER BY createdAt ASC").all() as Array<{
|
||||
id: string;
|
||||
templateId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: string;
|
||||
phase: string | null;
|
||||
prompt: string;
|
||||
toolMode: string | null;
|
||||
scriptName: string | null;
|
||||
enabled: number;
|
||||
defaultOn: number | null;
|
||||
modelProvider: string | null;
|
||||
modelId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
return rows.map((row) => this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(row)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single workflow step by ID.
|
||||
*/
|
||||
async getWorkflowStep(id: string): Promise<import("./types.js").WorkflowStep | undefined> {
|
||||
const config = await this.readConfig();
|
||||
const stored = (config.workflowSteps || []).find((ws) => ws.id === id);
|
||||
if (stored) return stored;
|
||||
const byId = this.db.prepare("SELECT * FROM workflow_steps WHERE id = ?").get(id) as
|
||||
| {
|
||||
id: string;
|
||||
templateId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: string;
|
||||
phase: string | null;
|
||||
prompt: string;
|
||||
toolMode: string | null;
|
||||
scriptName: string | null;
|
||||
enabled: number;
|
||||
defaultOn: number | null;
|
||||
modelProvider: string | null;
|
||||
modelId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
| undefined;
|
||||
if (byId) {
|
||||
return this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(byId));
|
||||
}
|
||||
|
||||
const byTemplate = this.db
|
||||
.prepare("SELECT * FROM workflow_steps WHERE templateId = ? ORDER BY createdAt ASC LIMIT 1")
|
||||
.get(id) as
|
||||
| {
|
||||
id: string;
|
||||
templateId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: string;
|
||||
phase: string | null;
|
||||
prompt: string;
|
||||
toolMode: string | null;
|
||||
scriptName: string | null;
|
||||
enabled: number;
|
||||
defaultOn: number | null;
|
||||
modelProvider: string | null;
|
||||
modelId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
| undefined;
|
||||
if (byTemplate) {
|
||||
return this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(byTemplate));
|
||||
}
|
||||
|
||||
const template = this.getBuiltInWorkflowTemplate(id);
|
||||
return template ? this.toBuiltInWorkflowStep(template) : undefined;
|
||||
@@ -2923,58 +3103,106 @@ ${stepsSection}`;
|
||||
* @throws Error if the workflow step is not found
|
||||
*/
|
||||
async updateWorkflowStep(id: string, updates: Partial<import("./types.js").WorkflowStepInput>): Promise<import("./types.js").WorkflowStep> {
|
||||
return this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const steps = config.workflowSteps || [];
|
||||
const index = steps.findIndex((ws) => ws.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Workflow step '${id}' not found`);
|
||||
}
|
||||
|
||||
const step = steps[index];
|
||||
|
||||
// Handle mode change
|
||||
if (updates.mode !== undefined) {
|
||||
const newMode = updates.mode;
|
||||
// Validate: script mode requires scriptName
|
||||
if (newMode === "script" && !updates.scriptName?.trim() && !step.scriptName?.trim()) {
|
||||
throw new Error("Script mode requires a scriptName");
|
||||
}
|
||||
step.mode = newMode;
|
||||
// When switching to script mode, clear prompt and model overrides
|
||||
if (newMode === "script") {
|
||||
step.prompt = "";
|
||||
step.toolMode = undefined;
|
||||
step.modelProvider = undefined;
|
||||
step.modelId = undefined;
|
||||
}
|
||||
// When switching to prompt mode, clear scriptName
|
||||
if (newMode === "prompt") {
|
||||
step.scriptName = undefined;
|
||||
step.toolMode = step.toolMode || "readonly";
|
||||
const row = this.db.prepare("SELECT * FROM workflow_steps WHERE id = ?").get(id) as
|
||||
| {
|
||||
id: string;
|
||||
templateId: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: string;
|
||||
phase: string | null;
|
||||
prompt: string;
|
||||
toolMode: string | null;
|
||||
scriptName: string | null;
|
||||
enabled: number;
|
||||
defaultOn: number | null;
|
||||
modelProvider: string | null;
|
||||
modelId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Workflow step '${id}' not found`);
|
||||
}
|
||||
|
||||
const step = this.toStoredWorkflowStep(row);
|
||||
|
||||
// Handle mode change
|
||||
if (updates.mode !== undefined) {
|
||||
const newMode = updates.mode;
|
||||
// Validate: script mode requires scriptName
|
||||
if (newMode === "script" && !updates.scriptName?.trim() && !step.scriptName?.trim()) {
|
||||
throw new Error("Script mode requires a scriptName");
|
||||
}
|
||||
|
||||
if (updates.name !== undefined) step.name = updates.name;
|
||||
if (updates.description !== undefined) step.description = updates.description;
|
||||
if (updates.phase !== undefined) step.phase = updates.phase;
|
||||
if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt;
|
||||
if (updates.toolMode !== undefined && step.mode === "prompt") step.toolMode = updates.toolMode;
|
||||
if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName;
|
||||
if (updates.enabled !== undefined) step.enabled = updates.enabled;
|
||||
if (updates.defaultOn !== undefined) step.defaultOn = updates.defaultOn;
|
||||
if (step.mode === "prompt") {
|
||||
if ("modelProvider" in updates) step.modelProvider = updates.modelProvider;
|
||||
if ("modelId" in updates) step.modelId = updates.modelId;
|
||||
step.mode = newMode;
|
||||
// When switching to script mode, clear prompt and model overrides
|
||||
if (newMode === "script") {
|
||||
step.prompt = "";
|
||||
step.toolMode = undefined;
|
||||
step.modelProvider = undefined;
|
||||
step.modelId = undefined;
|
||||
}
|
||||
step.updatedAt = new Date().toISOString();
|
||||
// When switching to prompt mode, clear scriptName
|
||||
if (newMode === "prompt") {
|
||||
step.scriptName = undefined;
|
||||
step.toolMode = step.toolMode || "readonly";
|
||||
}
|
||||
}
|
||||
|
||||
config.workflowSteps = steps;
|
||||
await this.writeConfig(config);
|
||||
if (updates.name !== undefined) step.name = updates.name;
|
||||
if (updates.description !== undefined) step.description = updates.description;
|
||||
if (updates.phase !== undefined) step.phase = updates.phase;
|
||||
if (updates.prompt !== undefined && step.mode === "prompt") step.prompt = updates.prompt;
|
||||
if (updates.toolMode !== undefined && step.mode === "prompt") step.toolMode = updates.toolMode;
|
||||
if (updates.scriptName !== undefined && step.mode === "script") step.scriptName = updates.scriptName;
|
||||
if (updates.enabled !== undefined) step.enabled = updates.enabled;
|
||||
if (updates.defaultOn !== undefined) step.defaultOn = updates.defaultOn;
|
||||
if (step.mode === "script" && !step.scriptName?.trim()) {
|
||||
throw new Error("Script mode requires a scriptName");
|
||||
}
|
||||
if (step.mode === "prompt") {
|
||||
if ("modelProvider" in updates) step.modelProvider = updates.modelProvider;
|
||||
if ("modelId" in updates) step.modelId = updates.modelId;
|
||||
}
|
||||
step.updatedAt = new Date().toISOString();
|
||||
|
||||
return step;
|
||||
});
|
||||
this.db.prepare(
|
||||
`UPDATE workflow_steps
|
||||
SET templateId = ?,
|
||||
name = ?,
|
||||
description = ?,
|
||||
mode = ?,
|
||||
phase = ?,
|
||||
prompt = ?,
|
||||
toolMode = ?,
|
||||
scriptName = ?,
|
||||
enabled = ?,
|
||||
defaultOn = ?,
|
||||
modelProvider = ?,
|
||||
modelId = ?,
|
||||
updatedAt = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
step.templateId ?? null,
|
||||
step.name,
|
||||
step.description,
|
||||
step.mode,
|
||||
step.phase || "pre-merge",
|
||||
step.prompt,
|
||||
step.toolMode ?? null,
|
||||
step.scriptName ?? null,
|
||||
step.enabled ? 1 : 0,
|
||||
step.defaultOn === undefined ? null : step.defaultOn ? 1 : 0,
|
||||
step.modelProvider ?? null,
|
||||
step.modelId ?? null,
|
||||
step.updatedAt,
|
||||
step.id,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2983,19 +3211,15 @@ ${stepsSection}`;
|
||||
* @throws Error if the workflow step is not found
|
||||
*/
|
||||
async deleteWorkflowStep(id: string): Promise<void> {
|
||||
await this.withConfigLock(async () => {
|
||||
const config = await this.readConfig();
|
||||
const steps = config.workflowSteps || [];
|
||||
const index = steps.findIndex((ws) => ws.id === id);
|
||||
const deleted = this.db.prepare("DELETE FROM workflow_steps WHERE id = ?").run(id) as {
|
||||
changes?: number;
|
||||
};
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Workflow step '${id}' not found`);
|
||||
}
|
||||
if ((deleted.changes || 0) === 0) {
|
||||
throw new Error(`Workflow step '${id}' not found`);
|
||||
}
|
||||
|
||||
steps.splice(index, 1);
|
||||
config.workflowSteps = steps;
|
||||
await this.writeConfig(config);
|
||||
});
|
||||
this.db.bumpLastModified();
|
||||
|
||||
// Clean up references from existing tasks (best-effort, outside config lock)
|
||||
try {
|
||||
|
||||
@@ -1169,10 +1169,6 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
||||
export interface BoardConfig {
|
||||
nextId: number;
|
||||
settings?: Settings;
|
||||
/** Global workflow step definitions */
|
||||
workflowSteps?: WorkflowStep[];
|
||||
/** Auto-incrementing counter for workflow step IDs */
|
||||
nextWorkflowStepId?: number;
|
||||
}
|
||||
|
||||
export interface MergeResult extends MergeDetails {
|
||||
|
||||
Reference in New Issue
Block a user