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:
@@ -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)
|
||||
|
||||
|
||||
983
packages/core/src/__tests__/store-archive-search.test.ts
Normal file
983
packages/core/src/__tests__/store-archive-search.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
131
packages/core/src/__tests__/store-run-mutation-context.test.ts
Normal file
131
packages/core/src/__tests__/store-run-mutation-context.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
814
packages/core/src/__tests__/store-workflow-steps.test.ts
Normal file
814
packages/core/src/__tests__/store-workflow-steps.test.ts
Normal 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
Reference in New Issue
Block a user