feat(FN-3982): split monolithic store.test.ts into archive-search, run-muta

Split the monolithic `store.test.ts` suite into three focused test files (`store-workflow-steps.test.ts`, `store-archive-search.test.ts`, `store-run-mutation-context.test.ts`) and updated the test audit report with post-split timing benchmarks.

Fusion-Task-Id: FN-3982
This commit is contained in:
Fusion
2026-05-11 00:23:03 -07:00
committed by gsxdsm
parent 007efcab0b
commit ac6aaca2c2
5 changed files with 1930 additions and 1973 deletions

View File

@@ -8,6 +8,8 @@ _Date: 2026-04-08_
- Decomposed `packages/core/src/__tests__/store.test.ts` into focused suites: `store-plugin-routing.test.ts`, `store-prompt-generation.test.ts`, `store-priority.test.ts`, `store-token-usage.test.ts`, `store-persistence.test.ts`, `store-settings.test.ts`, `store-attachments.test.ts`, `store-watcher.test.ts`, and `store-migration.test.ts`.
- Local verification after the split (`pnpm --filter @fusion/core exec vitest run ...`) showed the extracted suites running in sub-second to low-single-digit durations (`store-attachments` ~0.45s, `store-migration` ~0.46s, `store-persistence` ~0.72s, `store-watcher` ~2.89s), while the remaining catch-all `store.test.ts` still measured ~23.35s and remains the largest residual core test file.
- FN-3982 second-wave follow-up (2026-05-11) extracted the remaining bottleneck domains into `store-workflow-steps.test.ts`, `store-archive-search.test.ts`, and `store-run-mutation-context.test.ts`.
- Post-split local timing snapshot (`pnpm --filter @fusion/core exec vitest run src/__tests__/store.test.ts src/__tests__/store-workflow-steps.test.ts src/__tests__/store-archive-search.test.ts src/__tests__/store-run-mutation-context.test.ts`) now shows: `store.test.ts` ~18.50s, `store-archive-search.test.ts` ~3.66s, `store-workflow-steps.test.ts` ~2.22s, and `store-run-mutation-context.test.ts` ~1.51s.
### FN-3293 stabilization update (2026-05-04)

View File

