feat(FN-4468): add workflow step gateMode advisory gating

Adds an "advisory" `gateMode` option for workflow steps: steps in advisory mode log findings but no longer block merge completion, while steps that must block can still be configured as blocking. The change covers the workflow step types and schema, executor gating logic, dashboard UI in WorkflowSte

Fusion-Task-Id: FN-4468
This commit is contained in:
Fusion
2026-05-14 05:30:13 -07:00
committed by gsxdsm
parent d318f08eb6
commit 95d742abb3
23 changed files with 213 additions and 42 deletions

View File

@@ -290,7 +290,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -318,7 +318,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1383,7 +1383,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1408,11 +1408,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
db.close();
});
@@ -1447,7 +1447,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1488,7 +1488,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1560,7 +1560,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1800,7 +1800,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1874,7 +1874,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
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" }]);
@@ -1898,7 +1898,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2002,7 +2002,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2221,7 +2221,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(75);
expect(localDb.getSchemaVersion()).toBe(76);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2532,7 +2532,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2668,7 +2668,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(75);
expect(migrated.getSchemaVersion()).toBe(76);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2695,7 +2695,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(75);
expect(fresh.getSchemaVersion()).toBe(76);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(75);
expect(db1.getSchemaVersion()).toBe(76);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(75);
expect(db3.getSchemaVersion()).toBe(76);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(75);
expect(db1.getSchemaVersion()).toBe(76);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(75);
expect(db2.getSchemaVersion()).toBe(76);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(75);
expect(db1.getSchemaVersion()).toBe(76);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -2639,7 +2639,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
});
it("mission_features table has loop state columns", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
});
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(75);
expect(db.getSchemaVersion()).toBe(76);
const index = db
.prepare(

View File

@@ -160,6 +160,7 @@ async function migrateConfig(fusionDir: string, db: Database): Promise<void> {
mode,
phase,
prompt,
gateMode,
toolMode,
scriptName,
enabled,
@@ -168,7 +169,7 @@ async function migrateConfig(fusionDir: string, db: Database): Promise<void> {
modelId,
createdAt,
updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const step of workflowSteps) {
@@ -189,6 +190,7 @@ async function migrateConfig(fusionDir: string, db: Database): Promise<void> {
mode,
phase,
mode === "prompt" ? step.prompt || "" : "",
step.gateMode ?? (mode === "script" ? "gate" : "advisory"),
mode === "prompt" ? step.toolMode ?? null : null,
mode === "script" ? step.scriptName ?? null : null,
step.enabled === false ? 0 : 1,

View File

@@ -119,7 +119,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 75;
const SCHEMA_VERSION = 76;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -324,6 +324,7 @@ CREATE TABLE IF NOT EXISTS workflow_steps (
mode TEXT NOT NULL DEFAULT 'prompt',
phase TEXT NOT NULL DEFAULT 'pre-merge',
prompt TEXT NOT NULL DEFAULT '',
gateMode TEXT NOT NULL DEFAULT 'advisory',
toolMode TEXT,
scriptName TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
@@ -1769,6 +1770,7 @@ export class Database {
mode TEXT NOT NULL DEFAULT 'prompt',
phase TEXT NOT NULL DEFAULT 'pre-merge',
prompt TEXT NOT NULL DEFAULT '',
gateMode TEXT NOT NULL DEFAULT 'advisory',
toolMode TEXT,
scriptName TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
@@ -1798,6 +1800,7 @@ export class Database {
mode,
phase,
prompt,
gateMode,
toolMode,
scriptName,
enabled,
@@ -1806,7 +1809,7 @@ export class Database {
modelId,
createdAt,
updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const step of workflowSteps) {
@@ -1837,6 +1840,9 @@ export class Database {
mode,
phase,
typeof step.prompt === "string" ? step.prompt : "",
step.gateMode === "gate" || step.gateMode === "advisory"
? step.gateMode
: (mode === "script" ? "gate" : "advisory"),
step.toolMode === "coding" || step.toolMode === "readonly" ? step.toolMode : null,
typeof step.scriptName === "string" ? step.scriptName : null,
step.enabled === false ? 0 : 1,
@@ -3244,6 +3250,13 @@ export class Database {
});
}
if (version < 76) {
this.applyMigration(76, () => {
this.addColumnIfMissing("workflow_steps", "gateMode", "TEXT NOT NULL DEFAULT 'advisory'");
this.db.exec("UPDATE workflow_steps SET gateMode = CASE WHEN mode = 'script' THEN 'gate' ELSE 'advisory' END WHERE gateMode IS NULL OR gateMode = ''");
});
}
}
/**

View File

@@ -16,7 +16,7 @@ function countWorkflow(results: WorkflowStepResult[] | undefined): Deterministic
let pending = 0;
for (const result of list) {
if (result.status === "passed") passed += 1;
else if (result.status === "failed") failed += 1;
else if (result.status === "failed" || result.status === "advisory_failure") failed += 1;
else if (result.status === "pending") pending += 1;
}
return { total: list.length, passed, failed, pending };

View File

@@ -2579,6 +2579,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
mode: "prompt",
phase: "pre-merge",
prompt: template.prompt,
gateMode: "advisory",
toolMode: template.toolMode || "readonly",
enabled: true,
createdAt: now,
@@ -2594,6 +2595,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
mode: string;
phase: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -2611,6 +2613,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
mode: row.mode === "script" ? "script" : "prompt",
phase: row.phase === "post-merge" ? "post-merge" : "pre-merge",
prompt: row.prompt || "",
gateMode: row.gateMode === "gate" || row.gateMode === "advisory"
? row.gateMode
: (row.mode === "script" ? "gate" : "advisory"),
toolMode: row.toolMode === "coding" || row.toolMode === "readonly" ? row.toolMode : undefined,
scriptName: row.scriptName ?? undefined,
enabled: Boolean(row.enabled),
@@ -2679,6 +2684,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
mode: "prompt",
phase: "pre-merge",
prompt: template.prompt,
gateMode: "advisory",
toolMode: template.toolMode || "readonly",
enabled: true,
});
@@ -7092,6 +7098,7 @@ ${stepsSection}`;
mode,
phase: input.phase || "pre-merge",
prompt: mode === "prompt" ? (input.prompt || "") : "",
gateMode: input.gateMode || (mode === "script" ? "gate" : "advisory"),
toolMode: mode === "prompt" ? (input.toolMode || "readonly") : undefined,
scriptName: mode === "script" ? input.scriptName : undefined,
enabled: input.enabled !== undefined ? input.enabled : true,
@@ -7111,6 +7118,7 @@ ${stepsSection}`;
mode,
phase,
prompt,
gateMode,
toolMode,
scriptName,
enabled,
@@ -7119,7 +7127,7 @@ ${stepsSection}`;
modelId,
createdAt,
updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
step.id,
step.templateId ?? null,
@@ -7128,6 +7136,7 @@ ${stepsSection}`;
step.mode,
step.phase || "pre-merge",
step.prompt,
step.gateMode ?? (step.mode === "script" ? "gate" : "advisory"),
step.toolMode ?? null,
step.scriptName ?? null,
step.enabled ? 1 : 0,
@@ -7170,6 +7179,7 @@ ${stepsSection}`;
mode: entry.template.mode ?? "prompt",
phase: entry.template.phase ?? "pre-merge",
prompt: entry.template.prompt ?? "",
gateMode: entry.template.gateMode ?? ((entry.template.mode ?? "prompt") === "script" ? "gate" : "advisory"),
scriptName: entry.template.scriptName,
toolMode: entry.template.toolMode,
enabled: entry.template.enabled ?? true,
@@ -7195,6 +7205,7 @@ ${stepsSection}`;
mode: string;
phase: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -7232,6 +7243,7 @@ ${stepsSection}`;
mode: string;
phase: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -7257,6 +7269,7 @@ ${stepsSection}`;
mode: string;
phase: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -7289,6 +7302,7 @@ ${stepsSection}`;
mode: string;
phase: string | null;
prompt: string;
gateMode: string | null;
toolMode: string | null;
scriptName: string | null;
enabled: number;
@@ -7317,6 +7331,7 @@ ${stepsSection}`;
// When switching to script mode, clear prompt and model overrides
if (newMode === "script") {
step.prompt = "";
step.gateMode = step.gateMode || "gate";
step.toolMode = undefined;
step.modelProvider = undefined;
step.modelId = undefined;
@@ -7324,6 +7339,7 @@ ${stepsSection}`;
// When switching to prompt mode, clear scriptName
if (newMode === "prompt") {
step.scriptName = undefined;
step.gateMode = step.gateMode || "advisory";
step.toolMode = step.toolMode || "readonly";
}
}
@@ -7333,6 +7349,7 @@ ${stepsSection}`;
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.gateMode !== undefined) step.gateMode = updates.gateMode;
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;
@@ -7353,6 +7370,7 @@ ${stepsSection}`;
mode = ?,
phase = ?,
prompt = ?,
gateMode = ?,
toolMode = ?,
scriptName = ?,
enabled = ?,
@@ -7368,6 +7386,7 @@ ${stepsSection}`;
step.mode,
step.phase || "pre-merge",
step.prompt,
step.gateMode ?? (step.mode === "script" ? "gate" : "advisory"),
step.toolMode ?? null,
step.scriptName ?? null,
step.enabled ? 1 : 0,

View File

@@ -240,6 +240,7 @@ export type WorkflowStepToolMode = "readonly" | "coding";
/** Lifecycle phase for workflow step execution. */
export type WorkflowStepPhase = "pre-merge" | "post-merge";
export type WorkflowStepGateMode = "gate" | "advisory";
export interface WorkflowStep {
/** Unique identifier (e.g., "WS-001") */
@@ -256,6 +257,8 @@ export interface WorkflowStep {
phase?: WorkflowStepPhase;
/** Full agent prompt to execute when this step runs (used when mode is "prompt") */
prompt: string;
/** Whether failures should block merge (`gate`) or be informational (`advisory`). */
gateMode?: WorkflowStepGateMode;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
@@ -352,6 +355,8 @@ export interface WorkflowStepInput {
phase?: WorkflowStepPhase;
/** Agent prompt (used when mode is "prompt"). Optional — can be AI-generated later via refinement. */
prompt?: string;
/** Failure behavior. Defaults to advisory for prompt mode and gate for script mode. */
gateMode?: WorkflowStepGateMode;
/** Tool set available to prompt-mode workflow agents. Defaults to readonly. */
toolMode?: WorkflowStepToolMode;
/** Script name from project settings (required when mode is "script").
@@ -377,7 +382,7 @@ export interface WorkflowStepResult {
/** Lifecycle phase at execution time */
phase?: WorkflowStepPhase;
/** Execution status */
status: "passed" | "failed" | "skipped" | "pending";
status: "passed" | "failed" | "advisory_failure" | "skipped" | "pending";
/** Output from the workflow step agent (findings, errors, etc.) */
output?: string;
/** ISO-8601 timestamp when the step started */
@@ -404,6 +409,8 @@ export interface WorkflowStepTemplate {
scriptName?: string;
/** Tool set available when the template runs as a prompt-mode step. */
toolMode?: WorkflowStepToolMode;
/** Failure behavior for materialized steps from this template. */
gateMode?: WorkflowStepGateMode;
/** Whether this template should be auto-selected for new tasks. */
defaultOn?: boolean;
/** AI model provider override for prompt-mode templates. */