@@ -0,0 +1,983 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { AgentStore } from "../agent-store.js";
import { TaskStore } from "../store.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore Archive and Search", () => {
const harness = createTaskStoreTestHarness();
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
describe("archiveTask", () => {
it("archives a done task (moves done → archived)", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTask(task.id);
expect(archived.column).toBe("archived");
});
it("adds log entry 'Task archived'", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTask(task.id);
expect(archived.log.some((l) => l.action === "Task archived")).toBe(true);
});
it("emits task:moved event with correct from/to columns", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const events: any[] = [];
store.on("task:moved", (data) => events.push(data));
await store.archiveTask(task.id, false);
expect(events).toHaveLength(1);
expect(events[0].from).toBe("done");
expect(events[0].to).toBe("archived");
});
it("persists to disk and round-trips correctly", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const fetched = await store.getTask(task.id);
expect(fetched.column).toBe("archived");
});
it("throws error when task is not in 'done' column", async () => {
const task = await store.createTask({ description: "Test task" });
// Task starts in triage, not done
await expect(store.archiveTask(task.id)).rejects.toThrow("must be in 'done'");
});
it("updates columnMovedAt timestamp", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const beforeArchive = (await store.getTask(task.id)).columnMovedAt;
await new Promise((r) => setTimeout(r, 10));
const archived = await store.archiveTask(task.id);
expect(archived.columnMovedAt).not.toBe(beforeArchive);
expect(new Date(archived.columnMovedAt!).getTime()).toBeGreaterThan(new Date(beforeArchive!).getTime());
});
});
describe("logEntry on archived tasks", () => {
it("rejects logEntry on cleanup-archived task with archived error", async () => {
const task = await store.createTask({ description: "Cleanup archive log test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, true);
await expect(store.logEntry(task.id, "should fail")).rejects.toThrow(/archived/i);
await expect(store.logEntry(task.id, "should fail")).rejects.not.toThrow(/not found/i);
});
it("rejects logEntry on non-cleanup archived task with archived error", async () => {
const task = await store.createTask({ description: "Non-cleanup archive log test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await expect(store.logEntry(task.id, "should fail")).rejects.toThrow(/archived/i);
});
it("rejects logEntry with runContext on cleanup-archived task", async () => {
const task = await store.createTask({ description: "Cleanup archive runContext log test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, true);
await expect(
store.logEntry(task.id, "should fail", "outcome", { runId: "run-1", agentId: "agent-1" }),
).rejects.toThrow(/archived/i);
});
});
describe("unarchiveTask", () => {
it("unarchives an archived task (moves archived → done)", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
});
it("adds log entry 'Task unarchived'", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.log.some((l) => l.action === "Task unarchived")).toBe(true);
});
it("emits task:moved event with correct from/to columns", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const events: any[] = [];
store.on("task:moved", (data) => events.push(data));
await store.unarchiveTask(task.id);
expect(events).toHaveLength(1);
expect(events[0].from).toBe("archived");
expect(events[0].to).toBe("done");
});
it("persists to disk and round-trips correctly", async () => {
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await store.unarchiveTask(task.id);
const fetched = await store.getTask(task.id);
expect(fetched.column).toBe("done");
});
it("throws error when task is not in 'archived' column", async () => {
const task = await store.createTask({ description: "Test task" });
// Task starts in triage, not archived
await expect(store.unarchiveTask(task.id)).rejects.toThrow("must be in 'archived'");
});
it("clears transient fields when unarchiving (FN-985 regression)", async () => {
// Simulate a task that completed normally and was archived,
// but somehow accumulated stale transient state.
const task = await store.createTask({ description: "Test task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// After reaching done, inject stale transient fields via updateTask
// (simulating state that could leak through if transient clearing was incomplete)
await store.updateTask(task.id, {
status: "failed",
error: "Something went wrong",
worktree: "/tmp/old-worktree",
blockedBy: "FN-999",
recoveryRetryCount: 3,
nextRecoveryAt: new Date(Date.now() + 86400000).toISOString(),
});
// Archive the task with stale state
await store.archiveTask(task.id, false);
// Unarchive — should clear all transient fields
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
expect(unarchived.status).toBeUndefined();
expect(unarchived.error).toBeUndefined();
expect(unarchived.worktree).toBeUndefined();
expect(unarchived.blockedBy).toBeUndefined();
expect(unarchived.recoveryRetryCount).toBeUndefined();
expect(unarchived.nextRecoveryAt).toBeUndefined();
});
});
describe("archiveAllDone", () => {
it("archives multiple done tasks", async () => {
const task1 = await store.createTask({ description: "Test task 1" });
const task2 = await store.createTask({ description: "Test task 2" });
const task3 = await store.createTask({ description: "Test task 3" });
// Move all to done
for (const task of [task1, task2, task3]) {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
}
const archived = await store.archiveAllDone();
expect(archived).toHaveLength(3);
expect(archived.every((t) => t.column === "archived")).toBe(true);
});
it("returns empty array when no done tasks exist", async () => {
const result = await store.archiveAllDone();
expect(result).toEqual([]);
});
it("emits task:moved event for each archived task", async () => {
const task1 = await store.createTask({ description: "Test task 1" });
const task2 = await store.createTask({ description: "Test task 2" });
for (const task of [task1, task2]) {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
}
const events: any[] = [];
store.on("task:moved", (data) => events.push(data));
await store.archiveAllDone();
expect(events).toHaveLength(2);
expect(events.every((e) => e.from === "done" && e.to === "archived")).toBe(true);
});
it("does not affect tasks in other columns", async () => {
const doneTask = await store.createTask({ description: "Done task" });
await store.moveTask(doneTask.id, "todo");
await store.moveTask(doneTask.id, "in-progress");
await store.moveTask(doneTask.id, "in-review");
await store.moveTask(doneTask.id, "done");
const todoTask = await store.createTask({ description: "Todo task" });
await store.moveTask(todoTask.id, "todo");
const inProgressTask = await store.createTask({ description: "In progress task" });
await store.moveTask(inProgressTask.id, "todo");
await store.moveTask(inProgressTask.id, "in-progress");
await store.archiveAllDone();
const fetchedTodo = await store.getTask(todoTask.id);
const fetchedInProgress = await store.getTask(inProgressTask.id);
expect(fetchedTodo.column).toBe("todo");
expect(fetchedInProgress.column).toBe("in-progress");
});
it("archives only done tasks when mixed columns exist", async () => {
const doneTask1 = await store.createTask({ description: "Done task 1" });
const doneTask2 = await store.createTask({ description: "Done task 2" });
const todoTask = await store.createTask({ description: "Todo task" });
for (const task of [doneTask1, doneTask2]) {
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
}
await store.moveTask(todoTask.id, "todo");
const archived = await store.archiveAllDone();
expect(archived).toHaveLength(2);
expect(archived.map((t) => t.id).sort()).toEqual([doneTask1.id, doneTask2.id].sort());
});
});
describe("cleanupArchivedTasks", () => {
it("writes compact entry to archive DB with compact agent log", async () => {
// This test asserts the archive.db file exists on disk, which the
// in-memory beforeEach store can't satisfy. Swap to disk-backed.
await harness.reopenDiskBackedStore();
store = harness.store();
// Create and archive a task
const task = await store.createTask({ description: "Test cleanup", title: "Cleanup Task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Add an agent log entry before archive; compact archive mode should
// preserve a bounded snapshot, not the legacy task.log payload.
await store.appendAgentLog(task.id, "Test agent log", "text");
await store.archiveTask(task.id, false);
const cleaned = await store.cleanupArchivedTasks();
expect(cleaned).toContain(task.id);
// Read from store's archive API
const entry = await store.findInArchive(task.id);
expect(entry).toBeDefined();
expect(entry!.id).toBe(task.id);
expect(entry!.title).toBe("Cleanup Task");
expect(entry!.description).toBe("Test cleanup");
expect(entry!.column).toBe("archived");
expect(entry!.log).toHaveLength(1);
expect(entry!.log[0].action).toBe("Task archived");
expect(entry!.agentLogMode).toBe("compact");
expect(entry!.agentLogSummary).toContain("Agent log entries: 1");
expect(entry!.agentLogSnapshot).toHaveLength(1);
expect(entry).not.toHaveProperty("agentLogFull");
const archivedDetail = await store.getTask(task.id);
expect(archivedDetail.column).toBe("archived");
expect(existsSync(join(harness.rootDir(), ".fusion", "archive.db"))).toBe(true);
});
it("removes task directory after archiving", async () => {
const task = await store.createTask({ description: "Test dir removal" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
expect(existsSync(dir)).toBe(true);
await store.cleanupArchivedTasks();
expect(existsSync(dir)).toBe(false);
});
it("skips already-cleaned-up tasks (idempotent)", async () => {
const task = await store.createTask({ description: "Test idempotent" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const cleaned1 = await store.cleanupArchivedTasks();
expect(cleaned1).toContain(task.id);
const cleaned2 = await store.cleanupArchivedTasks();
expect(cleaned2).toHaveLength(0);
});
it("preserves task metadata in archive entry", async () => {
const task = await store.createTask({
description: "Test metadata",
title: "Metadata Task",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Add some metadata via updateTask
await store.updateTask(task.id, {
reviewLevel: 2,
size: "M",
});
// Add an attachment (metadata only, no content)
await store.addAttachment(task.id, "test.txt", Buffer.from("test"), "text/plain");
await store.archiveTask(task.id, false);
await store.cleanupArchivedTasks();
// Read from store's archive API
const entry = await store.findInArchive(task.id);
expect(entry).toBeDefined();
expect(entry!.id).toBe(task.id);
expect(entry!.title).toBe("Metadata Task");
expect(entry!.size).toBe("M");
expect(entry!.reviewLevel).toBe(2);
expect(entry!.attachments).toHaveLength(1);
expect(entry!.attachments![0].originalName).toBe("test.txt");
});
it("honors archiveAgentLogMode none", async () => {
await store.updateSettings({ archiveAgentLogMode: "none" });
const task = await store.createTask({ description: "No agent log archive" });
await store.appendAgentLog(task.id, "Should not be archived", "text");
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const entry = await store.findInArchive(task.id);
expect(entry?.agentLogMode).toBe("none");
expect(entry?.agentLogSummary).toBeUndefined();
expect(entry?.agentLogSnapshot).toBeUndefined();
expect(entry?.agentLogFull).toBeUndefined();
});
it("honors archiveAgentLogMode full", async () => {
await store.updateSettings({ archiveAgentLogMode: "full" });
const task = await store.createTask({ description: "Full agent log archive" });
await store.appendAgentLog(task.id, "First full entry", "text");
await store.appendAgentLog(task.id, "Second full entry", "tool", "Read file", "executor");
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const entry = await store.findInArchive(task.id);
expect(entry?.agentLogMode).toBe("full");
expect(entry?.agentLogSummary).toContain("Agent log entries: 2");
expect(entry?.agentLogFull).toHaveLength(2);
expect(entry?.agentLogSnapshot).toBeUndefined();
});
});
describe("readArchiveLog", () => {
it("returns empty array when archive DB has no tasks", async () => {
const entries = await store.readArchiveLog();
expect(entries).toEqual([]);
});
it("returns parsed entries from archive DB", async () => {
const task = await store.createTask({ description: "Test read" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await store.cleanupArchivedTasks();
const entries = await store.readArchiveLog();
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe(task.id);
expect(entries[0].description).toBe("Test read");
});
it("handles multiple entries in archive DB", async () => {
// Archive and cleanup task 1
const task1 = await store.createTask({ description: "Task 1" });
await store.moveTask(task1.id, "todo");
await store.moveTask(task1.id, "in-progress");
await store.moveTask(task1.id, "in-review");
await store.moveTask(task1.id, "done");
await store.archiveTask(task1.id);
await store.cleanupArchivedTasks();
// Archive and cleanup task 2
const task2 = await store.createTask({ description: "Task 2" });
await store.moveTask(task2.id, "todo");
await store.moveTask(task2.id, "in-progress");
await store.moveTask(task2.id, "in-review");
await store.moveTask(task2.id, "done");
await store.archiveTask(task2.id);
await store.cleanupArchivedTasks();
const entries = await store.readArchiveLog();
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.id).sort()).toEqual([task1.id, task2.id].sort());
});
});
describe("findInArchive", () => {
it("returns undefined when task not in archive", async () => {
const entry = await store.findInArchive("KB-999");
expect(entry).toBeUndefined();
});
it("returns archive entry for specific task", async () => {
const task = await store.createTask({ description: "Test find" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const entry = await store.findInArchive(task.id);
expect(entry).toBeDefined();
expect(entry!.id).toBe(task.id);
expect(entry!.description).toBe("Test find");
});
it("keeps comments searchable from the archive database while excluding task logs", async () => {
const task = await store.createTask({ description: "Archived search body" });
await store.addComment(task.id, "needle-comment", "tester");
await store.logEntry(task.id, "needle-log-only");
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTaskAndCleanup(task.id);
const commentMatches = await store.searchTasks("needle-comment", { includeArchived: true });
expect(commentMatches.map((match) => match.id)).toContain(task.id);
const logMatches = await store.searchTasks("needle-log-only", { includeArchived: true });
expect(logMatches.map((match) => match.id)).not.toContain(task.id);
});
});
describe("unarchiveTask with restore", () => {
it("restores missing task from archive DB", async () => {
const task = await store.createTask({ description: "Test restore" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
// Unarchive should restore from archive
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
expect(unarchived.description).toBe("Test restore");
// Directory should be recreated
expect(existsSync(dir)).toBe(true);
});
it("works normally when task directory exists", async () => {
const task = await store.createTask({ description: "Test normal" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
// Note: NOT calling cleanupArchivedTasks, so directory exists
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
});
it("restored task has correct column (done) and preserved metadata", async () => {
const task = await store.createTask({
description: "Test metadata preserve",
title: "Preserved Task",
});
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Set metadata via updateTask
await store.updateTask(task.id, { size: "L", reviewLevel: 2 });
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.column).toBe("done");
expect(unarchived.title).toBe("Preserved Task");
expect(unarchived.size).toBe("L");
expect(unarchived.reviewLevel).toBe(2);
expect(unarchived.description).toBe("Test metadata preserve");
});
it("throws error when task directory missing and not in archive", async () => {
// Create a fake archived task by manually moving column
const task = await store.createTask({ description: "Not in archive" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
(store as any).archiveDb.delete(task.id);
// Delete directory without archiving
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true, force: true });
await expect(store.unarchiveTask(task.id)).rejects.toThrow("not found in archive");
});
it("adds log entry for restore action", async () => {
const task = await store.createTask({ description: "Test restore log" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const unarchived = await store.unarchiveTask(task.id);
expect(unarchived.log.some((l) => l.action === "Task restored from archive")).toBe(true);
expect(unarchived.log.some((l) => l.action === "Task unarchived")).toBe(true);
});
it("recreates PROMPT.md after restore", async () => {
const task = await store.createTask({ description: "Test prompt restore" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
await store.unarchiveTask(task.id);
// Verify PROMPT.md was recreated
const detail = await store.getTask(task.id);
expect(detail.prompt).toContain(task.id);
expect(detail.prompt).toContain("Test prompt restore");
});
it("recreates attachments directory (empty) after restore", async () => {
const task = await store.createTask({ description: "Test attach restore" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Add an attachment
await store.addAttachment(task.id, "test.txt", Buffer.from("test"), "text/plain");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
await store.unarchiveTask(task.id);
// Directory should exist with empty attachments folder
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "attachments"))).toBe(true);
});
});
describe("archiveTask with cleanup", () => {
it("archiveTask(true) archives and cleans up immediately", async () => {
const task = await store.createTask({ description: "Immediate cleanup" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTask(task.id, true);
expect(archived.column).toBe("archived");
// Directory should be gone immediately
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
// Should be in archive DB
const entry = await store.findInArchive(task.id);
expect(entry).toBeDefined();
expect(entry!.description).toBe("Immediate cleanup");
});
it("archiveTaskAndCleanup is convenience method", async () => {
const task = await store.createTask({ description: "Convenience method" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTaskAndCleanup(task.id);
expect(archived.column).toBe("archived");
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
});
it("archiveTask(false) preserves directory for explicit non-cleanup archives", async () => {
const task = await store.createTask({ description: "No cleanup" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTask(task.id, false);
expect(archived.column).toBe("archived");
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
expect(existsSync(dir)).toBe(true);
});
it("default cleanup parameter removes active task storage", async () => {
const task = await store.createTask({ description: "Default cleanup" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const archived = await store.archiveTask(task.id); // No cleanup param
expect(archived.column).toBe("archived");
// Directory should be removed by default
const dir = join(harness.rootDir(), ".fusion", "tasks", task.id);
expect(existsSync(dir)).toBe(false);
});
it("archiveTask clears stale linked agent assignments", async () => {
await harness.reopenDiskBackedStore();
store = harness.store();
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
try {
const task = await store.createTask({ description: "Archive clears links" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const agent = await agentStore.createAgent({ name: "Archive watcher", role: "executor" });
await agentStore.assignTask(agent.id, task.id);
await store.archiveTask(task.id, false);
const updatedAgent = await agentStore.getAgent(agent.id);
expect(updatedAgent?.taskId).toBeUndefined();
} finally {
agentStore.close();
}
});
});
describe("archive log persistence", () => {
it("archive log survives TaskStore reinitialization", async () => {
// Cross-instance persistence test — beforeEach creates an in-memory
// store, but this test verifies disk persistence. Swap to a
// disk-backed store before doing any work so newStore (also
// disk-backed) can read what the first instance wrote.
await harness.reopenDiskBackedStore();
store = harness.store();
const task = await store.createTask({ description: "Survival test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
await store.cleanupArchivedTasks();
// Create new store instance
const newStore = new TaskStore(harness.rootDir(), harness.globalDir());
await newStore.init();
const entries = await newStore.readArchiveLog();
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe(task.id);
expect(entries[0].description).toBe("Survival test");
newStore.close();
});
});
// ── Activity Log Tests ───────────────────────────────────────────
describe("searchTasks", () => {
it("searches tasks by ID", async () => {
const task1 = await store.createTask({ description: "First task" });
const task2 = await store.createTask({ description: "Second task" });
const results = await store.searchTasks("FN-001");
expect(results).toHaveLength(1);
expect(results[0].id).toBe("FN-001");
expect(results.some((t) => t.id === "FN-002")).toBe(false);
});
it("searches tasks by title", async () => {
await store.createTask({ title: "Fix login bug", description: "Login issue" });
await store.createTask({ title: "Add dashboard feature", description: "New UI" });
const results = await store.searchTasks("dashboard");
expect(results).toHaveLength(1);
expect(results[0].title).toBe("Add dashboard feature");
});
it("searches tasks by description", async () => {
await store.createTask({ description: "Fix the login button on the homepage" });
await store.createTask({ description: "Update the settings page layout" });
const results = await store.searchTasks("homepage");
expect(results).toHaveLength(1);
expect(results[0].description).toContain("homepage");
});
it("supports slim search results without loading task logs", async () => {
const uniqueTerm = `slimsearchpayload${Date.now()}`;
const task = await store.createTask({ description: `Slim search payload ${uniqueTerm}` });
await store.logEntry(task.id, "heavy log entry that should not appear in slim search");
const fullResults = await store.searchTasks(uniqueTerm);
const slimResults = await store.searchTasks(uniqueTerm, { slim: true });
const full = fullResults.find((result) => result.id === task.id)!;
const slim = slimResults.find((result) => result.id === task.id)!;
expect(full.log.length).toBeGreaterThan(0);
expect(slim.id).toBe(task.id);
expect(slim.log).toEqual([]);
});
it("can exclude archived tasks from search results", async () => {
const uniqueTerm = `archivedsearchpayload${Date.now()}`;
const task = await store.createTask({ description: `Archived search payload ${uniqueTerm}` });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id);
const withArchived = await store.searchTasks(uniqueTerm);
const withoutArchived = await store.searchTasks(uniqueTerm, { includeArchived: false });
expect(withArchived.some((result) => result.id === task.id)).toBe(true);
expect(withoutArchived.some((result) => result.id === task.id)).toBe(false);
});
it("searches tasks by comment text", async () => {
const task = await store.createTask({ description: "A task" });
// Add a comment containing a unique word
await store.addComment(task.id, "Need to prioritize the xylophone implementation", "tester");
const results = await store.searchTasks("xylophone");
expect(results).toHaveLength(1);
expect(results[0].id).toBe(task.id);
});
it("is case insensitive", async () => {
await store.createTask({ title: "UPPERCASE SEARCH TEST", description: "Testing case insensitivity" });
const results = await store.searchTasks("uppercase");
expect(results).toHaveLength(1);
expect(results[0].title).toBe("UPPERCASE SEARCH TEST");
});
it("falls back to listTasks for empty query", async () => {
await store.createTask({ description: "Task 1" });
await store.createTask({ description: "Task 2" });
const results = await store.searchTasks("");
const allTasks = await store.listTasks();
expect(results).toHaveLength(allTasks.length);
});
it("falls back to listTasks for whitespace-only query", async () => {
await store.createTask({ description: "Task 1" });
const results = await store.searchTasks(" ");
expect(results).toHaveLength(1);
});
it("uses OR semantics for multi-word queries", async () => {
await store.createTask({ title: "Fix login", description: "Button issues" });
await store.createTask({ title: "Add dashboard", description: "New features" });
const results = await store.searchTasks("login dashboard");
expect(results).toHaveLength(2);
});
it("returns empty array for non-existent query", async () => {
await store.createTask({ description: "Regular task description" });
const results = await store.searchTasks("xyznonexistent12345");
expect(results).toHaveLength(0);
});
it("respects limit option", async () => {
await store.createTask({ description: "Task 1" });
await store.createTask({ description: "Task 2" });
await store.createTask({ description: "Task 3" });
await store.createTask({ description: "Task 4" });
await store.createTask({ description: "Task 5" });
const results = await store.searchTasks("", { limit: 2 });
expect(results).toHaveLength(2);
});
it("respects offset option", async () => {
await store.createTask({ description: "Task 1" });
await store.createTask({ description: "Task 2" });
await store.createTask({ description: "Task 3" });
const allResults = await store.searchTasks("");
const offsetResults = await store.searchTasks("", { offset: 1 });
expect(allResults.length).toBe(3);
expect(offsetResults.length).toBe(2);
expect(offsetResults[0].id).toBe(allResults[1].id);
});
it("immediately indexes new comments", async () => {
const task = await store.createTask({ description: "A task without comments" });
const uniqueWord = `unique_search_term_${Date.now()}`;
// Initially should not be found
const beforeResults = await store.searchTasks(uniqueWord);
expect(beforeResults).toHaveLength(0);
// Add comment with unique word
await store.addComment(task.id, `Important note about the ${uniqueWord} feature`, "tester");
// Should now be found immediately (trigger fires synchronously)
const afterResults = await store.searchTasks(uniqueWord);
expect(afterResults).toHaveLength(1);
expect(afterResults[0].id).toBe(task.id);
});
it("sanitizes FTS5 special characters from query", async () => {
await store.createTask({ title: "Test with special chars", description: "Query parsing test" });
// This should not throw and should work correctly
const results = await store.searchTasks("test + special (chars)");
expect(results.length).toBeGreaterThanOrEqual(0); // Should not throw
});
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { TaskStore } from "../store.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore RunMutationContext", () => {
const harness = createTaskStoreTestHarness();
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
describe("RunMutationContext", () => {
it("logEntry() with runContext includes runContext field", async () => {
const task = await store.createTask({ description: "Test task" });
const runContext = { runId: "run-123", agentId: "agent-456" };
await store.logEntry(task.id, "Test action", "Test outcome", runContext);
const updatedTask = await store.getTask(task.id);
expect(updatedTask.log).toHaveLength(2);
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toEqual(runContext);
expect(lastEntry.action).toBe("Test action");
expect(lastEntry.outcome).toBe("Test outcome");
});
it("logEntry() without runContext has no runContext field (backward compat)", async () => {
const task = await store.createTask({ description: "Test task" });
await store.logEntry(task.id, "Test action", "Test outcome");
const updatedTask = await store.getTask(task.id);
expect(updatedTask.log).toHaveLength(2);
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toBeUndefined();
expect(lastEntry.action).toBe("Test action");
});
it("logEntry() bounds retained activity entries and truncates large outcomes", async () => {
const task = await store.createTask({ description: "Test task" });
const longOutcome = "x".repeat(5_000);
for (let index = 0; index < 1_005; index += 1) {
await store.logEntry(task.id, `Action ${index}`, index === 1_004 ? longOutcome : undefined);
}
const updatedTask = await store.getTask(task.id);
expect(updatedTask.log).toHaveLength(1_000);
expect(updatedTask.log[0].action).toBe("Action 5");
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.action).toBe("Action 1004");
expect(lastEntry.outcome?.length).toBeLessThan(longOutcome.length);
expect(lastEntry.outcome).toContain("outcome truncated");
}, 180_000);
it("addComment() with runContext includes runContext in log entry", async () => {
const task = await store.createTask({ description: "Test task" });
const runContext = { runId: "run-789", agentId: "agent-101" };
await store.addComment(task.id, "Test comment", "user", undefined, runContext);
const updatedTask = await store.getTask(task.id);
expect(updatedTask.comments).toHaveLength(1);
expect(updatedTask.comments![0].text).toBe("Test comment");
expect(updatedTask.log).toHaveLength(2);
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toEqual(runContext);
});
it("addSteeringComment() forwards runContext to addComment", async () => {
const task = await store.createTask({ description: "Test task" });
const runContext = { runId: "run-abc", agentId: "agent-def", source: "timer" };
await store.addSteeringComment(task.id, "Steering comment", "agent", runContext);
const updatedTask = await store.getTask(task.id);
expect(updatedTask.steeringComments).toHaveLength(1);
expect(updatedTask.steeringComments![0].text).toBe("Steering comment");
expect(updatedTask.log).toHaveLength(2);
const lastEntry = updatedTask.log[updatedTask.log.length - 1];
expect(lastEntry.runContext).toEqual(runContext);
});
it("getMutationsForRun(runId) returns only entries matching the runId, sorted by timestamp", async () => {
const task1 = await store.createTask({ description: "Task 1" });
const task2 = await store.createTask({ description: "Task 2" });
await store.logEntry(task1.id, "Action 1", undefined, { runId: "run-target", agentId: "agent-1" });
await new Promise((r) => setTimeout(r, 10));
await store.logEntry(task2.id, "Action 2", undefined, { runId: "run-target", agentId: "agent-1" });
await new Promise((r) => setTimeout(r, 10));
await store.logEntry(task1.id, "Action 3", undefined, { runId: "run-other", agentId: "agent-2" });
const mutations = await store.getMutationsForRun("run-target");
expect(mutations).toHaveLength(2);
expect(mutations.map((m) => m.action)).toEqual(["Action 1", "Action 2"]);
expect(new Date(mutations[0].timestamp).getTime()).toBeLessThan(new Date(mutations[1].timestamp).getTime());
});
it("getMutationsForRun(unknownRunId) returns empty array", async () => {
const task = await store.createTask({ description: "Test task" });
await store.logEntry(task.id, "Some action", undefined, { runId: "run-existing", agentId: "agent-1" });
const mutations = await store.getMutationsForRun("run-does-not-exist");
expect(mutations).toEqual([]);
});
it("getMutationsForRun() collects entries across multiple tasks", async () => {
const task1 = await store.createTask({ description: "Task 1" });
const task2 = await store.createTask({ description: "Task 2" });
const task3 = await store.createTask({ description: "Task 3" });
await store.logEntry(task1.id, "Entry 1", undefined, { runId: "run-shared", agentId: "agent-x" });
await store.logEntry(task2.id, "Entry 2", undefined, { runId: "run-shared", agentId: "agent-x" });
await store.logEntry(task3.id, "Entry 3", undefined, { runId: "run-other", agentId: "agent-y" });
const mutations = await store.getMutationsForRun("run-shared");
expect(mutations).toHaveLength(2);
expect(mutations.map((m) => m.action).sort()).toEqual(["Entry 1", "Entry 2"]);
});
});
});

View File

@@ -0,0 +1,814 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { TaskStore } from "../store.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
describe("TaskStore Workflow Steps", () => {
const harness = createTaskStoreTestHarness();
let store: TaskStore;
beforeEach(async () => {
await harness.beforeEach();
store = harness.store();
});
afterEach(async () => {
await harness.afterEach();
});
describe("Workflow Steps", () => {
it("should create a workflow step with all fields", async () => {
const ws = await store.createWorkflowStep({
name: "Documentation Review",
description: "Verify all public APIs have documentation",
prompt: "Review the task changes and verify that all new public functions have docs.",
enabled: true,
});
expect(ws.id).toBe("WS-001");
expect(ws.name).toBe("Documentation Review");
expect(ws.description).toBe("Verify all public APIs have documentation");
expect(ws.mode).toBe("prompt");
expect(ws.prompt).toBe("Review the task changes and verify that all new public functions have docs.");
expect(ws.scriptName).toBeUndefined();
expect(ws.enabled).toBe(true);
expect(ws.createdAt).toBeDefined();
expect(ws.updatedAt).toBeDefined();
});
it("should create a workflow step with minimal fields", async () => {
const ws = await store.createWorkflowStep({
name: "QA Check",
description: "Run tests and verify they pass",
});
expect(ws.id).toBe("WS-001");
expect(ws.name).toBe("QA Check");
expect(ws.description).toBe("Run tests and verify they pass");
expect(ws.mode).toBe("prompt"); // Default mode
expect(ws.prompt).toBe(""); // Empty when not provided
expect(ws.enabled).toBe(true); // Default enabled
});
it("should create a script-mode workflow step", async () => {
const ws = await store.createWorkflowStep({
name: "Run Tests",
description: "Execute the test suite",
mode: "script",
scriptName: "test",
});
expect(ws.id).toBe("WS-001");
expect(ws.name).toBe("Run Tests");
expect(ws.mode).toBe("script");
expect(ws.prompt).toBe("");
expect(ws.scriptName).toBe("test");
expect(ws.modelProvider).toBeUndefined();
expect(ws.modelId).toBeUndefined();
expect(ws.enabled).toBe(true);
});
it("should reject script mode without scriptName", async () => {
await expect(
store.createWorkflowStep({
name: "Broken",
description: "No script name",
mode: "script",
}),
).rejects.toThrow("Script mode requires a scriptName");
});
it("should reject script mode with empty scriptName", async () => {
await expect(
store.createWorkflowStep({
name: "Broken",
description: "Empty script name",
mode: "script",
scriptName: " ",
}),
).rejects.toThrow("Script mode requires a scriptName");
});
it("should auto-increment workflow step IDs", async () => {
const ws1 = await store.createWorkflowStep({ name: "Step 1", description: "First" });
const ws2 = await store.createWorkflowStep({ name: "Step 2", description: "Second" });
const ws3 = await store.createWorkflowStep({ name: "Step 3", description: "Third" });
expect(ws1.id).toBe("WS-001");
expect(ws2.id).toBe("WS-002");
expect(ws3.id).toBe("WS-003");
});
it("should list workflow steps", async () => {
await store.createWorkflowStep({ name: "Step 1", description: "First" });
await store.createWorkflowStep({ name: "Step 2", description: "Second" });
const steps = await store.listWorkflowSteps();
expect(steps).toHaveLength(2);
expect(steps[0].name).toBe("Step 1");
expect(steps[1].name).toBe("Step 2");
});
it("should return empty array when no workflow steps exist", async () => {
const steps = await store.listWorkflowSteps();
expect(steps).toHaveLength(0);
});
it("should get a single workflow step by ID", async () => {
const ws = await store.createWorkflowStep({ name: "Docs", description: "Check docs" });
const found = await store.getWorkflowStep(ws.id);
expect(found).toBeDefined();
expect(found!.id).toBe(ws.id);
expect(found!.name).toBe("Docs");
});
it("should return undefined for non-existent workflow step", async () => {
const found = await store.getWorkflowStep("WS-999");
expect(found).toBeUndefined();
});
it("should resolve plugin workflow steps from injected templates", async () => {
store.setPluginWorkflowStepTemplates([
{
pluginId: "my-plugin",
template: {
id: "plugin:my-plugin:my-step",
name: "My Plugin Step",
description: "Plugin-provided step",
prompt: "Run plugin checks",
toolMode: "readonly",
category: "Plugin",
icon: "puzzle",
},
},
]);
const step = await store.getWorkflowStep("plugin:my-plugin:my-step");
expect(step).toMatchObject({
id: "plugin:my-plugin:my-step",
templateId: "my-step",
name: "My Plugin Step",
mode: "prompt",
phase: "pre-merge",
enabled: true,
});
});
it("should list db workflow steps and plugin workflow steps together", async () => {
const dbStep = await store.createWorkflowStep({ name: "DB Step", description: "stored" });
store.setPluginWorkflowStepTemplates([
{
pluginId: "my-plugin",
template: {
id: "plugin:my-plugin:my-step",
name: "My Plugin Step",
description: "Plugin-provided step",
prompt: "Run plugin checks",
toolMode: "coding",
category: "Plugin",
icon: "puzzle",
},
},
]);
const steps = await store.listWorkflowSteps();
expect(steps.map((step) => step.id)).toEqual([dbStep.id, "plugin:my-plugin:my-step"]);
});
it("should list disabled plugin steps without auto-materializing them", async () => {
store.setPluginWorkflowStepTemplates([
{
pluginId: "my-plugin",
template: {
id: "plugin:my-plugin:disabled-step",
name: "Disabled Plugin Step",
description: "Plugin-provided step",
prompt: "Run plugin checks",
toolMode: "readonly",
category: "Plugin",
icon: "puzzle",
enabled: false,
},
},
]);
const listed = await store.listWorkflowSteps();
expect(listed.find((step) => step.id === "plugin:my-plugin:disabled-step")?.enabled).toBe(false);
const task = await store.createTask({
description: "Task with plugin-only workflow steps",
enabledWorkflowSteps: ["plugin:my-plugin:disabled-step"],
});
expect(task.enabledWorkflowSteps).toEqual(["plugin:my-plugin:disabled-step"]);
});
it("should keep plugin workflow IDs unchanged while materializing built-in templates", async () => {
store.setPluginWorkflowStepTemplates([
{
pluginId: "my-plugin",
template: {
id: "plugin:my-plugin:my-step",
name: "My Plugin Step",
description: "Plugin-provided step",
prompt: "Run plugin checks",
toolMode: "readonly",
category: "Plugin",
icon: "puzzle",
},
},
]);
const task = await store.createTask({
description: "Task with mixed workflow steps",
enabledWorkflowSteps: ["plugin:my-plugin:my-step", "browser-verification"],
});
expect(task.enabledWorkflowSteps).toEqual(["plugin:my-plugin:my-step", "WS-001"]);
});
it("should update a workflow step", async () => {
const ws = await store.createWorkflowStep({
name: "Original",
description: "Original desc",
prompt: "Original prompt",
});
const updated = await store.updateWorkflowStep(ws.id, {
name: "Updated",
description: "Updated desc",
prompt: "Updated prompt",
enabled: false,
});
expect(updated.name).toBe("Updated");
expect(updated.description).toBe("Updated desc");
expect(updated.mode).toBe("prompt");
expect(updated.prompt).toBe("Updated prompt");
expect(updated.enabled).toBe(false);
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(ws.updatedAt).getTime()
);
});
it("should switch a workflow step from prompt to script mode", async () => {
const ws = await store.createWorkflowStep({
name: "Docs",
description: "Check docs",
prompt: "Review documentation.",
mode: "prompt",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
const updated = await store.updateWorkflowStep(ws.id, {
mode: "script",
scriptName: "lint",
});
expect(updated.mode).toBe("script");
expect(updated.scriptName).toBe("lint");
expect(updated.prompt).toBe(""); // Cleared on mode switch
expect(updated.modelProvider).toBeUndefined(); // Cleared on mode switch
expect(updated.modelId).toBeUndefined(); // Cleared on mode switch
});
it("should switch a workflow step from script to prompt mode", async () => {
const ws = await store.createWorkflowStep({
name: "Lint",
description: "Run linting",
mode: "script",
scriptName: "lint",
});
const updated = await store.updateWorkflowStep(ws.id, {
mode: "prompt",
prompt: "Review code quality.",
});
expect(updated.mode).toBe("prompt");
expect(updated.scriptName).toBeUndefined(); // Cleared on mode switch
expect(updated.prompt).toBe("Review code quality.");
});
it("should reject switching to script mode without scriptName", async () => {
const ws = await store.createWorkflowStep({
name: "Docs",
description: "Check docs",
prompt: "Review documentation.",
});
await expect(
store.updateWorkflowStep(ws.id, { mode: "script" }),
).rejects.toThrow("Script mode requires a scriptName");
});
it("should ignore prompt updates for script-mode steps", async () => {
const ws = await store.createWorkflowStep({
name: "Lint",
description: "Run linting",
mode: "script",
scriptName: "lint",
});
const updated = await store.updateWorkflowStep(ws.id, {
prompt: "This should be ignored",
});
expect(updated.prompt).toBe(""); // Prompt not updated for script mode
});
it("should ignore model override updates for script-mode steps", async () => {
const ws = await store.createWorkflowStep({
name: "Lint",
description: "Run linting",
mode: "script",
scriptName: "lint",
});
const updated = await store.updateWorkflowStep(ws.id, {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
// Model overrides should not be set for script mode
expect(updated.modelProvider).toBeUndefined();
expect(updated.modelId).toBeUndefined();
});
it("should throw when updating non-existent workflow step", async () => {
await expect(
store.updateWorkflowStep("WS-999", { name: "Nope" })
).rejects.toThrow("Workflow step 'WS-999' not found");
});
it("should delete a workflow step", async () => {
const ws = await store.createWorkflowStep({ name: "ToDelete", description: "Gone" });
await store.deleteWorkflowStep(ws.id);
const steps = await store.listWorkflowSteps();
expect(steps).toHaveLength(0);
});
it("should throw when deleting non-existent workflow step", async () => {
await expect(store.deleteWorkflowStep("WS-999")).rejects.toThrow(
"Workflow step 'WS-999' not found"
);
});
it("should remove references from tasks when deleting a workflow step", async () => {
const ws = await store.createWorkflowStep({ name: "Docs", description: "Check docs" });
const task = await store.createTask({
description: "Test task with workflow steps",
enabledWorkflowSteps: [ws.id],
});
expect(task.enabledWorkflowSteps).toEqual([ws.id]);
await store.deleteWorkflowStep(ws.id);
// Wait for async cleanup
await new Promise((r) => setTimeout(r, 50));
const updatedTask = await store.getTask(task.id);
expect(updatedTask.enabledWorkflowSteps).toBeUndefined();
});
it("should create a task with enabledWorkflowSteps", async () => {
const ws1 = await store.createWorkflowStep({ name: "Docs", description: "Check docs" });
const ws2 = await store.createWorkflowStep({ name: "QA", description: "Run tests" });
const task = await store.createTask({
description: "Task with workflow steps",
enabledWorkflowSteps: [ws1.id, ws2.id],
});
expect(task.enabledWorkflowSteps).toEqual([ws1.id, ws2.id]);
});
it("should materialize built-in workflow templates when creating a task", async () => {
const task = await store.createTask({
description: "Task with browser verification",
enabledWorkflowSteps: ["browser-verification"],
});
expect(task.enabledWorkflowSteps).toEqual(["WS-001"]);
const step = await store.getWorkflowStep("WS-001");
expect(step).toMatchObject({
id: "WS-001",
templateId: "browser-verification",
name: "Browser Verification",
toolMode: "coding",
});
});
it("should reuse an existing materialized built-in workflow step", async () => {
const first = await store.createTask({
description: "First browser verification task",
enabledWorkflowSteps: ["browser-verification"],
});
const second = await store.createTask({
description: "Second browser verification task",
enabledWorkflowSteps: ["browser-verification"],
});
expect(first.enabledWorkflowSteps).toEqual(["WS-001"]);
expect(second.enabledWorkflowSteps).toEqual(["WS-001"]);
const steps = await store.listWorkflowSteps();
expect(steps.filter((step) => step.templateId === "browser-verification")).toHaveLength(1);
});
it("should materialize frontend-ux-design built-in template when creating a task", async () => {
const task = await store.createTask({
description: "Task with frontend UX design review",
enabledWorkflowSteps: ["frontend-ux-design"],
});
expect(task.enabledWorkflowSteps).toEqual(["WS-001"]);
const step = await store.getWorkflowStep("WS-001");
expect(step).toMatchObject({
id: "WS-001",
templateId: "frontend-ux-design",
name: "Frontend UX Design",
toolMode: "readonly",
});
});
it("should not set enabledWorkflowSteps when empty array provided", async () => {
const task = await store.createTask({
description: "Task without workflow steps",
enabledWorkflowSteps: [],
});
expect(task.enabledWorkflowSteps).toBeUndefined();
});
it("should create a workflow step with model override", async () => {
const ws = await store.createWorkflowStep({
name: "Security Audit",
description: "Check for security issues",
prompt: "Scan for vulnerabilities.",
enabled: true,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
expect(ws.modelProvider).toBe("anthropic");
expect(ws.modelId).toBe("claude-sonnet-4-5");
});
it("should create a workflow step without model override", async () => {
const ws = await store.createWorkflowStep({
name: "QA Check",
description: "Run tests",
});
expect(ws.modelProvider).toBeUndefined();
expect(ws.modelId).toBeUndefined();
});
it("should update a workflow step model override", async () => {
const ws = await store.createWorkflowStep({
name: "Docs",
description: "Check docs",
});
const updated = await store.updateWorkflowStep(ws.id, {
modelProvider: "openai",
modelId: "gpt-4o",
});
expect(updated.modelProvider).toBe("openai");
expect(updated.modelId).toBe("gpt-4o");
});
it("should clear a workflow step model override by setting to undefined", async () => {
const ws = await store.createWorkflowStep({
name: "Docs",
description: "Check docs",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
expect(ws.modelProvider).toBe("anthropic");
const updated = await store.updateWorkflowStep(ws.id, {
modelProvider: undefined,
modelId: undefined,
});
expect(updated.modelProvider).toBeUndefined();
expect(updated.modelId).toBeUndefined();
});
it("should persist model override across list/get", async () => {
const ws = await store.createWorkflowStep({
name: "Perf Review",
description: "Check performance",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
});
const listed = await store.listWorkflowSteps();
expect(listed[0].modelProvider).toBe("anthropic");
expect(listed[0].modelId).toBe("claude-sonnet-4-5");
const found = await store.getWorkflowStep(ws.id);
expect(found!.modelProvider).toBe("anthropic");
expect(found!.modelId).toBe("claude-sonnet-4-5");
});
it("should normalize legacy workflow steps without mode to prompt mode", async () => {
// Create a step normally (it will have mode: "prompt")
const ws = await store.createWorkflowStep({
name: "Legacy Step",
description: "Pre-existing step",
prompt: "Review the code.",
});
// Simulate legacy data by writing a step without mode directly to DB
const config = await (store as any).readConfig();
// Remove mode from the stored step to simulate legacy data
delete config.workflowSteps[0].mode;
await (store as any).writeConfig(config);
// Re-read should normalize mode to "prompt"
const found = await store.getWorkflowStep(ws.id);
expect(found!.mode).toBe("prompt");
expect(found!.prompt).toBe("Review the code.");
});
it("should persist script-mode workflow step across list/get", async () => {
const ws = await store.createWorkflowStep({
name: "Type Check",
description: "Run TypeScript type checking",
mode: "script",
scriptName: "typecheck",
});
const listed = await store.listWorkflowSteps();
expect(listed[0].mode).toBe("script");
expect(listed[0].scriptName).toBe("typecheck");
const found = await store.getWorkflowStep(ws.id);
expect(found!.mode).toBe("script");
expect(found!.scriptName).toBe("typecheck");
});
// ── Workflow Step defaultOn ──────────────────────────────────────────────
it("should persist defaultOn flag on workflow step creation", async () => {
const ws = await store.createWorkflowStep({
name: "Default-on Step",
description: "Auto-selected for new tasks",
defaultOn: true,
});
expect(ws.defaultOn).toBe(true);
const found = await store.getWorkflowStep(ws.id);
expect(found!.defaultOn).toBe(true);
// Verify persistence
const steps = await store.listWorkflowSteps();
expect(steps[0].defaultOn).toBe(true);
});
it("should not set defaultOn by default", async () => {
const ws = await store.createWorkflowStep({
name: "Non-default Step",
description: "Not auto-selected",
});
expect(ws.defaultOn).toBeUndefined();
const found = await store.getWorkflowStep(ws.id);
expect(found!.defaultOn).toBeUndefined();
});
it("should update defaultOn flag on workflow step", async () => {
const ws = await store.createWorkflowStep({
name: "Step",
description: "Desc",
});
const updated = await store.updateWorkflowStep(ws.id, { defaultOn: true });
expect(updated.defaultOn).toBe(true);
const found = await store.getWorkflowStep(ws.id);
expect(found!.defaultOn).toBe(true);
});
it("should clear defaultOn flag by setting to false", async () => {
const ws = await store.createWorkflowStep({
name: "Step",
description: "Desc",
defaultOn: true,
});
const updated = await store.updateWorkflowStep(ws.id, { defaultOn: false });
expect(updated.defaultOn).toBe(false);
const found = await store.getWorkflowStep(ws.id);
expect(found!.defaultOn).toBe(false);
});
it("should auto-apply default-on workflow steps when creating task without enabledWorkflowSteps", async () => {
await store.createWorkflowStep({ name: "Always Run", description: "Auto-select", enabled: true, defaultOn: true });
await store.createWorkflowStep({ name: "Optional Check", description: "Only when manually selected", enabled: true, defaultOn: false });
await store.createWorkflowStep({ name: "Disabled Step", description: "Disabled step", enabled: false, defaultOn: true });
const task = await store.createTask({ description: "Test task" });
// Only the enabled + defaultOn step should be auto-applied
expect(task.enabledWorkflowSteps).toEqual(["WS-001"]);
});
it("should use explicit enabledWorkflowSteps over default-on steps", async () => {
await store.createWorkflowStep({ name: "Always Run", description: "Auto-select", enabled: true, defaultOn: true });
const task = await store.createTask({
description: "Test task",
enabledWorkflowSteps: ["WS-001", "WS-002"],
});
// Explicit input takes precedence
expect(task.enabledWorkflowSteps).toEqual(["WS-001", "WS-002"]);
});
it("should use empty enabledWorkflowSteps to override default-on steps", async () => {
await store.createWorkflowStep({ name: "Always Run", description: "Auto-select", enabled: true, defaultOn: true });
const task = await store.createTask({
description: "Test task",
enabledWorkflowSteps: [],
});
// Explicit empty array means user intentionally wants no steps
expect(task.enabledWorkflowSteps).toBeUndefined();
});
it("should not auto-apply disabled steps even with defaultOn flag", async () => {
await store.createWorkflowStep({ name: "Disabled Step", description: "Disabled step", enabled: false, defaultOn: true });
const task = await store.createTask({ description: "Test task" });
expect(task.enabledWorkflowSteps).toBeUndefined();
});
it("should auto-apply multiple default-on steps in order", async () => {
await store.createWorkflowStep({ name: "First", description: "First", enabled: true, defaultOn: true });
await store.createWorkflowStep({ name: "Second", description: "Second", enabled: true, defaultOn: true });
await store.createWorkflowStep({ name: "Third", description: "Third", enabled: true, defaultOn: false });
const task = await store.createTask({ description: "Test task" });
expect(task.enabledWorkflowSteps).toEqual(["WS-001", "WS-002"]);
});
it("logs default-on resolution failures and still creates the task", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const listStepsSpy = vi.spyOn(store, "listWorkflowSteps").mockRejectedValue(new Error("workflow catalog unavailable"));
try {
const task = await store.createTask({ description: "Best effort defaults" });
expect(task.id).toMatch(/^FN-\d+$/);
expect(task.enabledWorkflowSteps).toBeUndefined();
const warningCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Failed to auto-apply default workflow steps during task creation"),
);
expect(warningCall).toBeDefined();
const [, context] = warningCall as [string, Record<string, unknown>];
expect(context).toMatchObject({
descriptionLength: "Best effort defaults".length,
error: "workflow catalog unavailable",
});
} finally {
listStepsSpy.mockRestore();
warnSpy.mockRestore();
}
});
it("should update task workflow steps and materialize built-in templates", async () => {
const task = await store.createTask({ description: "Editable task" });
const updated = await store.updateTask(task.id, {
enabledWorkflowSteps: ["browser-verification"],
});
expect(updated.enabledWorkflowSteps).toEqual(["WS-001"]);
const persisted = await store.getTask(task.id);
expect(persisted.enabledWorkflowSteps).toEqual(["WS-001"]);
});
it("should resolve built-in workflow templates from getWorkflowStep", async () => {
const step = await store.getWorkflowStep("browser-verification");
expect(step).toMatchObject({
id: "browser-verification",
templateId: "browser-verification",
name: "Browser Verification",
mode: "prompt",
phase: "pre-merge",
toolMode: "coding",
});
});
it("should resolve frontend-ux-design built-in template from getWorkflowStep", async () => {
const step = await store.getWorkflowStep("frontend-ux-design");
expect(step).toMatchObject({
id: "frontend-ux-design",
templateId: "frontend-ux-design",
name: "Frontend UX Design",
mode: "prompt",
phase: "pre-merge",
toolMode: "readonly",
});
});
// ── Workflow Step Phase ──────────────────────────────────────────────
it("should default phase to 'pre-merge' when creating a workflow step", async () => {
const ws = await store.createWorkflowStep({
name: "Pre-merge Check",
description: "Runs before merge",
});
expect(ws.phase).toBe("pre-merge");
});
it("should create a workflow step with explicit 'post-merge' phase", async () => {
const ws = await store.createWorkflowStep({
name: "Post-merge Notify",
description: "Runs after merge",
phase: "post-merge",
});
expect(ws.phase).toBe("post-merge");
});
it("should create a workflow step with explicit 'pre-merge' phase", async () => {
const ws = await store.createWorkflowStep({
name: "Pre-merge Gate",
description: "Runs before merge",
phase: "pre-merge",
});
expect(ws.phase).toBe("pre-merge");
});
it("should update a workflow step phase from pre-merge to post-merge", async () => {
const ws = await store.createWorkflowStep({
name: "Phase Switch",
description: "Will switch phase",
});
expect(ws.phase).toBe("pre-merge");
const updated = await store.updateWorkflowStep(ws.id, { phase: "post-merge" });
expect(updated.phase).toBe("post-merge");
});
it("should persist phase across list/get", async () => {
const ws = await store.createWorkflowStep({
name: "Phase Persist",
description: "Check phase persistence",
phase: "post-merge",
});
const listed = await store.listWorkflowSteps();
expect(listed[0].phase).toBe("post-merge");
const found = await store.getWorkflowStep(ws.id);
expect(found!.phase).toBe("post-merge");
});
it("should normalize legacy workflow steps without phase to pre-merge", async () => {
const ws = await store.createWorkflowStep({
name: "Legacy Step",
description: "Pre-existing step",
prompt: "Review the code.",
});
// Simulate legacy data by removing phase from the stored step
const config = await (store as any).readConfig();
delete config.workflowSteps[0].phase;
await (store as any).writeConfig(config);
// Re-read: phase should be undefined (legacy), but when used by engine
// it should be treated as "pre-merge"
const found = await store.getWorkflowStep(ws.id);
expect(found!.phase).toBeUndefined();
});
});
// ── Title Summarization Tests ────────────────────────────────────────────
});

File diff suppressed because it is too large Load Diff