refactor(FN-4021): split core store tests into domain-focused suites
- Replace monolithic store.test.ts with focused suites for create, update, upsert, movement, scheduling, comments, activity, and resilience behavior - Add parsing and snapshot-specific test files to isolate coverage for conversion and serialization paths - Keep shared store test harness in store-test-helpers.ts and migrate existing coverage to use the split suite structure - Remove legacy monolith test file after preserving and redistributing coverage across new suite files Fusion-Task-Id: FN-4021
This commit is contained in:
717
packages/core/src/__tests__/store-activity.test.ts
Normal file
717
packages/core/src/__tests__/store-activity.test.ts
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("activity log", () => {
|
||||||
|
it("recordActivity appends to log file", async () => {
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-001", taskTitle: "Test", details: "Created" });
|
||||||
|
const logs = await store.getActivityLog();
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].type).toBe("task:created");
|
||||||
|
expect(logs[0].id).toBeDefined();
|
||||||
|
expect(logs[0].timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordActivity logs failures and stays best-effort", async () => {
|
||||||
|
const storeAny = store as any;
|
||||||
|
const originalPrepare = storeAny.db.prepare.bind(storeAny.db);
|
||||||
|
const prepareSpy = vi.spyOn(storeAny.db, "prepare").mockImplementation((sql: string) => {
|
||||||
|
if (sql.includes("INSERT INTO activityLog")) {
|
||||||
|
return {
|
||||||
|
run: () => {
|
||||||
|
throw new Error("activity insert failed");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalPrepare(sql);
|
||||||
|
});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
store.recordActivity({
|
||||||
|
type: "task:created",
|
||||||
|
taskId: "FN-404",
|
||||||
|
taskTitle: "Resilient record",
|
||||||
|
details: "Create event",
|
||||||
|
metadata: { source: "test" },
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
type: "task:created",
|
||||||
|
taskId: "FN-404",
|
||||||
|
taskTitle: "Resilient record",
|
||||||
|
details: "Create event",
|
||||||
|
});
|
||||||
|
|
||||||
|
const failureCall = errorSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Failed to record activity"),
|
||||||
|
);
|
||||||
|
expect(failureCall).toBeDefined();
|
||||||
|
const [, context] = failureCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
type: "task:created",
|
||||||
|
taskId: "FN-404",
|
||||||
|
taskTitle: "Resilient record",
|
||||||
|
detailsLength: "Create event".length,
|
||||||
|
hasMetadata: true,
|
||||||
|
error: "activity insert failed",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
prepareSpy.mockRestore();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs listener-level activity recording failures without throwing", async () => {
|
||||||
|
const task = await store.createTask({ description: "Listener test" });
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const recordSpy = vi.spyOn(store, "recordActivity").mockRejectedValue(new Error("listener rejected"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => {
|
||||||
|
store.emit("task:created", task);
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Activity logging listener failed"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
sourceEvent: "task:created",
|
||||||
|
type: "task:created",
|
||||||
|
taskId: task.id,
|
||||||
|
error: "listener rejected",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
recordSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getActivityLog returns entries newest first", async () => {
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "First" });
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Second" });
|
||||||
|
const logs = await store.getActivityLog();
|
||||||
|
expect(logs[0].taskId).toBe("FN-002");
|
||||||
|
expect(logs[1].taskId).toBe("FN-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getActivityLog respects limit", async () => {
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "First" });
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Second" });
|
||||||
|
const logs = await store.getActivityLog({ limit: 1 });
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].taskId).toBe("FN-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getActivityLog filters by type", async () => {
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Created" });
|
||||||
|
await store.recordActivity({ type: "task:moved", taskId: "FN-001", details: "Moved" });
|
||||||
|
const logs = await store.getActivityLog({ type: "task:created" });
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].type).toBe("task:created");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getActivityLog filters by since timestamp", async () => {
|
||||||
|
const first = await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Created" });
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const second = await store.recordActivity({ type: "task:created", taskId: "FN-002", details: "Created later" });
|
||||||
|
|
||||||
|
// Filter for entries strictly after the first one (should return only second)
|
||||||
|
const logs = await store.getActivityLog({ since: first.timestamp });
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].taskId).toBe("FN-002");
|
||||||
|
|
||||||
|
// Filter for entries strictly after a time before the first one (should return both)
|
||||||
|
const beforeFirst = new Date(new Date(first.timestamp).getTime() - 100).toISOString();
|
||||||
|
const allLogs = await store.getActivityLog({ since: beforeFirst });
|
||||||
|
expect(allLogs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearActivityLog removes all entries", async () => {
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Test" });
|
||||||
|
await store.clearActivityLog();
|
||||||
|
const logs = await store.getActivityLog();
|
||||||
|
expect(logs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing log file gracefully", async () => {
|
||||||
|
const logs = await store.getActivityLog();
|
||||||
|
expect(logs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordActivity includes metadata when provided", async () => {
|
||||||
|
await store.recordActivity({
|
||||||
|
type: "task:moved",
|
||||||
|
taskId: "FN-001",
|
||||||
|
taskTitle: "Test Task",
|
||||||
|
details: "Moved to in-progress",
|
||||||
|
metadata: { from: "todo", to: "in-progress" },
|
||||||
|
});
|
||||||
|
const logs = await store.getActivityLog();
|
||||||
|
expect(logs[0].metadata).toEqual({ from: "todo", to: "in-progress" });
|
||||||
|
expect(logs[0].taskTitle).toBe("Test Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activity log survives TaskStore reinitialization", async () => {
|
||||||
|
// Cross-instance persistence test — see archive-log counterpart
|
||||||
|
// above for the in-memory carve-out rationale.
|
||||||
|
store.close();
|
||||||
|
store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: "FN-001", details: "Test" });
|
||||||
|
|
||||||
|
// Create new store instance
|
||||||
|
const newStore = new TaskStore(rootDir, globalDir);
|
||||||
|
await newStore.init();
|
||||||
|
|
||||||
|
const logs = await newStore.getActivityLog();
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].taskId).toBe("FN-001");
|
||||||
|
newStore.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Activity Log Event Listener Tests ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("activity log event listeners", () => {
|
||||||
|
it("records activity on task:created", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test created event" });
|
||||||
|
// Wait for async activity recording
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const logs = await store.getActivityLog({ type: "task:created" });
|
||||||
|
expect(logs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(logs[0].taskId).toBe(task.id);
|
||||||
|
expect(logs[0].type).toBe("task:created");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records activity on task:moved", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test moved event" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
// Wait for async activity recording
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const logs = await store.getActivityLog({ type: "task:moved" });
|
||||||
|
expect(logs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(logs[0].taskId).toBe(task.id);
|
||||||
|
expect(logs[0].type).toBe("task:moved");
|
||||||
|
expect(logs[0].metadata).toHaveProperty("from");
|
||||||
|
expect(logs[0].metadata).toHaveProperty("to");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records activity when task status becomes failed", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test failure event" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.updateTask(task.id, { status: "failed", error: "Something went wrong" });
|
||||||
|
// Wait for async activity recording
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const logs = await store.getActivityLog({ type: "task:failed" });
|
||||||
|
expect(logs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(logs[0].taskId).toBe(task.id);
|
||||||
|
expect(logs[0].type).toBe("task:failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records activity on settings:updated for important changes", async () => {
|
||||||
|
// ntfyEnabled/ntfyTopic are now global settings, use updateGlobalSettings
|
||||||
|
await store.updateGlobalSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||||
|
// Wait for async activity recording
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const logs = await store.getActivityLog({ type: "settings:updated" });
|
||||||
|
expect(logs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(logs[0].type).toBe("settings:updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records activity on task:deleted", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test deleted event" });
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
// Wait for async activity recording
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const logs = await store.getActivityLog({ type: "task:deleted" });
|
||||||
|
expect(logs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(logs[0].taskId).toBe(task.id);
|
||||||
|
expect(logs[0].type).toBe("task:deleted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures merge details when merging a task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test merge details" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
worktree: "/tmp/test-worktree",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { execSync } = await import("node:child_process");
|
||||||
|
try {
|
||||||
|
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
|
||||||
|
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
|
||||||
|
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await store.mergeTask(task.id);
|
||||||
|
expect(result.mergeConfirmed ?? result.merged).toBeDefined();
|
||||||
|
expect(result.task.mergeDetails).toBeDefined();
|
||||||
|
if (result.merged) {
|
||||||
|
expect(result.task.mergeDetails?.commitSha).toBeTruthy();
|
||||||
|
expect(result.task.mergeDetails?.mergeCommitMessage).toContain(task.id);
|
||||||
|
expect(result.task.mergeDetails?.mergedAt).toBeDefined();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// merge may fail depending on repo state; skip strict assertions in that case
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records activity on task:merged", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test merged event" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
// Manually set worktree for merge
|
||||||
|
await store.updateTask(task.id, { worktree: "/tmp/test-worktree" });
|
||||||
|
|
||||||
|
// Create branch for merge
|
||||||
|
const { execSync } = await import("node:child_process");
|
||||||
|
try {
|
||||||
|
execSync(`git checkout -b fusion/${task.id.toLowerCase()}`, { cwd: rootDir, stdio: "pipe" });
|
||||||
|
execSync('git commit --allow-empty -m "test commit"', { cwd: rootDir, stdio: "pipe" });
|
||||||
|
execSync("git checkout main || git checkout master", { cwd: rootDir, stdio: "pipe" });
|
||||||
|
} catch {
|
||||||
|
// Branch may already exist or no main/master, skip merge test
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.mergeTask(task.id);
|
||||||
|
} catch {
|
||||||
|
// Merge may fail due to branch setup, that's ok for activity log test
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for async activity recording
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const logs = await store.getActivityLog({ type: "task:merged" });
|
||||||
|
// We check if the merge was attempted (logs may exist even if merge failed)
|
||||||
|
// The key is that the event listener was called
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not record activity for non-failure task updates", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test non-failure update" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.updateTask(task.id, { status: "in-progress" });
|
||||||
|
// Wait for any async activity recording
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
// Get all failed logs - should not include this task
|
||||||
|
const failedLogs = await store.getActivityLog({ type: "task:failed" });
|
||||||
|
const taskFailedLogs = failedLogs.filter((l) => l.taskId === task.id);
|
||||||
|
expect(taskFailedLogs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Workflow Steps ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe("event emissions", () => {
|
||||||
|
it("createTask emits task:created with the new task", async () => {
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:created", (t: any) => events.push(t));
|
||||||
|
const task = await store.createTask({ description: "event test" });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(task.id);
|
||||||
|
expect(events[0].description).toBe("event test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask emits task:moved with from/to columns", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:moved", (data: any) => events.push(data));
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].from).toBe("triage");
|
||||||
|
expect(events[0].to).toBe("todo");
|
||||||
|
expect(events[0].task.id).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateTask emits task:updated with the updated task", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.updateTask(task.id, { title: "Updated" });
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(events.some((e: any) => e.title === "Updated")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauseTask emits task:updated", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(events.some((e: any) => e.paused === true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateStep emits task:updated", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.updateStep(task.id, 0, "in-progress");
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask emits task:deleted", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:deleted", (t: any) => events.push(t));
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logEntry emits task:updated", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t: any) => events.push(t));
|
||||||
|
await store.logEntry(task.id, "test action", "test outcome");
|
||||||
|
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("execution timing timestamps", () => {
|
||||||
|
it("preserves the original executionStartedAt across an internal rerun bounce", async () => {
|
||||||
|
const task = await store.createTask({ description: "retry bounce timing" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
const started = await store.moveTask(task.id, "in-progress");
|
||||||
|
const originalExecutionStartedAt = started.executionStartedAt;
|
||||||
|
|
||||||
|
expect(originalExecutionStartedAt).toBeDefined();
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const bouncedToTodo = await store.moveTask(task.id, "todo");
|
||||||
|
expect(bouncedToTodo.executionStartedAt).toBeUndefined();
|
||||||
|
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
worktree: "/tmp/retry-bounce",
|
||||||
|
executionStartedAt: originalExecutionStartedAt ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bouncedBack = await store.moveTask(task.id, "in-progress");
|
||||||
|
expect(bouncedBack.executionStartedAt).toBe(originalExecutionStartedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("settings:updated event", () => {
|
||||||
|
it("fires on updateSettings with correct old and new values", async () => {
|
||||||
|
const events: { settings: any; previous: any }[] = [];
|
||||||
|
store.on("settings:updated", (data) => events.push(data));
|
||||||
|
|
||||||
|
await store.updateSettings({ maxConcurrent: 5 });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].previous.maxConcurrent).toBe(2); // DEFAULT_SETTINGS value
|
||||||
|
expect(events[0].settings.maxConcurrent).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes previous globalPause: false → new globalPause: true when toggled", async () => {
|
||||||
|
const events: { settings: any; previous: any }[] = [];
|
||||||
|
store.on("settings:updated", (data) => events.push(data));
|
||||||
|
|
||||||
|
// Default globalPause is false
|
||||||
|
await store.updateSettings({ globalPause: true });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].previous.globalPause).toBe(false);
|
||||||
|
expect(events[0].settings.globalPause).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes previous globalPause: true → new globalPause: false when toggled off", async () => {
|
||||||
|
await store.updateSettings({ globalPause: true });
|
||||||
|
|
||||||
|
const events: { settings: any; previous: any }[] = [];
|
||||||
|
store.on("settings:updated", (data) => events.push(data));
|
||||||
|
|
||||||
|
await store.updateSettings({ globalPause: false });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].previous.globalPause).toBe(true);
|
||||||
|
expect(events[0].settings.globalPause).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires on every updateSettings call even when value unchanged", async () => {
|
||||||
|
const events: { settings: any; previous: any }[] = [];
|
||||||
|
store.on("settings:updated", (data) => events.push(data));
|
||||||
|
|
||||||
|
await store.updateSettings({ maxConcurrent: 2 });
|
||||||
|
await store.updateSettings({ maxConcurrent: 2 });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Duplicate Task Tests ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("task-store diagnostics for best-effort catch paths", () => {
|
||||||
|
it("logs init config sync failures without blocking startup", async () => {
|
||||||
|
const localRoot = makeTmpDir();
|
||||||
|
const localGlobal = makeTmpDir();
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
let localStore: TaskStore | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
|
||||||
|
(localStore as any).configPath = join(localRoot, ".fusion", "missing-dir", "config.json");
|
||||||
|
|
||||||
|
await expect(localStore.init()).resolves.toBeUndefined();
|
||||||
|
await expect(localStore.createTask({ description: "still boots" })).resolves.toMatchObject({
|
||||||
|
id: "FN-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed during init"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
phase: "init:config-sync",
|
||||||
|
configPath: join(localRoot, ".fusion", "missing-dir", "config.json"),
|
||||||
|
});
|
||||||
|
expect(typeof context.error).toBe("string");
|
||||||
|
} finally {
|
||||||
|
localStore?.close();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs writeConfig disk sync failures while preserving project settings updates", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const storeAny = store as any;
|
||||||
|
const originalConfigPath = storeAny.configPath;
|
||||||
|
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await store.updateSettings({ mergeStrategy: "pull-request" });
|
||||||
|
expect(updated.mergeStrategy).toBe("pull-request");
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after config write"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
phase: "writeConfig:disk-sync",
|
||||||
|
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
|
||||||
|
});
|
||||||
|
expect(typeof context.error).toBe("string");
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
expect(settings.mergeStrategy).toBe("pull-request");
|
||||||
|
} finally {
|
||||||
|
storeAny.configPath = originalConfigPath;
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs allocateId disk sync failures while preserving task creation", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const storeAny = store as any;
|
||||||
|
const originalConfigPath = storeAny.configPath;
|
||||||
|
storeAny.configPath = join(rootDir, ".fusion", "missing-sync", "config.json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await store.createTask({ description: "allocate despite sync failure" });
|
||||||
|
expect(task.id).toBe("FN-001");
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Backward-compat config.json sync failed after ID allocation"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
phase: "allocateId:disk-sync",
|
||||||
|
configPath: join(rootDir, ".fusion", "missing-sync", "config.json"),
|
||||||
|
taskId: task.id,
|
||||||
|
});
|
||||||
|
expect(typeof context.error).toBe("string");
|
||||||
|
} finally {
|
||||||
|
storeAny.configPath = originalConfigPath;
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs init memory bootstrap failures without blocking startup", async () => {
|
||||||
|
const localRoot = makeTmpDir();
|
||||||
|
const localGlobal = makeTmpDir();
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const ensureSpy = vi
|
||||||
|
.spyOn(projectMemory, "ensureMemoryFileWithBackend")
|
||||||
|
.mockRejectedValueOnce(new Error("memory backend unavailable"));
|
||||||
|
let localStore: TaskStore | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
|
||||||
|
await expect(localStore.init()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed during init"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
phase: "init:memory-bootstrap",
|
||||||
|
rootDir: localRoot,
|
||||||
|
error: "memory backend unavailable",
|
||||||
|
});
|
||||||
|
expect(ensureSpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
localStore?.close();
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
await rm(localRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
await rm(localGlobal, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs memory toggle-on bootstrap failures without blocking settings updates", async () => {
|
||||||
|
await store.updateSettings({ memoryEnabled: false } as any);
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const ensureSpy = vi
|
||||||
|
.spyOn(projectMemory, "ensureMemoryFileWithBackend")
|
||||||
|
.mockRejectedValueOnce(new Error("memory toggle write failed"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await store.updateSettings({ memoryEnabled: true } as any);
|
||||||
|
expect(updated.memoryEnabled).toBe(true);
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Project-memory bootstrap failed after memory toggle-on"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
phase: "updateSettings:memory-toggle-on",
|
||||||
|
rootDir,
|
||||||
|
error: "memory toggle write failed",
|
||||||
|
});
|
||||||
|
expect(ensureSpy).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs fs.watch setup failures and keeps polling active", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const storeAny = store as any;
|
||||||
|
const originalTasksDir = storeAny.tasksDir;
|
||||||
|
storeAny.tasksDir = join(rootDir, ".fusion", "missing-tasks-dir");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.watch();
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] fs.watch unavailable; falling back to polling-only updates"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
phase: "watch:fs-watch-setup",
|
||||||
|
tasksDir: join(rootDir, ".fusion", "missing-tasks-dir"),
|
||||||
|
});
|
||||||
|
expect(typeof context.error).toBe("string");
|
||||||
|
expect(storeAny.pollInterval).not.toBeNull();
|
||||||
|
await expect(storeAny.checkForChanges()).resolves.toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
store.stopWatching();
|
||||||
|
storeAny.tasksDir = originalTasksDir;
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs unreadable legacy agent.log files while keeping import non-fatal", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const taskDir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
const logPath = join(taskDir, "agent.log");
|
||||||
|
await mkdir(logPath);
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
await expect(store.importLegacyAgentLogs()).resolves.toBe(0);
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Skipping unreadable legacy agent.log file during import"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
phase: "importLegacyAgentLogs:read-file",
|
||||||
|
taskId: task.id,
|
||||||
|
logPath,
|
||||||
|
});
|
||||||
|
expect(typeof context.error).toBe("string");
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Branch Cleanup on Delete/Archive ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
925
packages/core/src/__tests__/store-comments.test.ts
Normal file
925
packages/core/src/__tests__/store-comments.test.ts
Normal file
@@ -0,0 +1,925 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("task comments", () => {
|
||||||
|
it("adds a task comment to a task", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated = await store.addTaskComment(task.id, "Please review this", "alice");
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("Please review this");
|
||||||
|
expect(updated.comments![0].author).toBe("alice");
|
||||||
|
expect(updated.comments![0].id).toBeDefined();
|
||||||
|
expect(updated.comments![0].createdAt).toBeDefined();
|
||||||
|
expect(updated.comments![0].updatedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing task comment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const added = await store.addTaskComment(task.id, "First draft", "alice");
|
||||||
|
const commentId = added.comments![0].id;
|
||||||
|
|
||||||
|
const updated = await store.updateTaskComment(task.id, commentId, "Updated draft");
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("Updated draft");
|
||||||
|
expect(updated.comments![0].updatedAt).toBeDefined();
|
||||||
|
expect(updated.log.some((entry) => entry.action === "Comment updated")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a task comment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const added = await store.addTaskComment(task.id, "Disposable", "alice");
|
||||||
|
const commentId = added.comments![0].id;
|
||||||
|
|
||||||
|
const updated = await store.deleteTaskComment(task.id, commentId);
|
||||||
|
|
||||||
|
expect(updated.comments).toBeUndefined();
|
||||||
|
expect(updated.log.some((entry) => entry.action === "Comment deleted")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when updating a missing task comment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await expect(store.updateTaskComment(task.id, "missing", "Nope")).rejects.toThrow(
|
||||||
|
`Comment missing not found on task ${task.id}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when deleting a missing task comment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await expect(store.deleteTaskComment(task.id, "missing")).rejects.toThrow(
|
||||||
|
`Comment missing not found on task ${task.id}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists all comments in unified comments field", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.addTaskComment(task.id, "General note", "alice");
|
||||||
|
await store.addComment(task.id, "Execution note");
|
||||||
|
|
||||||
|
const reopened = await store.getTask(task.id);
|
||||||
|
// Both comments should be in the unified comments array
|
||||||
|
expect(reopened.comments).toHaveLength(2);
|
||||||
|
expect(reopened.comments![0].text).toBe("General note");
|
||||||
|
expect(reopened.comments![1].text).toBe("Execution note");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("addComment", () => {
|
||||||
|
it("adds a steering comment to a task", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated = await store.addComment(task.id, "Please handle the edge case");
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("Please handle the edge case");
|
||||||
|
expect(updated.comments![0].author).toBe("user");
|
||||||
|
expect(updated.comments![0].id).toBeDefined();
|
||||||
|
expect(updated.comments![0].createdAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts agent as author", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated = await store.addComment(task.id, "Note from agent", "agent");
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].author).toBe("agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes comments array if undefined", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
expect(task.comments).toBeUndefined();
|
||||||
|
|
||||||
|
const updated = await store.addComment(task.id, "First comment");
|
||||||
|
expect(updated.comments).toBeDefined();
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends multiple comments in order", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.addComment(task.id, "First comment");
|
||||||
|
await store.addComment(task.id, "Second comment");
|
||||||
|
await store.addComment(task.id, "Third comment");
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.comments).toHaveLength(3);
|
||||||
|
expect(fetched.comments![0].text).toBe("First comment");
|
||||||
|
expect(fetched.comments![1].text).toBe("Second comment");
|
||||||
|
expect(fetched.comments![2].text).toBe("Third comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates unique IDs for each comment", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated1 = await store.addComment(task.id, "Comment 1");
|
||||||
|
const updated2 = await store.addComment(task.id, "Comment 2");
|
||||||
|
|
||||||
|
const id1 = updated1.comments![0].id;
|
||||||
|
const id2 = updated2.comments![1].id;
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits task:updated event", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t) => events.push(t));
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Test comment");
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].comments).toHaveLength(1);
|
||||||
|
expect(events[0].comments![0].text).toBe("Test comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists to disk and round-trips correctly", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.addComment(task.id, "Persisted comment");
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.comments).toHaveLength(1);
|
||||||
|
expect(fetched.comments![0].text).toBe("Persisted comment");
|
||||||
|
expect(fetched.comments![0].author).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds log entry for the action", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const updated = await store.addComment(task.id, "Comment with log");
|
||||||
|
|
||||||
|
expect(updated.log.some((l) => l.action === "Comment added by user")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates updatedAt timestamp", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const before = task.updatedAt;
|
||||||
|
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
|
||||||
|
|
||||||
|
const updated = await store.addComment(task.id, "Timestamp test");
|
||||||
|
expect(updated.updatedAt).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates refinement task when steering comment added to done task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 allTasksBefore = await store.listTasks();
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Need to fix edge case");
|
||||||
|
|
||||||
|
const allTasksAfter = await store.listTasks();
|
||||||
|
expect(allTasksAfter).toHaveLength(allTasksBefore.length + 1);
|
||||||
|
|
||||||
|
const refinement = allTasksAfter.find((t) => t.id !== task.id && t.title?.includes("Refinement"));
|
||||||
|
expect(refinement).toBeDefined();
|
||||||
|
expect(refinement?.column).toBe("triage");
|
||||||
|
expect(refinement?.dependencies).toContain(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create refinement when steering comment added to non-done task (triage)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
// Task starts in triage
|
||||||
|
|
||||||
|
const allTasksBefore = await store.listTasks();
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Some feedback");
|
||||||
|
|
||||||
|
const allTasksAfter = await store.listTasks();
|
||||||
|
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create refinement when steering comment added to non-done task (in-progress)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
const allTasksBefore = await store.listTasks();
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Some feedback");
|
||||||
|
|
||||||
|
const allTasksAfter = await store.listTasks();
|
||||||
|
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create refinement when steering comment added to non-done task (in-review)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
const allTasksBefore = await store.listTasks();
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Some feedback");
|
||||||
|
|
||||||
|
const allTasksAfter = await store.listTasks();
|
||||||
|
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steering comment is still added to original task even when refinement is created", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 updated = await store.addComment(task.id, "Need to fix edge case");
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("Need to fix edge case");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refinement task has correct dependency on original done task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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.addComment(task.id, "Need to fix edge case");
|
||||||
|
|
||||||
|
const allTasks = await store.listTasks();
|
||||||
|
const refinement = allTasks.find((t) => t.id !== task.id && t.dependencies?.includes(task.id));
|
||||||
|
|
||||||
|
expect(refinement).toBeDefined();
|
||||||
|
expect(refinement?.dependencies).toEqual([task.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create refinement for agent-authored comments", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 allTasksBefore = await store.listTasks();
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Agent feedback", "agent");
|
||||||
|
|
||||||
|
const allTasksAfter = await store.listTasks();
|
||||||
|
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail when steering comment is empty or whitespace on done task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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");
|
||||||
|
|
||||||
|
// Should not throw - refineTask will reject empty feedback but we catch it
|
||||||
|
const updated = await store.addComment(task.id, " ");
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs warning and still persists comment when best-effort auto-refinement fails", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 runContext = { runId: "run-refinement-failure", agentId: "agent-refinement" };
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const refineSpy = vi.spyOn(store, "refineTask").mockRejectedValue(new Error("refine unavailable"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taskCountBefore = (await store.listTasks()).length;
|
||||||
|
const updated = await store.addComment(task.id, "Need refinement", "user", undefined, runContext);
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("Need refinement");
|
||||||
|
|
||||||
|
const taskCountAfter = (await store.listTasks()).length;
|
||||||
|
expect(taskCountAfter).toBe(taskCountBefore);
|
||||||
|
|
||||||
|
const persisted = await store.getTask(task.id);
|
||||||
|
expect(persisted.comments).toHaveLength(1);
|
||||||
|
expect(persisted.comments![0].text).toBe("Need refinement");
|
||||||
|
|
||||||
|
expect(refineSpy).toHaveBeenCalledWith(task.id, "Need refinement");
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment auto-refinement failed"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
taskId: task.id,
|
||||||
|
author: "user",
|
||||||
|
commentLength: "Need refinement".length,
|
||||||
|
column: "done",
|
||||||
|
priorStatus: null,
|
||||||
|
phase: "addComment:auto-refinement",
|
||||||
|
runId: "run-refinement-failure",
|
||||||
|
agentId: "agent-refinement",
|
||||||
|
error: "refine unavailable",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
refineSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs warning and still persists comment when status update fails during awaiting-approval invalidation", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in triage" });
|
||||||
|
await store.updateTask(task.id, { status: "awaiting-approval" });
|
||||||
|
|
||||||
|
const runContext = { runId: "run-invalidation-failure", agentId: "agent-invalidation" };
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const updateSpy = vi.spyOn(store, "updateTask").mockRejectedValueOnce(new Error("status update failed"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await store.addComment(task.id, "New user feedback", "user", undefined, runContext);
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("New user feedback");
|
||||||
|
|
||||||
|
const persisted = await store.getTask(task.id);
|
||||||
|
expect(persisted.comments).toHaveLength(1);
|
||||||
|
expect(persisted.comments![0].text).toBe("New user feedback");
|
||||||
|
expect(persisted.status).toBe("awaiting-approval");
|
||||||
|
|
||||||
|
expect(updateSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment re-triage failed"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
taskId: task.id,
|
||||||
|
author: "user",
|
||||||
|
commentLength: "New user feedback".length,
|
||||||
|
column: "triage",
|
||||||
|
priorStatus: "awaiting-approval",
|
||||||
|
phase: "addComment:awaiting-approval-invalidation",
|
||||||
|
stage: "status-update",
|
||||||
|
nextStatus: "needs-replan",
|
||||||
|
runId: "run-invalidation-failure",
|
||||||
|
agentId: "agent-invalidation",
|
||||||
|
error: "status update failed",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
updateSpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs warning and keeps invalidated status when log entry fails after awaiting-approval invalidation", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in triage" });
|
||||||
|
await store.updateTask(task.id, { status: "awaiting-approval" });
|
||||||
|
|
||||||
|
const runContext = { runId: "run-post-invalidation-log-failure", agentId: "agent-invalidation" };
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const logEntrySpy = vi.spyOn(store, "logEntry").mockRejectedValueOnce(new Error("log entry failed"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await store.addComment(task.id, "New user feedback", "user", undefined, runContext);
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("New user feedback");
|
||||||
|
|
||||||
|
const persisted = await store.getTask(task.id);
|
||||||
|
expect(persisted.comments).toHaveLength(1);
|
||||||
|
expect(persisted.comments![0].text).toBe("New user feedback");
|
||||||
|
expect(persisted.status).toBe("needs-replan");
|
||||||
|
|
||||||
|
expect(logEntrySpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const warningCall = warnSpy.mock.calls.find(
|
||||||
|
(call) => typeof call[0] === "string" && call[0].includes("[task-store] Best-effort post-comment re-triage failed"),
|
||||||
|
);
|
||||||
|
expect(warningCall).toBeDefined();
|
||||||
|
|
||||||
|
const [, context] = warningCall as [string, Record<string, unknown>];
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
taskId: task.id,
|
||||||
|
author: "user",
|
||||||
|
commentLength: "New user feedback".length,
|
||||||
|
column: "triage",
|
||||||
|
priorStatus: "awaiting-approval",
|
||||||
|
phase: "addComment:awaiting-approval-invalidation",
|
||||||
|
stage: "post-invalidation-log-entry",
|
||||||
|
nextStatus: "needs-replan",
|
||||||
|
runId: "run-post-invalidation-log-failure",
|
||||||
|
agentId: "agent-invalidation",
|
||||||
|
error: "log entry failed",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
logEntrySpy.mockRestore();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addSteeringComment on done task does NOT create a refinement task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 allTasksBefore = await store.listTasks();
|
||||||
|
|
||||||
|
await store.addSteeringComment(task.id, "Please handle the edge case");
|
||||||
|
|
||||||
|
const allTasksAfter = await store.listTasks();
|
||||||
|
// No refinement task should be created
|
||||||
|
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addSteeringComment writes to both comments and steeringComments", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
const updated = await store.addSteeringComment(task.id, "Focus on error handling");
|
||||||
|
|
||||||
|
// Should appear in unified comments (for UI display)
|
||||||
|
expect(updated.comments).toBeDefined();
|
||||||
|
expect(updated.comments!.some(c => c.text === "Focus on error handling")).toBe(true);
|
||||||
|
|
||||||
|
// Should appear in steeringComments (for executor injection)
|
||||||
|
expect(updated.steeringComments).toBeDefined();
|
||||||
|
expect(updated.steeringComments!.some(c => c.text === "Focus on error handling")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addSteeringComment steeringComments persist through round-trip", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.addSteeringComment(task.id, "Focus on error handling");
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.steeringComments).toBeDefined();
|
||||||
|
expect(fetched.steeringComments!).toHaveLength(1);
|
||||||
|
expect(fetched.steeringComments![0].text).toBe("Focus on error handling");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steering comments do not duplicate in comments across read-write cycles", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
// Add a steering comment (writes to both comments and steeringComments columns)
|
||||||
|
await store.addSteeringComment(task.id, "Focus on error handling");
|
||||||
|
|
||||||
|
// Read the task back — comments should have exactly 1 entry
|
||||||
|
const read1 = await store.getTask(task.id);
|
||||||
|
expect(read1.comments).toHaveLength(1);
|
||||||
|
expect(read1.steeringComments).toHaveLength(1);
|
||||||
|
|
||||||
|
// Simulate a write-back (updateTask writes via upsertTask)
|
||||||
|
await store.updateTask(task.id, { status: "planning" });
|
||||||
|
|
||||||
|
// Read again — should still have exactly 1 comment, not 2
|
||||||
|
const read2 = await store.getTask(task.id);
|
||||||
|
expect(read2.comments).toHaveLength(1);
|
||||||
|
expect(read2.comments![0].text).toBe("Focus on error handling");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no duplication accumulation over multiple read-write cycles with steering comments", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.addSteeringComment(task.id, "Comment A");
|
||||||
|
await store.addSteeringComment(task.id, "Comment B");
|
||||||
|
|
||||||
|
// Perform 5 read-write cycles
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.comments).toHaveLength(2);
|
||||||
|
expect(fetched.steeringComments).toHaveLength(2);
|
||||||
|
// Write back via an innocuous update
|
||||||
|
await store.updateTask(task.id, { status: "planning" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final read — still exactly 2 comments
|
||||||
|
const final = await store.getTask(task.id);
|
||||||
|
expect(final.comments).toHaveLength(2);
|
||||||
|
expect(final.comments!.map(c => c.text).sort()).toEqual(["Comment A", "Comment B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed regular and steering comments maintain correct counts through cycles", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
// Add 1 regular comment and 1 steering comment
|
||||||
|
await store.addTaskComment(task.id, "Regular note", "alice");
|
||||||
|
await store.addSteeringComment(task.id, "Steering note");
|
||||||
|
|
||||||
|
// Should have 2 comments total, 1 steering comment
|
||||||
|
const read1 = await store.getTask(task.id);
|
||||||
|
expect(read1.comments).toHaveLength(2);
|
||||||
|
expect(read1.steeringComments).toHaveLength(1);
|
||||||
|
|
||||||
|
// Perform 3 read-write cycles
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.comments).toHaveLength(2);
|
||||||
|
await store.updateTask(task.id, { status: "planning" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const final = await store.getTask(task.id);
|
||||||
|
expect(final.comments).toHaveLength(2);
|
||||||
|
expect(final.steeringComments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("regular addComment on done task still creates refinement", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 allTasksBefore = await store.listTasks();
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Need to fix edge case");
|
||||||
|
|
||||||
|
const allTasksAfter = await store.listTasks();
|
||||||
|
expect(allTasksAfter).toHaveLength(allTasksBefore.length + 1);
|
||||||
|
|
||||||
|
const refinement = allTasksAfter.find((t) => t.id !== task.id && t.title?.includes("Refinement"));
|
||||||
|
expect(refinement).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions awaiting-approval to needs-replan when user comments on triage task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in triage" });
|
||||||
|
// Keep in triage but set awaiting-approval status
|
||||||
|
await store.updateTask(task.id, { status: "awaiting-approval" });
|
||||||
|
|
||||||
|
const result = await store.addComment(task.id, "I want to change the approach", "user");
|
||||||
|
|
||||||
|
// Re-read the task to get the Phase 3 status update
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
|
||||||
|
// Task should remain in triage but status should change to needs-replan
|
||||||
|
expect(updated.column).toBe("triage");
|
||||||
|
expect(updated.status).toBe("needs-replan");
|
||||||
|
// Comment should still be added
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(updated.comments![0].text).toBe("I want to change the approach");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT transition to needs-replan when agent comments on awaiting-approval task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in triage" });
|
||||||
|
await store.updateTask(task.id, { status: "awaiting-approval" });
|
||||||
|
|
||||||
|
const updated = await store.addComment(task.id, "Agent system note", "agent");
|
||||||
|
|
||||||
|
// Status should remain awaiting-approval for agent comments
|
||||||
|
expect(updated.status).toBe("awaiting-approval");
|
||||||
|
// Comment should still be added
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to needs-replan when user comments on non-awaiting-approval triage task with real spec", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in triage" });
|
||||||
|
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||||
|
await writeFile(promptPath, `# Task: ${task.id} - Triage Plan\n\n## Mission\n\nPlanned task.`);
|
||||||
|
|
||||||
|
await store.addComment(task.id, "User feedback", "user");
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(updated.status).toBe("needs-replan");
|
||||||
|
expect(updated.column).toBe("triage");
|
||||||
|
expect(updated.comments?.[0]?.text).toBe("User feedback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT transition to needs-replan when user comments on triage task with bootstrap stub prompt", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in triage" });
|
||||||
|
|
||||||
|
await store.addComment(task.id, "User feedback", "user");
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(updated.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions todo task to needs-replan when user comments and task has real spec", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in todo", column: "todo" });
|
||||||
|
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||||
|
await writeFile(promptPath, `# Task: ${task.id} - Todo Plan\n\n## Mission\n\nPlanned task.`);
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Please update approach", "user");
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(updated.status).toBe("needs-replan");
|
||||||
|
expect(updated.column).toBe("todo");
|
||||||
|
expect(updated.log.some((entry) => entry.action === "User comment requested re-specification of planned task")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT transition todo task to needs-replan when prompt matches bootstrap stub", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in todo", column: "todo" });
|
||||||
|
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||||
|
await writeFile(promptPath, `# ${task.id}\n\nTask in todo\n`);
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Please update approach", "user");
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(updated.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT transition to needs-replan when user comments on in-progress task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in progress", column: "todo" });
|
||||||
|
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||||
|
await writeFile(promptPath, `# Task: ${task.id} - Plan\n\n## Mission\n\nPlanned task.`);
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Please adjust implementation", "user");
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(updated.column).toBe("in-progress");
|
||||||
|
expect(updated.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT transition to needs-replan when user comments on in-review task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task in review", column: "todo" });
|
||||||
|
const promptPath = join(rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||||
|
await writeFile(promptPath, `# Task: ${task.id} - Plan\n\n## Mission\n\nPlanned task.`);
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
await store.addComment(task.id, "Please adjust before merge", "user");
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(updated.column).toBe("in-review");
|
||||||
|
expect(updated.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("task comments and merge details types", () => {
|
||||||
|
it("has undefined comments on new tasks", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const reopened = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(reopened.comments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports the task comment and merge details shapes", async () => {
|
||||||
|
const comment: NonNullable<Task["comments"]>[number] = {
|
||||||
|
id: `comment-${Date.now()}`,
|
||||||
|
text: "Looks good",
|
||||||
|
author: "alice",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const mergeDetails: NonNullable<Task["mergeDetails"]> = {
|
||||||
|
commitSha: "abc123def456",
|
||||||
|
filesChanged: 3,
|
||||||
|
insertions: 10,
|
||||||
|
deletions: 2,
|
||||||
|
mergeCommitMessage: "feat(KB-001): merge fusion/fn-001",
|
||||||
|
mergedAt: new Date().toISOString(),
|
||||||
|
mergeConfirmed: true,
|
||||||
|
prNumber: 42,
|
||||||
|
};
|
||||||
|
const taskShape: Pick<Task, "comments" | "mergeDetails"> = {
|
||||||
|
comments: [comment],
|
||||||
|
mergeDetails,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(taskShape.comments).toEqual([comment]);
|
||||||
|
expect(taskShape.mergeDetails).toEqual(mergeDetails);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("updatePrInfo", () => {
|
||||||
|
it("adds PR info to a task without existing PR", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const prInfo = {
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
number: 42,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Fix the bug",
|
||||||
|
headBranch: "kb-001-fix-bug",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = await store.updatePrInfo(task.id, prInfo);
|
||||||
|
|
||||||
|
expect(updated.prInfo).toEqual(prInfo);
|
||||||
|
expect(updated.log.some((l) => l.action === "PR linked" && l.outcome?.includes("#42"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps PR number/url after moving task to done", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const prInfo = {
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
number: 42,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Fix the bug",
|
||||||
|
headBranch: "kb-001-fix-bug",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await store.updatePrInfo(task.id, prInfo);
|
||||||
|
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 updated = await store.getTask(task.id);
|
||||||
|
expect(updated.prInfo?.number).toBe(42);
|
||||||
|
expect(updated.prInfo?.url).toBe("https://github.com/owner/repo/pull/42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates existing PR info with new values", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const prInfo1 = {
|
||||||
|
url: "https://github.com/owner/repo/pull/1",
|
||||||
|
number: 1,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Initial PR",
|
||||||
|
headBranch: "branch-1",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
};
|
||||||
|
await store.updatePrInfo(task.id, prInfo1);
|
||||||
|
|
||||||
|
const prInfo2 = {
|
||||||
|
url: "https://github.com/owner/repo/pull/1",
|
||||||
|
number: 1,
|
||||||
|
status: "merged" as const,
|
||||||
|
title: "Initial PR (updated)",
|
||||||
|
headBranch: "branch-1",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 3,
|
||||||
|
lastCommentAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const updated = await store.updatePrInfo(task.id, prInfo2);
|
||||||
|
|
||||||
|
expect(updated.prInfo?.status).toBe("merged");
|
||||||
|
expect(updated.prInfo?.commentCount).toBe(3);
|
||||||
|
expect(updated.prInfo?.lastCommentAt).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears PR info when passed null", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const prInfo = {
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
number: 42,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Fix the bug",
|
||||||
|
headBranch: "kb-001-fix-bug",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
};
|
||||||
|
await store.updatePrInfo(task.id, prInfo);
|
||||||
|
|
||||||
|
const updated = await store.updatePrInfo(task.id, null);
|
||||||
|
|
||||||
|
expect(updated.prInfo).toBeUndefined();
|
||||||
|
expect(updated.log.some((l) => l.action === "PR unlinked")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits task:updated event when PR info changes", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t) => events.push(t));
|
||||||
|
|
||||||
|
const prInfo = {
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
number: 42,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Fix the bug",
|
||||||
|
headBranch: "kb-001-fix-bug",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
};
|
||||||
|
await store.updatePrInfo(task.id, prInfo);
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].prInfo?.number).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT emit task:updated when PR info is unchanged", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const prInfo = {
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
number: 42,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Fix the bug",
|
||||||
|
headBranch: "kb-001-fix-bug",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
};
|
||||||
|
await store.updatePrInfo(task.id, prInfo);
|
||||||
|
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t) => events.push(t));
|
||||||
|
|
||||||
|
// Update with same values (status and number unchanged)
|
||||||
|
await store.updatePrInfo(task.id, { ...prInfo });
|
||||||
|
|
||||||
|
// Should not emit because number and status are the same
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists to disk and round-trips correctly", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const prInfo = {
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
number: 42,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Fix the bug",
|
||||||
|
headBranch: "kb-001-fix-bug",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 5,
|
||||||
|
lastCommentAt: "2026-03-30T12:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
await store.updatePrInfo(task.id, prInfo);
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(fetched.prInfo).toEqual(prInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates updatedAt timestamp", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const before = task.updatedAt;
|
||||||
|
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
|
||||||
|
|
||||||
|
const prInfo = {
|
||||||
|
url: "https://github.com/owner/repo/pull/42",
|
||||||
|
number: 42,
|
||||||
|
status: "open" as const,
|
||||||
|
title: "Fix the bug",
|
||||||
|
headBranch: "kb-001-fix-bug",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
};
|
||||||
|
const updated = await store.updatePrInfo(task.id, prInfo);
|
||||||
|
|
||||||
|
expect(updated.updatedAt).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent updates correctly", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
// Fire 5 concurrent updates
|
||||||
|
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||||
|
store.updatePrInfo(task.id, {
|
||||||
|
url: `https://github.com/owner/repo/pull/${i + 1}`,
|
||||||
|
number: i + 1,
|
||||||
|
status: "open" as const,
|
||||||
|
title: `PR ${i + 1}`,
|
||||||
|
headBranch: `branch-${i + 1}`,
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: i,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// Read back and verify valid JSON
|
||||||
|
const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json");
|
||||||
|
const raw = await readFile(taskJsonPath, "utf-8");
|
||||||
|
const result = JSON.parse(raw) as Task;
|
||||||
|
|
||||||
|
// Should have exactly one of the PRs set (last one wins)
|
||||||
|
expect(result.prInfo).toBeDefined();
|
||||||
|
expect(result.prInfo!.number).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.prInfo!.number).toBeLessThanOrEqual(5);
|
||||||
|
|
||||||
|
// Should have all the PR linked log entries
|
||||||
|
const prLogs = result.log.filter((l) => l.action === "PR linked");
|
||||||
|
expect(prLogs).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
803
packages/core/src/__tests__/store-create.test.ts
Normal file
803
packages/core/src/__tests__/store-create.test.ts
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("breakIntoSubtasks task creation flag", () => {
|
||||||
|
it("persists breakIntoSubtasks=true when explicitly requested", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Large feature",
|
||||||
|
breakIntoSubtasks: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.breakIntoSubtasks).toBe(true);
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.breakIntoSubtasks).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists modelPresetId when provided during task creation", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Preset task",
|
||||||
|
modelPresetId: "budget",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.modelPresetId).toBe("budget");
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.modelPresetId).toBe("budget");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves breakIntoSubtasks unset by default", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Regular task",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.breakIntoSubtasks).toBeUndefined();
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.breakIntoSubtasks).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists missionId and sliceId when provided during task creation", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Mission-linked task",
|
||||||
|
missionId: "MS-001",
|
||||||
|
sliceId: "SL-001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.missionId).toBe("MS-001");
|
||||||
|
expect(task.sliceId).toBe("SL-001");
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.missionId).toBe("MS-001");
|
||||||
|
expect(detail.sliceId).toBe("SL-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves missionId and sliceId unset when not provided", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Regular task",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.missionId).toBeUndefined();
|
||||||
|
expect(task.sliceId).toBeUndefined();
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.missionId).toBeUndefined();
|
||||||
|
expect(detail.sliceId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe("createTask — model overrides", () => {
|
||||||
|
it("persists executor and validator model overrides on creation", async () => {
|
||||||
|
const created = await store.createTask({
|
||||||
|
title: "Task with model overrides",
|
||||||
|
description: "Use explicit executor and validator models",
|
||||||
|
modelProvider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-5",
|
||||||
|
validatorModelProvider: "openai",
|
||||||
|
validatorModelId: "gpt-4o",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.modelProvider).toBe("anthropic");
|
||||||
|
expect(created.modelId).toBe("claude-sonnet-4-5");
|
||||||
|
expect(created.validatorModelProvider).toBe("openai");
|
||||||
|
expect(created.validatorModelId).toBe("gpt-4o");
|
||||||
|
|
||||||
|
const persisted = await store.getTask(created.id);
|
||||||
|
expect(persisted.modelProvider).toBe("anthropic");
|
||||||
|
expect(persisted.modelId).toBe("claude-sonnet-4-5");
|
||||||
|
expect(persisted.validatorModelProvider).toBe("openai");
|
||||||
|
expect(persisted.validatorModelId).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("createTask — assigneeUserId", () => {
|
||||||
|
it("persists assigneeUserId on creation", async () => {
|
||||||
|
const created = await store.createTask({
|
||||||
|
title: "Task with user assignment",
|
||||||
|
description: "A task assigned to a user",
|
||||||
|
assigneeUserId: "requesting-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.assigneeUserId).toBe("requesting-user");
|
||||||
|
|
||||||
|
const persisted = await store.getTask(created.id);
|
||||||
|
expect(persisted.assigneeUserId).toBe("requesting-user");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("task provenance", () => {
|
||||||
|
it("defaults sourceType to unknown when source is omitted", async () => {
|
||||||
|
const task = await store.createTask({ description: "Provenance default" });
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.sourceType).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists simple source type from createTask", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Created from dashboard",
|
||||||
|
source: { sourceType: "dashboard_ui" },
|
||||||
|
});
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.sourceType).toBe("dashboard_ui");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("roundtrips full provenance metadata", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Heartbeat-generated task",
|
||||||
|
source: {
|
||||||
|
sourceType: "agent_heartbeat",
|
||||||
|
sourceAgentId: "agent-123",
|
||||||
|
sourceRunId: "run-456",
|
||||||
|
sourceSessionId: "session-789",
|
||||||
|
sourceMessageId: "msg-001",
|
||||||
|
sourceMetadata: { reason: "scheduled" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.sourceType).toBe("agent_heartbeat");
|
||||||
|
expect(fetched.sourceAgentId).toBe("agent-123");
|
||||||
|
expect(fetched.sourceRunId).toBe("run-456");
|
||||||
|
expect(fetched.sourceSessionId).toBe("session-789");
|
||||||
|
expect(fetched.sourceMessageId).toBe("msg-001");
|
||||||
|
expect(fetched.sourceMetadata).toEqual({ reason: "scheduled" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets duplicate and refine provenance parent links", async () => {
|
||||||
|
const source = await store.createTask({ description: "Original" });
|
||||||
|
const duplicated = await store.duplicateTask(source.id);
|
||||||
|
expect(duplicated.sourceType).toBe("task_duplicate");
|
||||||
|
expect(duplicated.sourceParentTaskId).toBe(source.id);
|
||||||
|
|
||||||
|
await store.moveTask(source.id, "todo");
|
||||||
|
await store.moveTask(source.id, "in-progress");
|
||||||
|
await store.moveTask(source.id, "in-review");
|
||||||
|
await store.moveTask(source.id, "done");
|
||||||
|
const refined = await store.refineTask(source.id, "Needs polish");
|
||||||
|
expect(refined.sourceType).toBe("task_refine");
|
||||||
|
expect(refined.sourceParentTaskId).toBe(source.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves provenance on updateTask", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Will be updated",
|
||||||
|
source: {
|
||||||
|
sourceType: "automation",
|
||||||
|
sourceAgentId: "agent-auto",
|
||||||
|
sourceMetadata: { trigger: "nightly" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.updateTask(task.id, { title: "Updated" });
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.sourceType).toBe("automation");
|
||||||
|
expect(fetched.sourceAgentId).toBe("agent-auto");
|
||||||
|
expect(fetched.sourceMetadata).toEqual({ trigger: "nightly" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists research provenance metadata", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Research finding follow-up",
|
||||||
|
source: {
|
||||||
|
sourceType: "research",
|
||||||
|
sourceMetadata: {
|
||||||
|
runId: "RR-42",
|
||||||
|
findingId: "finding-1",
|
||||||
|
findingLabel: "Key risk",
|
||||||
|
documentKey: "research-RR-42",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.sourceType).toBe("research");
|
||||||
|
expect(fetched.sourceMetadata).toEqual({
|
||||||
|
runId: "RR-42",
|
||||||
|
findingId: "finding-1",
|
||||||
|
findingLabel: "Key risk",
|
||||||
|
documentKey: "research-RR-42",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("title handling", () => {
|
||||||
|
it("creates task with undefined title when none provided", async () => {
|
||||||
|
const task = await store.createTask({ description: "Fix the login bug on the settings page" });
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(task.description).toBe("Fix the login bug on the settings page");
|
||||||
|
|
||||||
|
// Verify persisted to disk
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates task with provided title", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: "Custom Title",
|
||||||
|
description: "This is the description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.title).toBe("Custom Title");
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.title).toBe("Custom Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from provided title", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: " Padded Title ",
|
||||||
|
description: "Some description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.title).toBe("Padded Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats empty string title as undefined", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: "",
|
||||||
|
description: "Some description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats whitespace-only title as undefined", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: " ",
|
||||||
|
description: "Some description",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves description exactly as provided", async () => {
|
||||||
|
const description = "Fix $$$ bug @ home-page (urgent!)";
|
||||||
|
const task = await store.createTask({ description });
|
||||||
|
|
||||||
|
expect(task.description).toBe(description);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes ID only in PROMPT.md heading when no title", async () => {
|
||||||
|
const task = await store.createTask({ description: "Implement the new feature" });
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
|
||||||
|
// Heading should be just the task ID when no title is provided
|
||||||
|
expect(detail.prompt).toMatch(/^# FN-001\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes title in PROMPT.md heading when provided", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: "My Feature",
|
||||||
|
description: "Build something great",
|
||||||
|
column: "todo",
|
||||||
|
});
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(detail.prompt).toMatch(/^# FN-001: My Feature\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty description gracefully (should throw)", async () => {
|
||||||
|
await expect(store.createTask({ description: "" })).rejects.toThrow("Description is required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Archive Cleanup Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("createTask with title summarization", () => {
|
||||||
|
it("should use generated title when onSummarize returns a title", async () => {
|
||||||
|
const longDescription = "a".repeat(201);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Generated Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: longDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Title is not set synchronously - summarization happens async
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).toHaveBeenCalledWith(longDescription);
|
||||||
|
|
||||||
|
// Wait for async summarization to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Verify title was set asynchronously
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBe("AI Generated Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when title is already provided", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ title: "User Title", description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBe("User Title");
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when description is too short", async () => {
|
||||||
|
const shortDescription = "a".repeat(100);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: shortDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when autoSummarizeTitles is false", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: false } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not call onSummarize when no settings provided", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSummarize when summarize input flag is true", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201), summarize: true },
|
||||||
|
{ onSummarize: mockOnSummarize }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Title is not set synchronously
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(mockOnSummarize).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Wait for async summarization to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Verify title was set asynchronously
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBe("AI Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore malformed confirmation-prose generated titles", async () => {
|
||||||
|
const mockOnSummarize = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue("Created task **FN-9999** in the triage column. Here's a summary.");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle onSummarize returning null", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task created without title
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
|
||||||
|
// Wait for async summarization to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Title should remain undefined
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle onSummarize throwing error gracefully", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const mockOnSummarize = vi.fn().mockRejectedValue(new Error("AI service failed"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(task.id).toMatch(/^FN-\d+$/); // Task still created
|
||||||
|
|
||||||
|
// Wait for async error to be logged
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
const [message, context] = consoleSpy.mock.calls[0] as [string, Record<string, unknown>];
|
||||||
|
expect(message).toContain("[task-store] Title summarization failed for task");
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
taskId: task.id,
|
||||||
|
descriptionLength: 201,
|
||||||
|
autoSummarizeEnabled: true,
|
||||||
|
error: "AI service failed",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs outer promise-chain failure when inner warning logger throws", async () => {
|
||||||
|
const syntheticError = "Synthetic warn logger failure";
|
||||||
|
// Throw inside warn logging so the failure escapes the inner summarize try/catch
|
||||||
|
// and is handled by the outer Promise.resolve().then(...).catch(...) branch.
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||||
|
throw new Error(syntheticError);
|
||||||
|
});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const mockOnSummarize = vi.fn().mockRejectedValue(new Error("AI service failed"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.id).toMatch(/^FN-\d+$/);
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBeUndefined();
|
||||||
|
|
||||||
|
const outerErrorCall = errorSpy.mock.calls.find(([message]) =>
|
||||||
|
typeof message === "string"
|
||||||
|
&& message.includes("[task-store] Unexpected title summarization promise-chain failure")
|
||||||
|
);
|
||||||
|
expect(outerErrorCall).toBeDefined();
|
||||||
|
|
||||||
|
const [message, context] = outerErrorCall as [string, Record<string, unknown>];
|
||||||
|
expect(message).toContain("[task-store] Unexpected title summarization promise-chain failure");
|
||||||
|
expect(context).toMatchObject({
|
||||||
|
taskId: task.id,
|
||||||
|
descriptionLength: 201,
|
||||||
|
autoSummarizeEnabled: true,
|
||||||
|
error: syntheticError,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger summarization at exactly 201 characters", async () => {
|
||||||
|
const boundaryDescription = "a".repeat(201);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: boundaryDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnSummarize).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Wait for async summarization
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBe("AI Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not trigger summarization at exactly 200 characters", async () => {
|
||||||
|
const boundaryDescription = "a".repeat(200);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: boundaryDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize explicit title over summarize flag", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ title: "User Title", description: "a".repeat(201), summarize: true },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(task.title).toBe("User Title");
|
||||||
|
expect(mockOnSummarize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include generated title in PROMPT.md heading", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("Generated Task Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Title not set synchronously
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
|
||||||
|
// Wait for async summarization
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Verify title and PROMPT.md were updated
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBe("Generated Task Title");
|
||||||
|
expect(updatedTask.prompt).toMatch(/^# FN-\d+: Generated Task Title\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve original description when generating a title", async () => {
|
||||||
|
const originalDescription = "a".repeat(201);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Summary Title");
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: originalDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Title not set synchronously
|
||||||
|
expect(task.title).toBeUndefined();
|
||||||
|
expect(task.description).toBe(originalDescription);
|
||||||
|
|
||||||
|
// Wait for async summarization
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBe("AI Summary Title");
|
||||||
|
expect(updatedTask.description).toBe(originalDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not overwrite user-set title during async summarization", async () => {
|
||||||
|
const mockOnSummarize = vi.fn().mockImplementation(async () => {
|
||||||
|
// Simulate slow AI response
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
return "AI Title";
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Immediately update with user title
|
||||||
|
await store.updateTask(task.id, { title: "User Title" });
|
||||||
|
|
||||||
|
// Wait for delayed onSummarize to resolve
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Title should still be "User Title" (race guard should have prevented overwrite)
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBe("User Title");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Utility Path Independence Regression ─────────────────────────────────────
|
||||||
|
// FN-1727: Title summarization runs on a separate utility lane (async microtask)
|
||||||
|
// and is NOT gated by task-lane semaphore settings. This test proves that:
|
||||||
|
// 1. createTask returns immediately (synchronous) regardless of maxConcurrent
|
||||||
|
// 2. onSummarize callback fires asynchronously via Promise.resolve().then()
|
||||||
|
// 3. Task creation succeeds even when onSummarize would be blocked by semaphore
|
||||||
|
//
|
||||||
|
// The engine's maxConcurrent setting lives at the execution layer and does NOT
|
||||||
|
// affect the core store's createTask method, which has no semaphore dependency.
|
||||||
|
|
||||||
|
describe("createTask summarization is independent of engine maxConcurrent settings", () => {
|
||||||
|
it("creates task and calls onSummarize even with maxConcurrent: 0", async () => {
|
||||||
|
// Set extreme concurrency setting to prove the core store is unaffected.
|
||||||
|
// Note: The core store does NOT read maxConcurrent from settings during
|
||||||
|
// createTask - this is purely a documentation regression proving the
|
||||||
|
// architectural separation between core (store) and engine (semaphore).
|
||||||
|
await store.updateSettings({ maxConcurrent: 0 });
|
||||||
|
|
||||||
|
const longDescription = "a".repeat(201);
|
||||||
|
const mockOnSummarize = vi.fn().mockResolvedValue("AI Title From Saturation Test");
|
||||||
|
|
||||||
|
// Create task with summarization enabled
|
||||||
|
const task = await store.createTask(
|
||||||
|
{ description: longDescription },
|
||||||
|
{ onSummarize: mockOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRITICAL ASSERTIONS:
|
||||||
|
// 1. Task was created immediately (synchronous return)
|
||||||
|
expect(task.id).toMatch(/^FN-\d+$/);
|
||||||
|
expect(task.title).toBeUndefined(); // Not set synchronously
|
||||||
|
|
||||||
|
// 2. onSummarize was called (async but independent of maxConcurrent)
|
||||||
|
expect(mockOnSummarize).toHaveBeenCalledWith(longDescription);
|
||||||
|
|
||||||
|
// 3. Wait for async summarization and verify title was set
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const updatedTask = await store.getTask(task.id);
|
||||||
|
expect(updatedTask.title).toBe("AI Title From Saturation Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset maxConcurrent to normal value
|
||||||
|
await store.updateSettings({ maxConcurrent: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("task creation succeeds when onSummarize is blocked by slow callback (proving no semaphore dependency)", async () => {
|
||||||
|
// Simulate a slow/stalled onSummarize callback to prove there's no
|
||||||
|
// semaphore that would block task creation. The core store has no
|
||||||
|
// dependency on any concurrency limiter.
|
||||||
|
const slowOnSummarize = vi.fn().mockImplementation(
|
||||||
|
async () => new Promise<string>(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const taskPromise = store.createTask(
|
||||||
|
{ description: "a".repeat(201) },
|
||||||
|
{ onSummarize: slowOnSummarize, settings: { autoSummarizeTitles: true } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Task creation MUST complete quickly (before slowOnSummarize resolves)
|
||||||
|
const task = await taskPromise;
|
||||||
|
expect(task.id).toMatch(/^FN-\d+$/);
|
||||||
|
|
||||||
|
// Verify slowOnSummarize was initiated (async microtask)
|
||||||
|
expect(slowOnSummarize).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The slow callback is still pending (would take 1000ms to resolve)
|
||||||
|
// but task creation already succeeded - proving no blocking dependency
|
||||||
|
const freshTask = await store.getTask(task.id);
|
||||||
|
expect(freshTask.id).toBe(task.id);
|
||||||
|
// Title not yet set because onSummarize is still pending
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("distributed task-id allocator seam", () => {
|
||||||
|
it("returns a stable allocator instance", () => {
|
||||||
|
const first = store.getDistributedTaskIdAllocator();
|
||||||
|
const second = store.getDistributedTaskIdAllocator();
|
||||||
|
expect(first).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createTaskWithReservedId creates using provided id", async () => {
|
||||||
|
const created = await store.createTaskWithReservedId(
|
||||||
|
{ description: "replicated task", nodeId: "node-b" },
|
||||||
|
{ taskId: "FN-9001" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(created.id).toBe("FN-9001");
|
||||||
|
expect(created.nodeId).toBe("node-b");
|
||||||
|
const detail = await store.getTask("FN-9001");
|
||||||
|
expect(detail.prompt).toBe("# FN-9001\n\nreplicated task\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createTaskWithReservedId rejects duplicates and self-dependencies", async () => {
|
||||||
|
await store.createTaskWithReservedId({ description: "first" }, { taskId: "FN-9003" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.createTaskWithReservedId({ description: "duplicate" }, { taskId: "FN-9003" }),
|
||||||
|
).rejects.toThrow("Task ID already exists: FN-9003");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.createTaskWithReservedId(
|
||||||
|
{ description: "self dep", dependencies: ["FN-9004"] },
|
||||||
|
{ taskId: "FN-9004" },
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Task FN-9004 cannot depend on itself");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applyReplicatedTaskCreate does not auto-apply default workflow steps", async () => {
|
||||||
|
const workflowStep = await store.createWorkflowStep({
|
||||||
|
name: "Default step",
|
||||||
|
description: "auto",
|
||||||
|
enabled: true,
|
||||||
|
defaultOn: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
replicationVersion: 1 as const,
|
||||||
|
reservationId: "res-default-step",
|
||||||
|
taskId: "FN-9010",
|
||||||
|
sourceNodeId: "node-a",
|
||||||
|
createdAt: "2026-05-05T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||||
|
prompt: "# FN-9010\n\ncluster create\n",
|
||||||
|
input: {
|
||||||
|
description: "cluster create",
|
||||||
|
column: "triage" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await store.applyReplicatedTaskCreate(payload);
|
||||||
|
expect(result.applied).toBe(true);
|
||||||
|
expect(result.task.enabledWorkflowSteps).toBeUndefined();
|
||||||
|
expect(workflowStep.defaultOn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applyReplicatedTaskCreate is idempotent and detects collisions", async () => {
|
||||||
|
const payload = {
|
||||||
|
replicationVersion: 1 as const,
|
||||||
|
reservationId: "res-1",
|
||||||
|
taskId: "FN-9002",
|
||||||
|
sourceNodeId: "node-a",
|
||||||
|
createdAt: "2026-05-05T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||||
|
prompt: "# FN-9002\n\ncluster create\n",
|
||||||
|
input: {
|
||||||
|
description: "cluster create",
|
||||||
|
column: "triage" as const,
|
||||||
|
nodeId: "node-c",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await store.applyReplicatedTaskCreate(payload);
|
||||||
|
expect(first.applied).toBe(true);
|
||||||
|
const second = await store.applyReplicatedTaskCreate(payload);
|
||||||
|
expect(second.applied).toBe(false);
|
||||||
|
expect(second.task.id).toBe("FN-9002");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.applyReplicatedTaskCreate({
|
||||||
|
...payload,
|
||||||
|
input: { ...payload.input, description: "different" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Replicated task payload collision");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
786
packages/core/src/__tests__/store-movement.test.ts
Normal file
786
packages/core/src/__tests__/store-movement.test.ts
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("moveTask — in-progress to triage", () => {
|
||||||
|
it("allows moving an in-progress task to triage", async () => {
|
||||||
|
const task = await store.createTask({ description: "test in-progress to triage" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "triage");
|
||||||
|
expect(moved.column).toBe("triage");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("moveTask — resets steps when moving back to todo/triage", () => {
|
||||||
|
async function setMixedStepStatuses(taskId: string): Promise<void> {
|
||||||
|
await store.updateStep(taskId, 0, "done");
|
||||||
|
await store.updateStep(taskId, 1, "in-progress");
|
||||||
|
await store.updateStep(taskId, 2, "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("resets all steps to pending and currentStep to 0 when moving from in-progress to todo", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(task.id);
|
||||||
|
await store.updateTask(task.id, { currentStep: 2 });
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
|
||||||
|
expect(moved.currentStep).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets all steps to pending and currentStep to 0 when moving from in-progress to triage", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(task.id);
|
||||||
|
await store.updateTask(task.id, { currentStep: 1 });
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "triage");
|
||||||
|
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
|
||||||
|
expect(moved.currentStep).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves step progress when moving in-progress → todo with preserveResumeState option", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(task.id);
|
||||||
|
await store.updateTask(task.id, { currentStep: 2 });
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo", { preserveResumeState: true });
|
||||||
|
|
||||||
|
expect(moved.steps[0].status).toBe("done");
|
||||||
|
expect(moved.steps[1].status).toBe("in-progress");
|
||||||
|
expect(moved.steps[2].status).toBe("pending");
|
||||||
|
expect(moved.currentStep).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves step progress and currentStep when moving in-progress → todo with preserveProgress", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: Checkbox keep
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
|
||||||
|
- [x] Done thing
|
||||||
|
|
||||||
|
### Step 1: Implement
|
||||||
|
|
||||||
|
- [ ] Pending thing
|
||||||
|
|
||||||
|
### Step 2: Verify
|
||||||
|
|
||||||
|
- [ ] Pending thing
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(task.id);
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
currentStep: 2,
|
||||||
|
worktree: "/tmp/worktree",
|
||||||
|
executionStartedAt: new Date().toISOString(),
|
||||||
|
executionCompletedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo", { preserveProgress: true });
|
||||||
|
const prompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||||
|
|
||||||
|
expect(moved.steps[0].status).toBe("done");
|
||||||
|
expect(moved.steps[1].status).toBe("in-progress");
|
||||||
|
expect(moved.currentStep).toBe(2);
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.executionStartedAt).toBeUndefined();
|
||||||
|
expect(moved.executionCompletedAt).toBeUndefined();
|
||||||
|
expect(prompt).toContain("- [x] Done thing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still resets when preserveProgress is true but all steps are pending", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(task.id);
|
||||||
|
await store.updateStep(task.id, 0, "pending");
|
||||||
|
await store.updateStep(task.id, 1, "pending");
|
||||||
|
await store.updateTask(task.id, { currentStep: 2 });
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo", { preserveProgress: true });
|
||||||
|
|
||||||
|
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
|
||||||
|
expect(moved.currentStep).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves steps for in-review → todo and done → todo with preserveProgress", async () => {
|
||||||
|
const fromReview = await createTaskWithSteps();
|
||||||
|
await store.moveTask(fromReview.id, "todo");
|
||||||
|
await store.moveTask(fromReview.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(fromReview.id);
|
||||||
|
await store.moveTask(fromReview.id, "in-review");
|
||||||
|
await store.updateTask(fromReview.id, { currentStep: 1, executionStartedAt: new Date().toISOString() });
|
||||||
|
|
||||||
|
const reviewMoved = await store.moveTask(fromReview.id, "todo", { preserveProgress: true });
|
||||||
|
expect(reviewMoved.steps[0].status).toBe("done");
|
||||||
|
expect(reviewMoved.steps[1].status).toBe("in-progress");
|
||||||
|
expect(reviewMoved.currentStep).toBe(1);
|
||||||
|
expect(reviewMoved.executionStartedAt).toBeUndefined();
|
||||||
|
|
||||||
|
const fromDone = await createTaskWithSteps();
|
||||||
|
await store.moveTask(fromDone.id, "todo");
|
||||||
|
await store.moveTask(fromDone.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(fromDone.id);
|
||||||
|
await store.updateStep(fromDone.id, 1, "done");
|
||||||
|
await store.updateStep(fromDone.id, 2, "done");
|
||||||
|
await store.moveTask(fromDone.id, "in-review");
|
||||||
|
await store.moveTask(fromDone.id, "done");
|
||||||
|
await store.updateTask(fromDone.id, { currentStep: 2, executionStartedAt: new Date().toISOString() });
|
||||||
|
|
||||||
|
const doneMoved = await store.moveTask(fromDone.id, "todo", { preserveProgress: true });
|
||||||
|
expect(doneMoved.steps[0].status).toBe("done");
|
||||||
|
expect(doneMoved.steps[1].status).toBe("done");
|
||||||
|
expect(doneMoved.currentStep).toBe(2);
|
||||||
|
expect(doneMoved.executionStartedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserveResumeState keeps step progress and timing but always releases the worktree", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await setMixedStepStatuses(task.id);
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
currentStep: 2,
|
||||||
|
worktree: "/tmp/worktree",
|
||||||
|
branch: "fusion/fn-test",
|
||||||
|
executionStartedAt: startedAt,
|
||||||
|
executionCompletedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo", {
|
||||||
|
preserveProgress: true,
|
||||||
|
preserveResumeState: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(moved.steps[0].status).toBe("done");
|
||||||
|
expect(moved.steps[1].status).toBe("in-progress");
|
||||||
|
expect(moved.currentStep).toBe(2);
|
||||||
|
// Worktree is always released on requeue so the directory can be
|
||||||
|
// reused by another task; the branch stays so progress is kept.
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.branch).toBe("fusion/fn-test");
|
||||||
|
expect(moved.executionStartedAt).toBe(startedAt);
|
||||||
|
expect(moved.executionCompletedAt).toBeUndefined();
|
||||||
|
|
||||||
|
// Round-trip: when the task is re-promoted to in-progress with a
|
||||||
|
// fresh allocator, the branch reference must survive the requeue
|
||||||
|
// so the executor can reattach to it via createFromExistingBranch
|
||||||
|
// and resume the in-flight changes. Guards against regressions in
|
||||||
|
// the in-review → todo full-reset path leaking into other paths.
|
||||||
|
const repromoted = await store.moveTask(task.id, "in-progress", {
|
||||||
|
allocateWorktree: () => "/tmp/worktree-fresh",
|
||||||
|
});
|
||||||
|
expect(repromoted.branch).toBe("fusion/fn-test");
|
||||||
|
expect(repromoted.worktree).toBe("/tmp/worktree-fresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserveWorktree keeps the directory across an internal bounce", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.updateTask(task.id, { worktree: "/tmp/wt-bounce" });
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo", {
|
||||||
|
preserveResumeState: true,
|
||||||
|
preserveWorktree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bounce path keeps the same checkout assigned so listeners
|
||||||
|
// never observe an interim worktree=null state and self-healing
|
||||||
|
// can't reclaim the directory as idle.
|
||||||
|
expect(moved.worktree).toBe("/tmp/wt-bounce");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allocateWorktree assigns a path under the cross-task lock and avoids names already in use", async () => {
|
||||||
|
const a = await createTaskWithSteps();
|
||||||
|
const b = await createTaskWithSteps();
|
||||||
|
await store.moveTask(a.id, "todo");
|
||||||
|
await store.moveTask(a.id, "in-progress");
|
||||||
|
await store.updateTask(a.id, { worktree: "/tmp/.worktrees/eager-daisy" });
|
||||||
|
await store.moveTask(b.id, "todo");
|
||||||
|
|
||||||
|
const seenReserved: Set<string>[] = [];
|
||||||
|
const moved = await store.moveTask(b.id, "in-progress", {
|
||||||
|
allocateWorktree: (reservedNames) => {
|
||||||
|
seenReserved.push(new Set(reservedNames));
|
||||||
|
// Caller picks a name; if it collides with reservedNames the
|
||||||
|
// caller is responsible for choosing a different one. Here we
|
||||||
|
// assert the reservedNames snapshot reflects task A's
|
||||||
|
// assignment, then return a non-colliding path.
|
||||||
|
return "/tmp/.worktrees/swift-falcon";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seenReserved).toHaveLength(1);
|
||||||
|
expect(seenReserved[0].has("eager-daisy")).toBe(true);
|
||||||
|
// The allocator's task itself must not appear in reservedNames —
|
||||||
|
// a task should never be told to avoid its own current name.
|
||||||
|
expect(seenReserved[0].has("swift-falcon")).toBe(false);
|
||||||
|
expect(moved.worktree).toBe("/tmp/.worktrees/swift-falcon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets steps when moving from in-review to todo", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
const withSteps = await store.getTask(task.id);
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
steps: withSteps.steps.map((step) => ({ ...step, status: "done" })),
|
||||||
|
currentStep: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
|
||||||
|
expect(moved.currentStep).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets steps when moving from done to todo", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
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 withDoneSteps = await store.getTask(task.id);
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
steps: withDoneSteps.steps.map((step) => ({ ...step, status: "done" })),
|
||||||
|
currentStep: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
|
||||||
|
expect(moved.currentStep).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets steps when moving from done to triage", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
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 withDoneSteps = await store.getTask(task.id);
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
steps: withDoneSteps.steps.map((step) => ({ ...step, status: "done" })),
|
||||||
|
currentStep: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "triage");
|
||||||
|
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
|
||||||
|
expect(moved.currentStep).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset steps when moving from todo to triage", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.updateStep(task.id, 0, "done");
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "triage");
|
||||||
|
expect(moved.steps[0]?.status).toBe("done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets PROMPT.md checkboxes when moving from in-progress to todo", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: Checkbox reset
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
|
||||||
|
- [x] Done thing
|
||||||
|
|
||||||
|
### Step 1: Implement
|
||||||
|
|
||||||
|
- [x] Done thing
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
|
const prompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||||
|
expect(prompt).not.toContain("- [x]");
|
||||||
|
expect(prompt).toContain("- [ ] Done thing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when steps array is empty", async () => {
|
||||||
|
const task = await store.createTask({ description: "no steps reset" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await expect(store.moveTask(task.id, "todo")).resolves.toMatchObject({ id: task.id, column: "todo" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("moveTask — clears transient fields when leaving in-progress", () => {
|
||||||
|
it("clears status, error, worktree, and blockedBy when moving from in-progress to todo", async () => {
|
||||||
|
const task = await store.createTask({ description: "test clear fields" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Simulate a failed state
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "Something went wrong",
|
||||||
|
worktree: "test-worktree",
|
||||||
|
blockedBy: "FN-001"
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.column).toBe("todo");
|
||||||
|
expect(moved.status).toBeUndefined();
|
||||||
|
expect(moved.error).toBeUndefined();
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.blockedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears status, error, worktree, and blockedBy when moving from in-progress to triage", async () => {
|
||||||
|
const task = await store.createTask({ description: "test clear fields to triage" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Simulate a failed state
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "Something went wrong",
|
||||||
|
worktree: "test-worktree",
|
||||||
|
blockedBy: "FN-001"
|
||||||
|
});
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "triage");
|
||||||
|
expect(moved.column).toBe("triage");
|
||||||
|
expect(moved.status).toBeUndefined();
|
||||||
|
expect(moved.error).toBeUndefined();
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.blockedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves status when moving from todo to in-progress", async () => {
|
||||||
|
const task = await store.createTask({ description: "test preserve status", column: "todo" });
|
||||||
|
|
||||||
|
// Set a custom status before moving to in-progress
|
||||||
|
await store.updateTask(task.id, { status: "planning" });
|
||||||
|
|
||||||
|
const moved = await store.moveTask(task.id, "in-progress");
|
||||||
|
expect(moved.column).toBe("in-progress");
|
||||||
|
expect(moved.status).toBe("planning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear status when moving between non-in-progress columns", async () => {
|
||||||
|
const task = await store.createTask({ description: "test non-in-progress move" });
|
||||||
|
// Task starts in triage
|
||||||
|
|
||||||
|
// Set a custom status
|
||||||
|
await store.updateTask(task.id, { status: "custom-status" });
|
||||||
|
|
||||||
|
// Move from triage to todo
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
expect(moved.column).toBe("todo");
|
||||||
|
expect(moved.status).toBe("custom-status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears status, error, worktree, and blockedBy when moving from in-progress to done", async () => {
|
||||||
|
const task = await store.createTask({ description: "test clear fields to done" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Simulate transient state that should not block completion
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "custom-status",
|
||||||
|
error: "Transient note",
|
||||||
|
worktree: "test-worktree",
|
||||||
|
blockedBy: "FN-001"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must go through in-review to reach done
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
const moved = await store.moveTask(task.id, "done");
|
||||||
|
expect(moved.column).toBe("done");
|
||||||
|
expect(moved.status).toBeUndefined();
|
||||||
|
expect(moved.error).toBeUndefined();
|
||||||
|
expect(moved.worktree).toBeUndefined();
|
||||||
|
expect(moved.blockedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears recovery fields when moving to done (FN-985 regression)", async () => {
|
||||||
|
const task = await store.createTask({ description: "test recovery fields" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
// Set recovery metadata via updateTask
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
recoveryRetryCount: 3,
|
||||||
|
nextRecoveryAt: new Date(Date.now() + 86400000).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
const moved = await store.moveTask(task.id, "done");
|
||||||
|
expect(moved.column).toBe("done");
|
||||||
|
expect(moved.recoveryRetryCount).toBeUndefined();
|
||||||
|
expect(moved.nextRecoveryAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats repeated done finalization as an idempotent no-op", async () => {
|
||||||
|
const task = await store.createTask({ description: "test repeated done finalization" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
const done = await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
const repeated = await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
expect(repeated.column).toBe("done");
|
||||||
|
expect(repeated.updatedAt).toBe(done.updatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes stale completion fields on repeated done finalization", async () => {
|
||||||
|
const task = await store.createTask({ description: "test repeated dirty done finalization" });
|
||||||
|
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.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "stale failure",
|
||||||
|
blockedBy: "FN-000",
|
||||||
|
worktree: "/tmp/fusion-stale-worktree",
|
||||||
|
recoveryRetryCount: 2,
|
||||||
|
nextRecoveryAt: new Date(Date.now() + 86400000).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeated = await store.moveTask(task.id, "done");
|
||||||
|
|
||||||
|
expect(repeated.column).toBe("done");
|
||||||
|
expect(repeated.status).toBeUndefined();
|
||||||
|
expect(repeated.error).toBeUndefined();
|
||||||
|
expect(repeated.blockedBy).toBeUndefined();
|
||||||
|
expect(repeated.worktree).toBeUndefined();
|
||||||
|
expect(repeated.recoveryRetryCount).toBeUndefined();
|
||||||
|
expect(repeated.nextRecoveryAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks moving failed in-review tasks to done", async () => {
|
||||||
|
const task = await store.createTask({ description: "test block failed review task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "Workflow step failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
await expect(store.moveTask(task.id, "done")).rejects.toThrow(
|
||||||
|
"Cannot move",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks moving in-review tasks with incomplete steps to done", async () => {
|
||||||
|
const task = await store.createTask({ description: "test block incomplete review task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.updateTask(task.id, { prompt: "## Steps\n### Step 0: First\n### Step 1: Second" });
|
||||||
|
await store.updateStep(task.id, 0, "done");
|
||||||
|
await store.updateStep(task.id, 1, "in-progress");
|
||||||
|
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
await expect(store.moveTask(task.id, "done")).rejects.toThrow(
|
||||||
|
"task has incomplete steps",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows reopening done tasks back to todo", async () => {
|
||||||
|
const task = await store.createTask({ description: "test reopen done task to todo" });
|
||||||
|
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 reopened = await store.moveTask(task.id, "todo");
|
||||||
|
expect(reopened.column).toBe("todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows reopening done tasks back to triage and clears transient execution state", async () => {
|
||||||
|
const task = await store.createTask({ description: "test reopen done task to triage" });
|
||||||
|
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.updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "stale completion error",
|
||||||
|
worktree: "stale-worktree",
|
||||||
|
blockedBy: "FN-123",
|
||||||
|
workflowStepResults: [{
|
||||||
|
workflowStepId: "wf-1",
|
||||||
|
workflowStepName: "Workflow step 1",
|
||||||
|
status: "passed",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reopened = await store.moveTask(task.id, "triage");
|
||||||
|
expect(reopened.column).toBe("triage");
|
||||||
|
expect(reopened.status).toBeUndefined();
|
||||||
|
expect(reopened.error).toBeUndefined();
|
||||||
|
expect(reopened.worktree).toBeUndefined();
|
||||||
|
expect(reopened.blockedBy).toBeUndefined();
|
||||||
|
expect(reopened.workflowStepResults).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows retrying in-review tasks back to todo and clears transient fields", async () => {
|
||||||
|
const task = await store.createTask({ description: "test retry in-review task to todo" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "completed",
|
||||||
|
error: "stale error",
|
||||||
|
worktree: "stale-worktree",
|
||||||
|
blockedBy: "FN-456",
|
||||||
|
branch: "fn/stale-branch",
|
||||||
|
baseBranch: "main",
|
||||||
|
baseCommitSha: "abc123",
|
||||||
|
summary: "stale summary from prior attempt",
|
||||||
|
recoveryRetryCount: 2,
|
||||||
|
nextRecoveryAt: new Date().toISOString(),
|
||||||
|
workflowStepResults: [{
|
||||||
|
workflowStepId: "wf-1",
|
||||||
|
workflowStepName: "Workflow step 1",
|
||||||
|
status: "passed",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const retried = await store.moveTask(task.id, "todo");
|
||||||
|
expect(retried.column).toBe("todo");
|
||||||
|
expect(retried.status).toBeUndefined();
|
||||||
|
expect(retried.error).toBeUndefined();
|
||||||
|
expect(retried.worktree).toBeUndefined();
|
||||||
|
expect(retried.blockedBy).toBeUndefined();
|
||||||
|
expect(retried.workflowStepResults).toBeUndefined();
|
||||||
|
// Full reset: prior branch/summary/recovery state discarded so the next
|
||||||
|
// run starts from scratch.
|
||||||
|
expect(retried.branch).toBeUndefined();
|
||||||
|
expect(retried.baseBranch).toBe("main");
|
||||||
|
expect(retried.executionStartBranch).toBeUndefined();
|
||||||
|
expect(retried.baseCommitSha).toBeUndefined();
|
||||||
|
expect(retried.summary).toBeUndefined();
|
||||||
|
expect(retried.recoveryRetryCount).toBeUndefined();
|
||||||
|
expect(retried.nextRecoveryAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows respec'ing in-review tasks back to triage and clears transient fields", async () => {
|
||||||
|
const task = await store.createTask({ description: "test respec in-review task to triage" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
status: "completed",
|
||||||
|
error: "stale error",
|
||||||
|
worktree: "stale-worktree",
|
||||||
|
blockedBy: "FN-456",
|
||||||
|
branch: "fn/stale-branch",
|
||||||
|
baseBranch: "main",
|
||||||
|
baseCommitSha: "abc123",
|
||||||
|
summary: "stale summary from prior attempt",
|
||||||
|
recoveryRetryCount: 2,
|
||||||
|
nextRecoveryAt: new Date().toISOString(),
|
||||||
|
workflowStepResults: [{
|
||||||
|
workflowStepId: "wf-1",
|
||||||
|
workflowStepName: "Workflow step 1",
|
||||||
|
status: "passed",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const respec = await store.moveTask(task.id, "triage");
|
||||||
|
expect(respec.column).toBe("triage");
|
||||||
|
expect(respec.status).toBeUndefined();
|
||||||
|
expect(respec.error).toBeUndefined();
|
||||||
|
expect(respec.worktree).toBeUndefined();
|
||||||
|
expect(respec.blockedBy).toBeUndefined();
|
||||||
|
expect(respec.workflowStepResults).toBeUndefined();
|
||||||
|
expect(respec.branch).toBeUndefined();
|
||||||
|
expect(respec.baseBranch).toBe("main");
|
||||||
|
expect(respec.executionStartBranch).toBeUndefined();
|
||||||
|
expect(respec.baseCommitSha).toBeUndefined();
|
||||||
|
expect(respec.summary).toBeUndefined();
|
||||||
|
expect(respec.recoveryRetryCount).toBeUndefined();
|
||||||
|
expect(respec.nextRecoveryAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("columnMovedAt", () => {
|
||||||
|
it("createTask sets columnMovedAt", async () => {
|
||||||
|
const before = new Date().toISOString();
|
||||||
|
const task = await store.createTask({ description: "test columnMovedAt" });
|
||||||
|
const after = new Date().toISOString();
|
||||||
|
expect(task.columnMovedAt).toBeDefined();
|
||||||
|
expect(task.columnMovedAt! >= before).toBe(true);
|
||||||
|
expect(task.columnMovedAt! <= after).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask sets columnMovedAt to a recent ISO timestamp", async () => {
|
||||||
|
const task = await store.createTask({ description: "move test", column: "triage" });
|
||||||
|
const originalMovedAt = task.columnMovedAt;
|
||||||
|
|
||||||
|
// Small delay to ensure timestamp differs
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const before = new Date().toISOString();
|
||||||
|
const moved = await store.moveTask(task.id, "todo");
|
||||||
|
const after = new Date().toISOString();
|
||||||
|
|
||||||
|
expect(moved.columnMovedAt).toBeDefined();
|
||||||
|
expect(moved.columnMovedAt! >= before).toBe(true);
|
||||||
|
expect(moved.columnMovedAt! <= after).toBe(true);
|
||||||
|
expect(moved.columnMovedAt).not.toBe(originalMovedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateTask does NOT change columnMovedAt", async () => {
|
||||||
|
const task = await store.createTask({ description: "no change test" });
|
||||||
|
const originalMovedAt = task.columnMovedAt;
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, { title: "new title" });
|
||||||
|
expect(updated.columnMovedAt).toBe(originalMovedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("VALID_TRANSITIONS — invalid archived transitions via moveTask", () => {
|
||||||
|
it("moveTask from archived → in-progress should fail", 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 expect(store.moveTask(task.id, "in-progress")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask from archived → triage should fail", 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 expect(store.moveTask(task.id, "triage")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask from archived → todo should fail", 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 expect(store.moveTask(task.id, "todo")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask from archived → in-review should fail", 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 expect(store.moveTask(task.id, "in-review")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask from triage → archived should fail", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
// Task starts in triage
|
||||||
|
|
||||||
|
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask from todo → archived should fail", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
|
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask from in-progress → archived should fail", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveTask from in-review → archived should fail", 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 expect(store.moveTask(task.id, "archived")).rejects.toThrow("Invalid transition");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
829
packages/core/src/__tests__/store-ops.test.ts
Normal file
829
packages/core/src/__tests__/store-ops.test.ts
Normal file
@@ -0,0 +1,829 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir, mockedExecSync, mockedRunCommandAsync } from "./store-test-helpers.js";
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import type { runCommandAsync } from "../run-command.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("duplicateTask", () => {
|
||||||
|
it("duplicates from triage column", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.id).not.toBe(task.id);
|
||||||
|
expect(duplicated.id).toMatch(/^FN-\d+$/);
|
||||||
|
expect(duplicated.column).toBe("triage");
|
||||||
|
expect(duplicated.description).toContain(task.description);
|
||||||
|
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicates from todo column", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.column).toBe("triage");
|
||||||
|
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicates from in-progress column", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.column).toBe("triage");
|
||||||
|
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicates from in-review column", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.column).toBe("triage");
|
||||||
|
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicates from done column", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
await store.moveTask(task.id, "done");
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.column).toBe("triage");
|
||||||
|
expect(duplicated.description).toContain(`(Duplicated from ${task.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("new task is always in triage regardless of source column", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
expect(duplicated.column).toBe("triage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("description includes source reference", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original description" });
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.description).toBe(`Original description\n\n(Duplicated from ${task.id})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets execution state (no steps, no worktree, etc.)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task", column: "todo" });
|
||||||
|
// Add some execution state
|
||||||
|
await store.updateTask(task.id, { worktree: "/some/path", status: "executing" });
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.steps).toEqual([]);
|
||||||
|
expect(duplicated.currentStep).toBe(0);
|
||||||
|
expect(duplicated.worktree).toBeUndefined();
|
||||||
|
expect(duplicated.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears nullable execution fields via updateTask(null)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test clear nullable execution fields", column: "todo" });
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
worktree: "/some/path",
|
||||||
|
branch: "fusion/fn-001",
|
||||||
|
baseBranch: "main",
|
||||||
|
baseCommitSha: "abc123",
|
||||||
|
status: "executing",
|
||||||
|
error: "boom",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, {
|
||||||
|
worktree: null,
|
||||||
|
branch: null,
|
||||||
|
baseBranch: null,
|
||||||
|
baseCommitSha: null,
|
||||||
|
status: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.worktree).toBeUndefined();
|
||||||
|
expect(updated.branch).toBeUndefined();
|
||||||
|
expect(updated.baseBranch).toBeUndefined();
|
||||||
|
expect(updated.baseCommitSha).toBeUndefined();
|
||||||
|
expect(updated.status).toBeUndefined();
|
||||||
|
expect(updated.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT copy dependencies", async () => {
|
||||||
|
const dep = await store.createTask({ description: "Dependency" });
|
||||||
|
const task = await store.createTask({ description: "Test task", dependencies: [dep.id] });
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.dependencies).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT copy attachments", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
// Add an attachment
|
||||||
|
await store.addAttachment(task.id, "test.png", Buffer.from("fake"), "image/png");
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.attachments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT copy steering comments", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.addComment(task.id, "Test comment");
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
// Comments should not be copied when duplicating
|
||||||
|
expect(duplicated.comments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits task:created event", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:created", (t) => events.push(t));
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(duplicated.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds log entry for duplicate action", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.log).toHaveLength(1);
|
||||||
|
expect(duplicated.log[0].action).toContain(`Duplicated from ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies source PROMPT.md content", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
const sourceDetail = await store.getTask(task.id);
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
const dupDetail = await store.getTask(duplicated.id);
|
||||||
|
|
||||||
|
expect(dupDetail.prompt).toBe(sourceDetail.prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOENT when source task does not exist", async () => {
|
||||||
|
await expect(store.duplicateTask("KB-999")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies title if present", async () => {
|
||||||
|
const task = await store.createTask({ title: "My Task", description: "Test" });
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.title).toBe("My Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT copy prInfo", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.updatePrInfo(task.id, {
|
||||||
|
url: "https://github.com/owner/repo/pull/1",
|
||||||
|
number: 1,
|
||||||
|
status: "open",
|
||||||
|
title: "Test PR",
|
||||||
|
headBranch: "fusion/fn-001",
|
||||||
|
baseBranch: "main",
|
||||||
|
commentCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.prInfo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT copy paused state", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.paused).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT copy blockedBy", async () => {
|
||||||
|
const blocker = await store.createTask({ description: "Blocker" });
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.updateTask(task.id, { blockedBy: blocker.id });
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.blockedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies baseBranch", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
await store.updateTask(task.id, { baseBranch: "some-branch" });
|
||||||
|
|
||||||
|
const duplicated = await store.duplicateTask(task.id);
|
||||||
|
|
||||||
|
expect(duplicated.baseBranch).toBe("some-branch");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Refine Task Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("refineTask", () => {
|
||||||
|
it("creates refinement from done task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 refined = await store.refineTask(task.id, "Need to fix edge case");
|
||||||
|
|
||||||
|
expect(refined.id).not.toBe(task.id);
|
||||||
|
expect(refined.id).toMatch(/^FN-\d+$/);
|
||||||
|
expect(refined.column).toBe("triage");
|
||||||
|
// Untitled source: uses first line of description as readable label
|
||||||
|
expect(refined.title).toBe("Refinement: Original task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates refinement from in-review task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.column).toBe("triage");
|
||||||
|
// Untitled source: uses first line of description as readable label
|
||||||
|
expect(refined.title).toBe("Refinement: Original task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when refining task in triage", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
// Task starts in triage
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when refining task in todo", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when refining task in in-progress", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when feedback is empty", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 expect(store.refineTask(task.id, "")).rejects.toThrow("Feedback is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when feedback is whitespace only", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 expect(store.refineTask(task.id, " ")).rejects.toThrow("Feedback is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets correct title format with original title", async () => {
|
||||||
|
const task = await store.createTask({ title: "My Feature", description: "Original 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 refined = await store.refineTask(task.id, "Add more tests");
|
||||||
|
|
||||||
|
expect(refined.title).toBe("Refinement: My Feature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets correct title format without original title (uses description fallback)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Fix the login bug" });
|
||||||
|
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 refined = await store.refineTask(task.id, "Add more tests");
|
||||||
|
|
||||||
|
// Falls back to first line of description when no title
|
||||||
|
expect(refined.title).toBe("Refinement: Fix the login bug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("description includes feedback and refines reference", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 refined = await store.refineTask(task.id, "Fix the edge case handling");
|
||||||
|
|
||||||
|
expect(refined.description).toBe(`Fix the edge case handling\n\nRefines: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets dependency on original task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.dependencies).toEqual([task.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds log entry for refinement creation", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.log).toHaveLength(1);
|
||||||
|
expect(refined.log[0].action).toBe(`Created as refinement of ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits task:created event", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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:created", (t) => events.push(t));
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].id).toBe(refined.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies attachments from original task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 attachment
|
||||||
|
await store.addAttachment(task.id, "test.png", Buffer.from("fake image"), "image/png");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.attachments).toHaveLength(1);
|
||||||
|
expect(refined.attachments![0].originalName).toBe("test.png");
|
||||||
|
expect(refined.attachments![0].mimeType).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies attachment files to new task directory", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 attachment
|
||||||
|
await store.addAttachment(task.id, "test.png", Buffer.from("fake image data"), "image/png");
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
// Verify file exists in new task directory
|
||||||
|
const attachDir = join(rootDir, ".fusion", "tasks", refined.id, "attachments");
|
||||||
|
const files = await readdir(attachDir);
|
||||||
|
expect(files.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify content was copied
|
||||||
|
const content = await readFile(join(attachDir, files[0]));
|
||||||
|
expect(content.toString()).toBe("fake image data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when source has no attachments", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.attachments).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets execution state (no steps, no worktree, etc.)", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
expect(refined.steps).toEqual([]);
|
||||||
|
expect(refined.currentStep).toBe(0);
|
||||||
|
expect(refined.worktree).toBeUndefined();
|
||||||
|
expect(refined.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates PROMPT.md for the refinement", async () => {
|
||||||
|
const task = await store.createTask({ description: "Original 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 refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
|
||||||
|
const detail = await store.getTask(refined.id);
|
||||||
|
// Untitled source: uses first line of description
|
||||||
|
expect(detail.prompt).toContain("Refinement: Original task");
|
||||||
|
expect(detail.prompt).toContain("Need improvements");
|
||||||
|
expect(detail.prompt).toContain(`Refines: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses first non-empty line of description when title is absent", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Use source task labels for refinement titles\n\nThis is a longer description.",
|
||||||
|
});
|
||||||
|
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 refined = await store.refineTask(task.id, "Add more tests");
|
||||||
|
|
||||||
|
expect(refined.title).toBe("Refinement: Use source task labels for refinement titles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses internal whitespace in description fallback", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Fix the \t spacing issue in UI",
|
||||||
|
});
|
||||||
|
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 refined = await store.refineTask(task.id, "More feedback");
|
||||||
|
|
||||||
|
expect(refined.title).toBe("Refinement: Fix the spacing issue in UI");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips leading blank lines in multi-line description", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "\n \n \nFirst real line of description\nSecond line",
|
||||||
|
});
|
||||||
|
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 refined = await store.refineTask(task.id, "Feedback");
|
||||||
|
|
||||||
|
expect(refined.title).toBe("Refinement: First real line of description");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to task ID when description has no non-empty lines", async () => {
|
||||||
|
// Create a task with a valid description, then update to all-whitespace
|
||||||
|
// (createTask rejects all-whitespace descriptions, but updates could produce this edge case)
|
||||||
|
const task = await store.createTask({ description: "Valid description" });
|
||||||
|
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.updateTask(task.id, { description: " \n \n\t\n" });
|
||||||
|
|
||||||
|
const refined = await store.refineTask(task.id, "Feedback");
|
||||||
|
|
||||||
|
expect(refined.title).toBe(`Refinement: ${task.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PROMPT.md heading matches the refinement title", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
title: "My Feature",
|
||||||
|
description: "Some description",
|
||||||
|
});
|
||||||
|
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 refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
const detail = await store.getTask(refined.id);
|
||||||
|
|
||||||
|
expect(refined.title).toBe("Refinement: My Feature");
|
||||||
|
expect(detail.prompt).toMatch(/^# Refinement: My Feature\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PROMPT.md heading uses description fallback when untitled", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "Fix the login bug on settings page",
|
||||||
|
});
|
||||||
|
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 refined = await store.refineTask(task.id, "Need improvements");
|
||||||
|
const detail = await store.getTask(refined.id);
|
||||||
|
|
||||||
|
expect(refined.title).toBe("Refinement: Fix the login bug on settings page");
|
||||||
|
expect(detail.prompt).toMatch(/^# Refinement: Fix the login bug on settings page\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ENOENT when source task does not exist", async () => {
|
||||||
|
await expect(store.refineTask("KB-999", "Feedback")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── Archive/Unarchive Tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("branch cleanup on delete and archive", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedExecSync.mockClear();
|
||||||
|
mockedRunCommandAsync.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedExecSync.mockImplementation(
|
||||||
|
(...args: Parameters<typeof execSync>) => {
|
||||||
|
// Restore pass-through to real implementation
|
||||||
|
const { execSync: realExecSync } = require("node:child_process");
|
||||||
|
return realExecSync(...args);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mockedRunCommandAsync.mockImplementation((...args: Parameters<typeof runCommandAsync>) =>
|
||||||
|
vi.importActual<typeof import("../run-command.js")>("../run-command.js").then((mod) =>
|
||||||
|
mod.runCommandAsync(...args),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask attempts branch cleanup via cleanupBranchForTask", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
// Mock: verify succeeds, delete succeeds
|
||||||
|
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
|
||||||
|
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
|
||||||
|
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||||
|
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||||
|
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||||
|
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask cleans up stored branch and derived branch when set", async () => {
|
||||||
|
const task = await store.createTask({ description: "Branch test" });
|
||||||
|
await store.updateTask(task.id, { branch: "fusion/my-custom-branch" });
|
||||||
|
|
||||||
|
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
|
||||||
|
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
|
||||||
|
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||||
|
|
||||||
|
// Should verify and delete both stored and derived branches
|
||||||
|
const customBranchVerify = calls.filter((c) => c.includes(`git rev-parse --verify "fusion/my-custom-branch"`));
|
||||||
|
const customBranchDelete = calls.filter((c) => c.includes(`git branch -D "fusion/my-custom-branch"`));
|
||||||
|
const derivedBranchVerify = calls.filter((c) => c.includes(`git rev-parse --verify "fusion/${task.id.toLowerCase()}"`));
|
||||||
|
const derivedBranchDelete = calls.filter((c) => c.includes(`git branch -D "fusion/${task.id.toLowerCase()}"`));
|
||||||
|
expect(customBranchVerify.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(customBranchDelete.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(derivedBranchVerify.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(derivedBranchDelete.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask succeeds even when branch cleanup fails", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
mockedRunCommandAsync.mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "not a git repo",
|
||||||
|
exitCode: 128,
|
||||||
|
signal: null,
|
||||||
|
bufferExceeded: false,
|
||||||
|
timedOut: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await store.deleteTask(task.id);
|
||||||
|
expect(deleted.id).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archiveTask with cleanup attempts branch cleanup", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
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");
|
||||||
|
|
||||||
|
mockedRunCommandAsync.mockImplementation(async (cmd: string) => {
|
||||||
|
if (cmd.includes("git rev-parse --verify") || cmd.includes("git branch -D")) {
|
||||||
|
return { stdout: "", stderr: "", exitCode: 0, signal: null, bufferExceeded: false, timedOut: false };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected runCommandAsync call: ${cmd}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.archiveTask(task.id, true);
|
||||||
|
|
||||||
|
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||||
|
const verifyCalls = calls.filter((c) => c.includes("git rev-parse --verify") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||||
|
const deleteCalls = calls.filter((c) => c.includes("git branch -D") && c.includes(`fusion/${task.id.toLowerCase()}`));
|
||||||
|
expect(verifyCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("archiveTask without cleanup does NOT attempt branch cleanup", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
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");
|
||||||
|
|
||||||
|
mockedRunCommandAsync.mockClear();
|
||||||
|
|
||||||
|
await store.archiveTask(task.id, false);
|
||||||
|
|
||||||
|
const calls = mockedRunCommandAsync.mock.calls.map((c) => c[0] as string);
|
||||||
|
const branchCommands = calls.filter((c) => c.includes("git branch -D") || c.includes("git rev-parse --verify"));
|
||||||
|
expect(branchCommands).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("project memory bootstrap", () => {
|
||||||
|
it("creates .fusion/memory/MEMORY.md on init when memoryEnabled is default (true)", async () => {
|
||||||
|
const memoryPath = join(rootDir, ".fusion", "memory", "MEMORY.md");
|
||||||
|
expect(existsSync(memoryPath)).toBe(true);
|
||||||
|
|
||||||
|
const content = await readFile(memoryPath, "utf-8");
|
||||||
|
expect(content).toContain("# Project Memory");
|
||||||
|
expect(content).toContain("## Architecture");
|
||||||
|
expect(content).toContain("## Conventions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create .fusion/memory/MEMORY.md when memoryEnabled is false after re-init", async () => {
|
||||||
|
const localRoot = makeTmpDir();
|
||||||
|
const localGlobal = makeTmpDir();
|
||||||
|
let localStore: TaskStore | undefined;
|
||||||
|
let secondStore: TaskStore | undefined;
|
||||||
|
try {
|
||||||
|
localStore = new TaskStore(localRoot, localGlobal);
|
||||||
|
await localStore.init();
|
||||||
|
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||||
|
|
||||||
|
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
|
||||||
|
if (existsSync(memoryPath)) {
|
||||||
|
await unlink(memoryPath);
|
||||||
|
}
|
||||||
|
expect(existsSync(memoryPath)).toBe(false);
|
||||||
|
|
||||||
|
localStore.close();
|
||||||
|
localStore = undefined;
|
||||||
|
|
||||||
|
secondStore = new TaskStore(localRoot, localGlobal);
|
||||||
|
await secondStore.init();
|
||||||
|
|
||||||
|
expect(existsSync(memoryPath)).toBe(false);
|
||||||
|
} finally {
|
||||||
|
secondStore?.close();
|
||||||
|
localStore?.close();
|
||||||
|
await rm(localRoot, { recursive: true, force: true });
|
||||||
|
await rm(localGlobal, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates .fusion/memory/MEMORY.md when memory is toggled on via updateSettings", async () => {
|
||||||
|
const localRoot = makeTmpDir();
|
||||||
|
const localGlobal = makeTmpDir();
|
||||||
|
let localStore: TaskStore | undefined;
|
||||||
|
try {
|
||||||
|
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
|
||||||
|
await localStore.init();
|
||||||
|
|
||||||
|
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||||
|
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
|
||||||
|
|
||||||
|
if (existsSync(memoryPath)) {
|
||||||
|
await unlink(memoryPath);
|
||||||
|
}
|
||||||
|
expect(existsSync(memoryPath)).toBe(false);
|
||||||
|
|
||||||
|
await localStore.updateSettings({ memoryEnabled: true } as any);
|
||||||
|
expect(existsSync(memoryPath)).toBe(true);
|
||||||
|
|
||||||
|
const content = await readFile(memoryPath, "utf-8");
|
||||||
|
expect(content).toContain("# Project Memory");
|
||||||
|
} finally {
|
||||||
|
localStore?.close();
|
||||||
|
await rm(localRoot, { recursive: true, force: true });
|
||||||
|
await rm(localGlobal, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite existing memory content when toggled on", async () => {
|
||||||
|
const localRoot = makeTmpDir();
|
||||||
|
const localGlobal = makeTmpDir();
|
||||||
|
let localStore: TaskStore | undefined;
|
||||||
|
try {
|
||||||
|
localStore = new TaskStore(localRoot, localGlobal, { inMemoryDb: true });
|
||||||
|
await localStore.init();
|
||||||
|
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
|
||||||
|
|
||||||
|
const customContent = "# My Custom Memory\n\nImportant stuff";
|
||||||
|
await writeFile(memoryPath, customContent, "utf-8");
|
||||||
|
|
||||||
|
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||||
|
await localStore.updateSettings({ memoryEnabled: true } as any);
|
||||||
|
|
||||||
|
const content = await readFile(memoryPath, "utf-8");
|
||||||
|
expect(content).toBe(customContent);
|
||||||
|
} finally {
|
||||||
|
localStore?.close();
|
||||||
|
await rm(localRoot, { recursive: true, force: true });
|
||||||
|
await rm(localGlobal, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe("research document key helper", () => {
|
||||||
|
it("builds canonical research document keys", () => {
|
||||||
|
expect(buildResearchDocumentKey("RR-1")).toBe("research-RR-1");
|
||||||
|
expect(buildResearchDocumentKey("RR/1")).toBe("research-RR1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects run IDs that sanitize to an empty string", () => {
|
||||||
|
expect(() => buildResearchDocumentKey("!!!")).toThrow("Invalid research run id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Title Handling Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
271
packages/core/src/__tests__/store-parsing.test.ts
Normal file
271
packages/core/src/__tests__/store-parsing.test.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("parseStepsFromPrompt", () => {
|
||||||
|
it("returns empty array when task directory is missing", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const steps = await store.parseStepsFromPrompt(task.id);
|
||||||
|
expect(steps).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("parseDependenciesFromPrompt", () => {
|
||||||
|
it("returns single dependency from PROMPT.md", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task with dep" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: Task with dep
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Task:** FN-001 (must be complete first)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check things
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = await store.parseDependenciesFromPrompt(task.id);
|
||||||
|
expect(deps).toEqual(["FN-001"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns multiple dependencies in order", async () => {
|
||||||
|
const task = await store.createTask({ description: "Task with deps" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: Task with deps
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **Task:** FN-010 (first dep)
|
||||||
|
- **Task:** FN-020 (second dep)
|
||||||
|
- **Task:** PROJ-003 (third dep)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check things
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = await store.parseDependenciesFromPrompt(task.id);
|
||||||
|
expect(deps).toEqual(["FN-010", "FN-020", "PROJ-003"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when dependencies section says None", async () => {
|
||||||
|
const task = await store.createTask({ description: "No deps" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: No deps
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **None**
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check things
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = await store.parseDependenciesFromPrompt(task.id);
|
||||||
|
expect(deps).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no Dependencies section exists", async () => {
|
||||||
|
const task = await store.createTask({ description: "No section" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: No section
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check things
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deps = await store.parseDependenciesFromPrompt(task.id);
|
||||||
|
expect(deps).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when task has no PROMPT.md file", async () => {
|
||||||
|
const task = await store.createTask({ description: "No prompt" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
// Delete the PROMPT.md that createTask generates
|
||||||
|
await unlink(join(dir, "PROMPT.md"));
|
||||||
|
|
||||||
|
const deps = await store.parseDependenciesFromPrompt(task.id);
|
||||||
|
expect(deps).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when task directory is missing", async () => {
|
||||||
|
const task = await store.createTask({ description: "No directory" });
|
||||||
|
await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const deps = await store.parseDependenciesFromPrompt(task.id);
|
||||||
|
expect(deps).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("parseFileScopeFromPrompt", () => {
|
||||||
|
it("returns paths when File Scope is followed by another heading", async () => {
|
||||||
|
const task = await store.createTask({ description: "Mid-file scope" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: Mid-file scope
|
||||||
|
|
||||||
|
## File Scope
|
||||||
|
|
||||||
|
- \`packages/core/src/store.ts\`
|
||||||
|
- \`packages/core/src/store.test.ts\`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check things
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const paths = await store.parseFileScopeFromPrompt(task.id);
|
||||||
|
expect(paths).toEqual([
|
||||||
|
"packages/core/src/store.ts",
|
||||||
|
"packages/core/src/store.test.ts",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all paths when File Scope is the last section", async () => {
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "End-of-file scope",
|
||||||
|
});
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: End-of-file scope
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check things
|
||||||
|
|
||||||
|
## File Scope
|
||||||
|
|
||||||
|
- \`packages/core/src/store.ts\`
|
||||||
|
- \`packages/core/src/store.test.ts\`
|
||||||
|
- \`packages/core/src/utils.ts\`
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const paths = await store.parseFileScopeFromPrompt(task.id);
|
||||||
|
expect(paths).toEqual([
|
||||||
|
"packages/core/src/store.ts",
|
||||||
|
"packages/core/src/store.test.ts",
|
||||||
|
"packages/core/src/utils.ts",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no File Scope section exists", async () => {
|
||||||
|
const task = await store.createTask({ description: "No scope" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: No scope
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check things
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const paths = await store.parseFileScopeFromPrompt(task.id);
|
||||||
|
expect(paths).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when PROMPT.md does not exist", async () => {
|
||||||
|
const task = await store.createTask({ description: "No prompt" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await unlink(join(dir, "PROMPT.md"));
|
||||||
|
|
||||||
|
const paths = await store.parseFileScopeFromPrompt(task.id);
|
||||||
|
expect(paths).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when task directory is missing", async () => {
|
||||||
|
const task = await store.createTask({ description: "No prompt directory" });
|
||||||
|
await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const paths = await store.parseFileScopeFromPrompt(task.id);
|
||||||
|
expect(paths).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles glob patterns in backtick-quoted paths", async () => {
|
||||||
|
const task = await store.createTask({ description: "Glob scope" });
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: Glob scope
|
||||||
|
|
||||||
|
## File Scope
|
||||||
|
|
||||||
|
- \`packages/core/*\`
|
||||||
|
- \`packages/cli/src/commands/dashboard.ts\`
|
||||||
|
- \`packages/engine/src/**/*.ts\`
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const paths = await store.parseFileScopeFromPrompt(task.id);
|
||||||
|
expect(paths).toEqual([
|
||||||
|
"packages/core/*",
|
||||||
|
"packages/cli/src/commands/dashboard.ts",
|
||||||
|
"packages/engine/src/**/*.ts",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
463
packages/core/src/__tests__/store-resilience.test.ts
Normal file
463
packages/core/src/__tests__/store-resilience.test.ts
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("write lock serialization", () => {
|
||||||
|
it("serializes concurrent logEntry and updateStep calls without corruption", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
const id = task.id;
|
||||||
|
|
||||||
|
// Fire 20 concurrent operations: 10 logEntry + 10 updateStep (alternating steps)
|
||||||
|
const promises: Promise<Task>[] = [];
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
promises.push(store.logEntry(id, `Log entry ${i}`));
|
||||||
|
} else {
|
||||||
|
// Toggle step 0 between in-progress and done
|
||||||
|
const status = i % 4 === 1 ? "in-progress" : "done";
|
||||||
|
promises.push(store.updateStep(id, 0, status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// Read back and verify valid JSON
|
||||||
|
const taskJsonPath = join(rootDir, ".fusion", "tasks", id, "task.json");
|
||||||
|
const raw = await readFile(taskJsonPath, "utf-8");
|
||||||
|
const result = JSON.parse(raw) as Task;
|
||||||
|
|
||||||
|
// Check all 10 log entries are present (plus initial "Task created" + step update logs)
|
||||||
|
const customLogs = result.log.filter((l) => l.action.startsWith("Log entry"));
|
||||||
|
expect(customLogs).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Defensive parsing test ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("defensive JSON parsing", () => {
|
||||||
|
it("reads from SQLite even if task.json on disk is corrupted", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json");
|
||||||
|
|
||||||
|
// Corrupt the file: append duplicate trailing content
|
||||||
|
const validJson = await readFile(taskJsonPath, "utf-8");
|
||||||
|
const corrupted = validJson + validJson.slice(validJson.length / 2);
|
||||||
|
await writeFile(taskJsonPath, corrupted);
|
||||||
|
|
||||||
|
// SQLite still has valid data — getTask should succeed
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.id).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads from SQLite even if task.json contains invalid content", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json");
|
||||||
|
|
||||||
|
// Write completely invalid content
|
||||||
|
await writeFile(taskJsonPath, "not json at all {{{");
|
||||||
|
|
||||||
|
// SQLite still has valid data — getTask should succeed
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.id).toBe(task.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Atomic write test ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("atomic writes", () => {
|
||||||
|
it("produces valid JSON after write with no .tmp files left behind", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
|
||||||
|
// Perform a write
|
||||||
|
await store.logEntry(task.id, "atomic test");
|
||||||
|
|
||||||
|
// Verify valid JSON
|
||||||
|
const raw = await readFile(join(dir, "task.json"), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as Task;
|
||||||
|
expect(parsed.log.some((l) => l.action === "atomic test")).toBe(true);
|
||||||
|
|
||||||
|
// Verify no .tmp files
|
||||||
|
const files = await readdir(dir);
|
||||||
|
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Atomic config writes ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
describe("atomic config writes", () => {
|
||||||
|
it("produces valid config.json with unique sequential IDs after 5 parallel createTask calls", async () => {
|
||||||
|
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||||
|
store.createTask({ description: `Concurrent task ${i}` }),
|
||||||
|
);
|
||||||
|
const tasks = await Promise.all(promises);
|
||||||
|
|
||||||
|
// All IDs should be unique
|
||||||
|
const ids = tasks.map((t) => t.id);
|
||||||
|
expect(new Set(ids).size).toBe(5);
|
||||||
|
|
||||||
|
// IDs should be sequential (FN-001 through FN-005)
|
||||||
|
const sortedIds = [...ids].sort();
|
||||||
|
expect(sortedIds).toEqual(["FN-001", "FN-002", "FN-003", "FN-004", "FN-005"]);
|
||||||
|
|
||||||
|
// config.json should be valid JSON with nextId = 6
|
||||||
|
const configPath = join(rootDir, ".fusion", "config.json");
|
||||||
|
const raw = await readFile(configPath, "utf-8");
|
||||||
|
const config = JSON.parse(raw);
|
||||||
|
expect(config.nextId).toBe(6);
|
||||||
|
|
||||||
|
// No .tmp files left behind
|
||||||
|
const haiDir = join(rootDir, ".fusion");
|
||||||
|
const files = await readdir(haiDir);
|
||||||
|
expect(files.filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Attachment tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe("concurrent stress", () => {
|
||||||
|
it("handles 10 parallel logEntry calls preserving all entries", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const initialLogCount = task.log.length; // 1 ("Task created")
|
||||||
|
|
||||||
|
const promises = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
store.logEntry(task.id, `Stress log ${i}`),
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const result = await store.getTask(task.id);
|
||||||
|
const stressLogs = result.log.filter((l) => l.action.startsWith("Stress log"));
|
||||||
|
expect(stressLogs).toHaveLength(10);
|
||||||
|
expect(result.log).toHaveLength(initialLogCount + 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("directory recreation for file-backed blobs", () => {
|
||||||
|
it("pauseTask recreates missing task directory before writing task.json", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const dir = await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const paused = await store.pauseTask(task.id, true);
|
||||||
|
|
||||||
|
expect(paused.paused).toBe(true);
|
||||||
|
expect(existsSync(dir)).toBe(true);
|
||||||
|
expect(existsSync(join(dir, "task.json"))).toBe(true);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateStep recreates missing task directory and persists regenerated task.json", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
const promptDir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
const prompt = await readFile(join(promptDir, "PROMPT.md"), "utf-8");
|
||||||
|
const dir = await deleteTaskDir(task.id);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(join(dir, "PROMPT.md"), prompt);
|
||||||
|
|
||||||
|
const updated = await store.updateStep(task.id, 0, "in-progress");
|
||||||
|
|
||||||
|
expect(updated.steps[0].status).toBe("in-progress");
|
||||||
|
expect(existsSync(dir)).toBe(true);
|
||||||
|
expect(existsSync(join(dir, "task.json"))).toBe(true);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.steps[0].status).toBe("in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves done/skipped steps when updateStep is called with in-progress", async () => {
|
||||||
|
const task = await createTaskWithSteps();
|
||||||
|
await store.updateStep(task.id, 0, "done");
|
||||||
|
await store.updateStep(task.id, 1, "done");
|
||||||
|
const beforeRegression = await store.getTask(task.id);
|
||||||
|
const currentStepBefore = beforeRegression.currentStep;
|
||||||
|
|
||||||
|
// Agent erroneously re-marks an already-done step as in-progress.
|
||||||
|
const result = await store.updateStep(task.id, 0, "in-progress");
|
||||||
|
|
||||||
|
expect(result.steps[0].status).toBe("done");
|
||||||
|
expect(result.steps[1].status).toBe("done");
|
||||||
|
expect(result.currentStep).toBe(currentStepBefore);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.steps[0].status).toBe("done");
|
||||||
|
expect(fetched.currentStep).toBe(currentStepBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addComment recreates missing task directory before persisting metadata", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const dir = await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const updated = await store.addComment(task.id, "Please recover from missing directory");
|
||||||
|
|
||||||
|
expect(updated.comments).toHaveLength(1);
|
||||||
|
expect(existsSync(dir)).toBe(true);
|
||||||
|
expect(existsSync(join(dir, "task.json"))).toBe(true);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.comments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addAttachment recreates missing task directory and attachment directory", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const dir = await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const attachment = await store.addAttachment(task.id, "note.txt", Buffer.from("hello"), "text/plain");
|
||||||
|
|
||||||
|
expect(existsSync(dir)).toBe(true);
|
||||||
|
expect(existsSync(join(dir, "attachments", attachment.filename))).toBe(true);
|
||||||
|
expect(existsSync(join(dir, "task.json"))).toBe(true);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.attachments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateTask recreates missing task directory before rewriting PROMPT.md", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const dir = await deleteTaskDir(task.id);
|
||||||
|
const prompt = "# KB-001\n\nRecovered prompt\n";
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, { title: "Recovered", prompt });
|
||||||
|
|
||||||
|
expect(updated.title).toBe("Recovered");
|
||||||
|
expect(existsSync(dir)).toBe(true);
|
||||||
|
expect(existsSync(join(dir, "PROMPT.md"))).toBe(true);
|
||||||
|
expect(await readFile(join(dir, "PROMPT.md"), "utf-8")).toBe(prompt);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.title).toBe("Recovered");
|
||||||
|
expect(fetched.prompt).toBe(prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicateTask recreates the new task directory before copying PROMPT.md", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
const duplicate = await store.duplicateTask(task.id);
|
||||||
|
const duplicateDir = join(rootDir, ".fusion", "tasks", duplicate.id);
|
||||||
|
|
||||||
|
expect(existsSync(duplicateDir)).toBe(true);
|
||||||
|
expect(existsSync(join(duplicateDir, "PROMPT.md"))).toBe(true);
|
||||||
|
expect(await readFile(join(duplicateDir, "PROMPT.md"), "utf-8")).toContain(task.description);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("pauseTask", () => {
|
||||||
|
it("sets paused flag to true and adds log entry", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const paused = await store.pauseTask(task.id, true);
|
||||||
|
|
||||||
|
expect(paused.paused).toBe(true);
|
||||||
|
expect(paused.log.some((l) => l.action === "Task paused")).toBe(true);
|
||||||
|
|
||||||
|
// Verify persistence
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpauses a paused task and clears paused flag", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
const unpaused = await store.pauseTask(task.id, false);
|
||||||
|
|
||||||
|
expect(unpaused.paused).toBeUndefined();
|
||||||
|
expect(unpaused.log.some((l) => l.action === "Task unpaused")).toBe(true);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.paused).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits task:updated event", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("task:updated", (t) => events.push(t));
|
||||||
|
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets status to 'paused' when pausing an in-progress task", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
// Move to in-progress: triage → todo → in-progress
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
const paused = await store.pauseTask(task.id, true);
|
||||||
|
expect(paused.paused).toBe(true);
|
||||||
|
expect(paused.status).toBe("paused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears status when unpausing an in-progress task", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
const unpaused = await store.pauseTask(task.id, false);
|
||||||
|
expect(unpaused.paused).toBeUndefined();
|
||||||
|
expect(unpaused.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets and clears paused status for in-review tasks", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
const paused = await store.pauseTask(task.id, true);
|
||||||
|
expect(paused.paused).toBe(true);
|
||||||
|
expect(paused.status).toBe("paused");
|
||||||
|
|
||||||
|
const unpaused = await store.pauseTask(task.id, false);
|
||||||
|
expect(unpaused.paused).toBeUndefined();
|
||||||
|
expect(unpaused.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips pause/unpause correctly", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
let fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.paused).toBe(true);
|
||||||
|
|
||||||
|
await store.pauseTask(task.id, false);
|
||||||
|
fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.paused).toBeUndefined();
|
||||||
|
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets pausedByAgentId and logs agent pause reason", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const paused = await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-1" });
|
||||||
|
|
||||||
|
expect(paused.pausedByAgentId).toBe("agent-1");
|
||||||
|
expect(paused.log.at(-1)?.action).toBe("Task paused (agent agent-1 paused)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears pausedByAgentId and logs agent resume reason", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.pauseTask(task.id, true, undefined, { pausedByAgentId: "agent-2" });
|
||||||
|
|
||||||
|
const unpaused = await store.pauseTask(task.id, false);
|
||||||
|
expect(unpaused.pausedByAgentId).toBeUndefined();
|
||||||
|
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused (agent agent-2 resumed)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses standard unpause log when task was not paused by an agent", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.pauseTask(task.id, true);
|
||||||
|
|
||||||
|
const unpaused = await store.pauseTask(task.id, false);
|
||||||
|
expect(unpaused.pausedByAgentId).toBeUndefined();
|
||||||
|
expect(unpaused.log.at(-1)?.action).toBe("Task unpaused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pausedByAgentId undefined when pausing without agent options", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const paused = await store.pauseTask(task.id, true);
|
||||||
|
|
||||||
|
expect(paused.pausedByAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("clearStaleExecutionStartBranchReferences (FN-2165)", () => {
|
||||||
|
it("nulls baseBranch on live tasks that reference a deleted branch", async () => {
|
||||||
|
const upstream = await store.createTask({ description: "Upstream" });
|
||||||
|
const dependent = await store.createTask({ description: "Dependent" });
|
||||||
|
await store.updateTask(dependent.id, {
|
||||||
|
executionStartBranch: `fusion/${upstream.id.toLowerCase()}-2`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleared = store.clearStaleExecutionStartBranchReferences([
|
||||||
|
`fusion/${upstream.id.toLowerCase()}-2`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(cleared).toEqual([dependent.id]);
|
||||||
|
const reloaded = await store.getTask(dependent.id);
|
||||||
|
expect(reloaded.executionStartBranch).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes the owner task so archival doesn't null its own baseBranch", async () => {
|
||||||
|
const upstream = await store.createTask({ description: "Upstream" });
|
||||||
|
await store.updateTask(upstream.id, { executionStartBranch: "fusion/some-base" });
|
||||||
|
|
||||||
|
const cleared = store.clearStaleExecutionStartBranchReferences(
|
||||||
|
["fusion/some-base"],
|
||||||
|
upstream.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cleared).toEqual([]);
|
||||||
|
const reloaded = await store.getTask(upstream.id);
|
||||||
|
expect(reloaded.executionStartBranch).toBe("fusion/some-base");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] and is a no-op when no branches given", () => {
|
||||||
|
expect(store.clearStaleExecutionStartBranchReferences([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears baseBranch on multiple dependents in one call", async () => {
|
||||||
|
const [a, b, c] = await Promise.all([
|
||||||
|
store.createTask({ description: "A" }),
|
||||||
|
store.createTask({ description: "B" }),
|
||||||
|
store.createTask({ description: "C" }),
|
||||||
|
]);
|
||||||
|
await store.updateTask(a.id, { executionStartBranch: "fusion/gone-a" });
|
||||||
|
await store.updateTask(b.id, { executionStartBranch: "fusion/gone-b" });
|
||||||
|
await store.updateTask(c.id, { executionStartBranch: "fusion/still-alive" });
|
||||||
|
|
||||||
|
const cleared = store.clearStaleExecutionStartBranchReferences([
|
||||||
|
"fusion/gone-a",
|
||||||
|
"fusion/gone-b",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(cleared.sort()).toEqual([a.id, b.id].sort());
|
||||||
|
const cReloaded = await store.getTask(c.id);
|
||||||
|
expect(cReloaded.executionStartBranch).toBe("fusion/still-alive");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
320
packages/core/src/__tests__/store-scheduling.test.ts
Normal file
320
packages/core/src/__tests__/store-scheduling.test.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("nodeId in-progress blocking", () => {
|
||||||
|
it("throws when updating nodeId on an in-progress task", async () => {
|
||||||
|
const task = await store.createTask({ description: "In progress task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await expect(store.updateTask(task.id, { nodeId: "node-abc" }))
|
||||||
|
.rejects.toThrow(/in progress/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows updating nodeId on a todo task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Todo task" });
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, { nodeId: "node-todo" });
|
||||||
|
expect(updated.nodeId).toBe("node-todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows updating nodeId on an in-review task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Review task" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
await store.moveTask(task.id, "in-review");
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, { nodeId: "node-review" });
|
||||||
|
expect(updated.nodeId).toBe("node-review");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows other updates on in-progress tasks (non-nodeId)", async () => {
|
||||||
|
const task = await store.createTask({ description: "In progress title update" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, { title: "Updated title" });
|
||||||
|
expect(updated.title).toBe("Updated title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows clearing nodeId on a done task", async () => {
|
||||||
|
const task = await store.createTask({ description: "Done task", nodeId: "node-done" });
|
||||||
|
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 updated = await store.updateTask(task.id, { nodeId: null });
|
||||||
|
expect(updated.nodeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw when nodeId update is undefined on an in-progress task", async () => {
|
||||||
|
const task = await store.createTask({ description: "In progress no-op", nodeId: "node-stable" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, { nodeId: undefined });
|
||||||
|
expect(updated.nodeId).toBe("node-stable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes task ID in nodeId override blocking error", async () => {
|
||||||
|
const task = await store.createTask({ description: "In progress blocked id" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
await expect(store.updateTask(task.id, { nodeId: "node-abc" })).rejects.toThrow(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows priority updates on in-progress tasks without changing existing nodeId", async () => {
|
||||||
|
const task = await store.createTask({ description: "In progress priority", nodeId: "node-keep" });
|
||||||
|
await store.moveTask(task.id, "todo");
|
||||||
|
await store.moveTask(task.id, "in-progress");
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, { priority: "high" });
|
||||||
|
expect(updated.priority).toBe("high");
|
||||||
|
expect(updated.nodeId).toBe("node-keep");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("getTasksByAssignedAgent", () => {
|
||||||
|
it("returns only tasks assigned to the requested agent", async () => {
|
||||||
|
const mine = await store.createTask({ description: "mine", assignedAgentId: "agent-1" });
|
||||||
|
await store.createTask({ description: "other", assignedAgentId: "agent-2" });
|
||||||
|
await store.createTask({ description: "unassigned" });
|
||||||
|
|
||||||
|
const tasks = await store.getTasksByAssignedAgent("agent-1");
|
||||||
|
expect(tasks.map((task) => task.id)).toEqual([mine.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports pausedOnly filter", async () => {
|
||||||
|
const paused = await store.createTask({ description: "paused", assignedAgentId: "agent-1" });
|
||||||
|
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
|
||||||
|
await store.updateTask(paused.id, { paused: true });
|
||||||
|
|
||||||
|
const tasks = await store.getTasksByAssignedAgent("agent-1", { pausedOnly: true });
|
||||||
|
expect(tasks.map((task) => task.id)).toEqual([paused.id]);
|
||||||
|
expect(tasks.some((task) => task.id === active.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports excludeArchived filter", async () => {
|
||||||
|
const active = await store.createTask({ description: "active", assignedAgentId: "agent-1" });
|
||||||
|
const archived = await store.createTask({ description: "archived", assignedAgentId: "agent-1", column: "done" });
|
||||||
|
await store.archiveTask(archived.id, false);
|
||||||
|
|
||||||
|
const tasks = await store.getTasksByAssignedAgent("agent-1", { excludeArchived: true });
|
||||||
|
expect(tasks.map((task) => task.id)).toEqual([active.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("selectNextTaskForAgent", () => {
|
||||||
|
it("returns null when no tasks exist", async () => {
|
||||||
|
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns in-progress task assigned to the agent", async () => {
|
||||||
|
const inProgress = await store.createTask({
|
||||||
|
description: "In-progress task",
|
||||||
|
column: "in-progress",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await store.selectNextTaskForAgent("agent-1");
|
||||||
|
|
||||||
|
expect(selected?.task.id).toBe(inProgress.id);
|
||||||
|
expect(selected?.priority).toBe("in_progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers in-progress over todo when both exist for the agent", async () => {
|
||||||
|
await store.createTask({
|
||||||
|
description: "Ready todo task",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
const inProgress = await store.createTask({
|
||||||
|
description: "In-progress task",
|
||||||
|
column: "in-progress",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await store.selectNextTaskForAgent("agent-1");
|
||||||
|
|
||||||
|
expect(selected?.task.id).toBe(inProgress.id);
|
||||||
|
expect(selected?.priority).toBe("in_progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns todo task with all dependencies done", async () => {
|
||||||
|
const dep = await store.createTask({ description: "Done dep", column: "done" });
|
||||||
|
const readyTodo = await store.createTask({
|
||||||
|
description: "Ready todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
dependencies: [dep.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await store.selectNextTaskForAgent("agent-1");
|
||||||
|
|
||||||
|
expect(selected?.task.id).toBe(readyTodo.id);
|
||||||
|
expect(selected?.priority).toBe("todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips todo task with unresolved dependencies that are not actionable", async () => {
|
||||||
|
const dep = await store.createTask({ description: "Unresolved dep", column: "todo" });
|
||||||
|
await store.createTask({
|
||||||
|
description: "Blocked todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
dependencies: [dep.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns blocked task with partially done dependencies when no higher-priority tasks exist", async () => {
|
||||||
|
const doneDep = await store.createTask({ description: "Done dep", column: "done" });
|
||||||
|
const blockedDep = await store.createTask({ description: "Blocked dep", column: "todo" });
|
||||||
|
const partiallyActionable = await store.createTask({
|
||||||
|
description: "Partially actionable todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
dependencies: [doneDep.id, blockedDep.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await store.selectNextTaskForAgent("agent-1");
|
||||||
|
|
||||||
|
expect(selected?.task.id).toBe(partiallyActionable.id);
|
||||||
|
expect(selected?.priority).toBe("blocked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips paused tasks", async () => {
|
||||||
|
const pausedTodo = await store.createTask({
|
||||||
|
description: "Paused todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
await store.updateTask(pausedTodo.id, { paused: true });
|
||||||
|
|
||||||
|
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips tasks assigned to a different agent", async () => {
|
||||||
|
await store.createTask({
|
||||||
|
description: "Other agent task",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(store.selectNextTaskForAgent("agent-1")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves FIFO ordering within the same priority tier", async () => {
|
||||||
|
const older = await store.createTask({
|
||||||
|
description: "Older ready todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await store.createTask({
|
||||||
|
description: "Newer ready todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await store.selectNextTaskForAgent("agent-1");
|
||||||
|
|
||||||
|
expect(selected?.task.id).toBe(older.id);
|
||||||
|
expect(selected?.priority).toBe("todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no tasks are assigned to the queried agent", async () => {
|
||||||
|
await store.createTask({
|
||||||
|
description: "Unassigned todo",
|
||||||
|
column: "todo",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(store.selectNextTaskForAgent("agent-without-tasks")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips implementation todos for non-executor role agents", async () => {
|
||||||
|
await store.createTask({
|
||||||
|
description: "Assigned todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.selectNextTaskForAgent("agent-1", { id: "agent-1", role: "reviewer" }),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns implementation todos for executor role agents", async () => {
|
||||||
|
const todo = await store.createTask({
|
||||||
|
description: "Assigned todo",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await store.selectNextTaskForAgent("agent-1", {
|
||||||
|
id: "agent-1",
|
||||||
|
role: "executor",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected?.task.id).toBe(todo.id);
|
||||||
|
expect(selected?.priority).toBe("todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows non-executor role agents to pick assigned todos when override metadata is set", async () => {
|
||||||
|
const delegated = await store.createTask({
|
||||||
|
description: "Assigned todo override",
|
||||||
|
column: "todo",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await store.selectNextTaskForAgent("agent-1", {
|
||||||
|
id: "agent-1",
|
||||||
|
role: "reviewer",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected?.task.id).toBe(delegated.id);
|
||||||
|
expect(selected?.priority).toBe("todo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lock serialization test ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
133
packages/core/src/__tests__/store-snapshots.test.ts
Normal file
133
packages/core/src/__tests__/store-snapshots.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("SQLite-first reads when task blobs are missing", () => {
|
||||||
|
it("getTask returns metadata from SQLite with an empty prompt when the task directory is missing", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
|
||||||
|
expect(fetched.id).toBe(task.id);
|
||||||
|
expect(fetched.description).toBe(task.description);
|
||||||
|
expect(fetched.prompt).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getTask syncs steps from PROMPT.md when task.steps is empty", async () => {
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
// task.steps should be empty in DB
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
await writeFile(
|
||||||
|
join(dir, "PROMPT.md"),
|
||||||
|
`# ${task.id}: Test task
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 0: Preflight
|
||||||
|
- [ ] Check something
|
||||||
|
|
||||||
|
### Step 1: Do the thing
|
||||||
|
- [ ] Do it
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const detail = await store.getTask(task.id);
|
||||||
|
expect(detail.steps).toEqual([
|
||||||
|
{ name: "Preflight", status: "pending" },
|
||||||
|
{ name: "Do the thing", status: "pending" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("shared mesh snapshots", () => {
|
||||||
|
it("persists and replicates extended lease metadata", async () => {
|
||||||
|
const task = await store.createTask({ description: "lease snapshot task" });
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
checkedOutBy: "agent-1",
|
||||||
|
checkedOutAt: "2026-05-01T00:00:00.000Z",
|
||||||
|
checkoutNodeId: "node-a",
|
||||||
|
checkoutRunId: "run-1",
|
||||||
|
checkoutLeaseRenewedAt: "2026-05-01T00:01:00.000Z",
|
||||||
|
checkoutLeaseEpoch: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await store.getTaskMetadataSnapshot();
|
||||||
|
const replicated = snapshot.payload.tasks.find((entry) => entry.id === task.id);
|
||||||
|
|
||||||
|
expect(replicated).toMatchObject({
|
||||||
|
checkedOutBy: "agent-1",
|
||||||
|
checkedOutAt: "2026-05-01T00:00:00.000Z",
|
||||||
|
checkoutNodeId: "node-a",
|
||||||
|
checkoutRunId: "run-1",
|
||||||
|
checkoutLeaseRenewedAt: "2026-05-01T00:01:00.000Z",
|
||||||
|
checkoutLeaseEpoch: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.updateTask(task.id, { checkedOutBy: null, checkoutLeaseEpoch: 8 });
|
||||||
|
const released = await store.getTask(task.id);
|
||||||
|
expect(released).toMatchObject({ checkedOutBy: undefined, checkoutLeaseEpoch: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports and reapplies task/activity/audit snapshots deterministically", async () => {
|
||||||
|
const task = await store.createTask({ description: "snapshot task" });
|
||||||
|
await store.updateTask(task.id, { worktree: "/tmp/fn-worktree", executionStartBranch: "fn/base" });
|
||||||
|
await store.recordActivity({ type: "task:created", taskId: task.id, details: "created" });
|
||||||
|
|
||||||
|
const taskSnapshot = await store.getTaskMetadataSnapshot();
|
||||||
|
const activitySnapshot = await store.getActivityLogSnapshot();
|
||||||
|
const auditSnapshot = store.getRunAuditSnapshot();
|
||||||
|
|
||||||
|
const taskResult = await store.applyTaskMetadataSnapshot(taskSnapshot);
|
||||||
|
const activityResult = store.applyActivityLogSnapshot(activitySnapshot);
|
||||||
|
const auditResult = store.applyRunAuditSnapshot(auditSnapshot);
|
||||||
|
|
||||||
|
const taskSnapshot2 = await store.getTaskMetadataSnapshot();
|
||||||
|
const activitySnapshot2 = await store.getActivityLogSnapshot();
|
||||||
|
const auditSnapshot2 = store.getRunAuditSnapshot();
|
||||||
|
|
||||||
|
expect(taskResult.applied + taskResult.skipped).toBeGreaterThan(0);
|
||||||
|
expect(taskSnapshot2.payload).toEqual(taskSnapshot.payload);
|
||||||
|
expect(activitySnapshot2.payload).toEqual(activitySnapshot.payload);
|
||||||
|
expect(auditSnapshot2.payload).toEqual(auditSnapshot.payload);
|
||||||
|
expect(activityResult.skipped).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(auditResult.skipped).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
const persisted = await store.getTask(task.id);
|
||||||
|
expect(persisted?.worktree).toBe("/tmp/fn-worktree");
|
||||||
|
expect(persisted?.executionStartBranch).toBe("fn/base");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,10 +3,34 @@ import { rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { TaskStore } from "../store.js";
|
|
||||||
|
vi.mock("node:child_process", async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import("node:child_process")>();
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
execSync: vi.fn((...args: Parameters<typeof mod.execSync>) => mod.execSync(...args)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../run-command.js", async (importOriginal) => {
|
||||||
|
const mod = await importOriginal<typeof import("../run-command.js")>();
|
||||||
|
return {
|
||||||
|
...mod,
|
||||||
|
runCommandAsync: vi.fn((...args: Parameters<typeof mod.runCommandAsync>) => mod.runCommandAsync(...args)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { runCommandAsync } from "../run-command.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
import type { Task } from "../types.js";
|
import type { Task } from "../types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
export { TaskStore, TaskHasDependentsError };
|
||||||
|
|
||||||
|
export const mockedExecSync = vi.mocked(execSync);
|
||||||
|
export const mockedRunCommandAsync = vi.mocked(runCommandAsync);
|
||||||
|
|
||||||
|
export function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
|
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,15 +99,22 @@ export function createTaskStoreTestHarness() {
|
|||||||
issueNumber: 2471,
|
issueNumber: 2471,
|
||||||
url: "https://github.com/runfusion/fusion/issues/2471",
|
url: "https://github.com/runfusion/fusion/issues/2471",
|
||||||
}),
|
}),
|
||||||
insertLogEntryWithTimestamp: (
|
insertLogEntryWithTimestamp: (...args: any[]): void => {
|
||||||
taskId: string,
|
let targetStore: TaskStore = store;
|
||||||
text: string,
|
let taskId: string;
|
||||||
type: string,
|
let text: string;
|
||||||
timestamp: string,
|
let type: string;
|
||||||
detail?: string,
|
let timestamp: string;
|
||||||
agent?: string,
|
let detail: string | undefined;
|
||||||
): void => {
|
let agent: string | undefined;
|
||||||
(store as any).db.prepare(`
|
|
||||||
|
if (typeof args[0] === "object") {
|
||||||
|
[targetStore, taskId, text, type, timestamp, detail, agent] = args;
|
||||||
|
} else {
|
||||||
|
[taskId, text, type, timestamp, detail, agent] = args;
|
||||||
|
}
|
||||||
|
|
||||||
|
(targetStore as any).db.prepare(`
|
||||||
INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent)
|
INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(taskId, timestamp, text, type, detail ?? null, agent ?? null);
|
`).run(taskId, timestamp, text, type, detail ?? null, agent ?? null);
|
||||||
|
|||||||
1102
packages/core/src/__tests__/store-update.test.ts
Normal file
1102
packages/core/src/__tests__/store-update.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
829
packages/core/src/__tests__/store-upsert.test.ts
Normal file
829
packages/core/src/__tests__/store-upsert.test.ts
Normal file
@@ -0,0 +1,829 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import * as projectMemory from "../project-memory.js";
|
||||||
|
import { AgentStore } from "../agent-store.js";
|
||||||
|
import { CentralDatabase } from "../central-db.js";
|
||||||
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
|
import { buildResearchDocumentKey, type Task } from "../types.js";
|
||||||
|
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
rootDir = harness.rootDir();
|
||||||
|
globalDir = harness.globalDir();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestTask = () => harness.createTestTask();
|
||||||
|
const createTaskWithSteps = () => harness.createTaskWithSteps();
|
||||||
|
const deleteTaskDir = (taskId: string) => harness.deleteTaskDir(taskId);
|
||||||
|
const createSourceIssueFixture = () => harness.createSourceIssueFixture();
|
||||||
|
const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args);
|
||||||
|
|
||||||
|
describe("upsertTask regression coverage", () => {
|
||||||
|
it("creates tasks successfully on a fresh database schema", async () => {
|
||||||
|
const freshRoot = makeTmpDir();
|
||||||
|
const freshGlobal = makeTmpDir();
|
||||||
|
const freshStore = new TaskStore(freshRoot, freshGlobal);
|
||||||
|
await freshStore.init();
|
||||||
|
|
||||||
|
const task = await freshStore.createTask({ description: "fresh schema task" });
|
||||||
|
expect(task.id).toBe("FN-001");
|
||||||
|
expect(await freshStore.getTask(task.id)).toBeDefined();
|
||||||
|
|
||||||
|
freshStore.close();
|
||||||
|
await rm(freshRoot, { recursive: true, force: true });
|
||||||
|
await rm(freshGlobal, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists createTask with nullable, array, and optional scalar fields", async () => {
|
||||||
|
const created = await store.createTask({
|
||||||
|
title: "Persist me",
|
||||||
|
description: "Create path coverage",
|
||||||
|
column: "todo",
|
||||||
|
dependencies: ["FN-999"],
|
||||||
|
enabledWorkflowSteps: ["WS-001"],
|
||||||
|
modelProvider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-5",
|
||||||
|
validatorModelProvider: "openai",
|
||||||
|
validatorModelId: "gpt-4o",
|
||||||
|
modelPresetId: "normal",
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await store.getTask(created.id);
|
||||||
|
expect(persisted.title).toBe("Persist me");
|
||||||
|
expect(persisted.column).toBe("todo");
|
||||||
|
expect(persisted.dependencies).toEqual(["FN-999"]);
|
||||||
|
expect(persisted.enabledWorkflowSteps).toEqual(["WS-001"]);
|
||||||
|
expect(persisted.modelProvider).toBe("anthropic");
|
||||||
|
expect(persisted.validatorModelProvider).toBe("openai");
|
||||||
|
expect(persisted.modelPresetId).toBe("normal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists updateTask changes across scalar, array, and nullable JSON-backed fields", async () => {
|
||||||
|
const task = await store.createTask({ description: "Update path coverage" });
|
||||||
|
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
title: "Updated title",
|
||||||
|
dependencies: ["FN-002", "FN-003"],
|
||||||
|
blockedBy: "FN-002",
|
||||||
|
status: "failed",
|
||||||
|
error: "boom",
|
||||||
|
summary: "summary",
|
||||||
|
workflowStepResults: [
|
||||||
|
{
|
||||||
|
workflowStepId: "WS-001",
|
||||||
|
workflowStepName: "QA",
|
||||||
|
status: "passed",
|
||||||
|
startedAt: "2026-04-01T00:00:00.000Z",
|
||||||
|
completedAt: "2026-04-01T00:01:00.000Z",
|
||||||
|
output: "ok",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modifiedFiles: ["packages/core/src/store.ts"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await store.getTask(task.id);
|
||||||
|
expect(persisted.title).toBe("Updated title");
|
||||||
|
expect(persisted.dependencies).toEqual(["FN-002", "FN-003"]);
|
||||||
|
expect(persisted.blockedBy).toBe("FN-002");
|
||||||
|
expect(persisted.status).toBe("failed");
|
||||||
|
expect(persisted.error).toBe("boom");
|
||||||
|
expect(persisted.summary).toBe("summary");
|
||||||
|
expect(persisted.workflowStepResults).toHaveLength(1);
|
||||||
|
expect(persisted.workflowStepResults?.[0].workflowStepId).toBe("WS-001");
|
||||||
|
expect(persisted.modifiedFiles).toEqual(["packages/core/src/store.ts"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("agent log persistence", () => {
|
||||||
|
it("appendAgentLog inserts into agentLogEntries and getAgentLogs reads it back", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "Hello world", "text");
|
||||||
|
await store.appendAgentLog(task.id, "Read", "tool");
|
||||||
|
(store as any).flushAgentLogBuffer();
|
||||||
|
|
||||||
|
const rows = (store as any).db.prepare(`
|
||||||
|
SELECT taskId, text, type FROM agentLogEntries
|
||||||
|
WHERE taskId = ?
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
`).all(task.id) as Array<{ taskId: string; text: string; type: string }>;
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ taskId: task.id, text: "Hello world", type: "text" },
|
||||||
|
{ taskId: task.id, text: "Read", type: "tool" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(2);
|
||||||
|
expect(logs[0].text).toBe("Hello world");
|
||||||
|
expect(logs[0].type).toBe("text");
|
||||||
|
expect(logs[0].taskId).toBe(task.id);
|
||||||
|
expect(logs[1].text).toBe("Read");
|
||||||
|
expect(logs[1].type).toBe("tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAgentLogs returns empty array when no log entries exist", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAgentLogs returns empty array when task directory is missing", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await deleteTaskDir(task.id);
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendAgentLog emits agent:log event", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("agent:log", (entry) => events.push(entry));
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "delta text", "text");
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].text).toBe("delta text");
|
||||||
|
expect(events[0].type).toBe("text");
|
||||||
|
expect(events[0].taskId).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendAgentLogBatch inserts all entries and emits per-entry events", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("agent:log", (entry) => events.push(entry));
|
||||||
|
|
||||||
|
await store.appendAgentLogBatch([
|
||||||
|
{ taskId: task.id, text: "batch 1", type: "text" },
|
||||||
|
{ taskId: task.id, text: "tool", type: "tool", detail: "read file", agent: "executor" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(2);
|
||||||
|
expect(logs.map((entry) => entry.text)).toEqual(["batch 1", "tool"]);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[1]).toMatchObject({ text: "tool", type: "tool", detail: "read file", agent: "executor" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates oversized tool detail before persisting and emitting", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
const oversizedDetail = "X".repeat(5000);
|
||||||
|
const truncationMarker = "[tool output truncated to keep dashboard log views responsive]";
|
||||||
|
store.on("agent:log", (entry) => events.push(entry));
|
||||||
|
|
||||||
|
await store.appendAgentLogBatch([
|
||||||
|
{ taskId: task.id, text: "Bash", type: "tool_result", detail: oversizedDetail, agent: "executor" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].detail).toContain(truncationMarker);
|
||||||
|
expect(logs[0].detail!.match(/\[tool output truncated to keep dashboard log views responsive\]/g)).toHaveLength(1);
|
||||||
|
expect(logs[0].detail!.length).toBeLessThan(oversizedDetail.length);
|
||||||
|
expect(events[0].detail).toBe(logs[0].detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendAgentLogBatch with empty entries is a no-op", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLogBatch([]);
|
||||||
|
|
||||||
|
expect(await store.getAgentLogCount(task.id)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendAgentLog writes detail when provided", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "Bash", "tool", "ls -la");
|
||||||
|
await store.appendAgentLog(task.id, "Read", "tool", "packages/core/src/types.ts");
|
||||||
|
await store.appendAgentLog(task.id, "some text", "text");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(3);
|
||||||
|
expect(logs[0].detail).toBe("ls -la");
|
||||||
|
expect(logs[1].detail).toBe("packages/core/src/types.ts");
|
||||||
|
expect(logs[2].detail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendAgentLog omits detail field when not provided", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "Bash", "tool");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0]).not.toHaveProperty("detail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple appends correctly", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
|
||||||
|
}
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(5);
|
||||||
|
expect(logs[0].text).toBe("chunk 0");
|
||||||
|
expect(logs[4].text).toBe("chunk 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAgentLogCount returns the number of persisted log entries", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
expect(await store.getAgentLogCount(task.id)).toBe(0);
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "chunk 0", "text");
|
||||||
|
await store.appendAgentLog(task.id, "chunk 1", "tool");
|
||||||
|
|
||||||
|
expect(await store.getAgentLogCount(task.id)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the most recent agent log entries from SQLite in chronological order", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id, { limit: 2 });
|
||||||
|
expect(logs.map((entry) => entry.text)).toEqual(["chunk 3", "chunk 4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns older agent log pages when offset skips recent entries", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([
|
||||||
|
{ text: "chunk 3" },
|
||||||
|
{ text: "chunk 4" },
|
||||||
|
]);
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([
|
||||||
|
{ text: "chunk 1" },
|
||||||
|
{ text: "chunk 2" },
|
||||||
|
]);
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 4 })).resolves.toMatchObject([
|
||||||
|
{ text: "chunk 0" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves insertion order when multiple entries share the same timestamp", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
|
||||||
|
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs.map((entry) => entry.text)).toEqual([
|
||||||
|
"first tied",
|
||||||
|
"second tied",
|
||||||
|
"third tied",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies deterministic ordering for tied timestamps with limit/offset pagination", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
|
||||||
|
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "fourth tied", "text", tiedTimestamp);
|
||||||
|
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([
|
||||||
|
{ text: "third tied" },
|
||||||
|
{ text: "fourth tied" },
|
||||||
|
]);
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 1 })).resolves.toMatchObject([
|
||||||
|
{ text: "second tied" },
|
||||||
|
{ text: "third tied" },
|
||||||
|
]);
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([
|
||||||
|
{ text: "first tied" },
|
||||||
|
{ text: "second tied" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves long entry fields when returning a bounded tail", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const longText = [
|
||||||
|
"## Long Tail Entry",
|
||||||
|
"",
|
||||||
|
"This entry should survive a bounded tail read in full.",
|
||||||
|
"Z".repeat(800),
|
||||||
|
].join("\n");
|
||||||
|
const longDetail = "detail/".repeat(120) + "AgentLogViewer.tsx";
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "older entry", "text");
|
||||||
|
await store.appendAgentLog(task.id, longText, "tool", longDetail, "executor");
|
||||||
|
await store.appendAgentLog(task.id, "newest entry", "text");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id, { limit: 2 });
|
||||||
|
|
||||||
|
expect(logs.map((entry) => entry.text)).toEqual([longText, "newest entry"]);
|
||||||
|
expect(logs[0].detail).toBe(longDetail);
|
||||||
|
expect(logs[0].agent).toBe("executor");
|
||||||
|
expect(logs[0].text.length).toBe(longText.length);
|
||||||
|
expect(logs[0].detail!.length).toBe(longDetail.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clips oversized historical tool detail at read time", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const oversizedDetail = "Y".repeat(7000);
|
||||||
|
const truncationMarker = "[tool output truncated to keep dashboard log views responsive]";
|
||||||
|
|
||||||
|
insertLogEntryWithTimestamp(
|
||||||
|
store,
|
||||||
|
task.id,
|
||||||
|
"Bash",
|
||||||
|
"tool_result",
|
||||||
|
"2026-04-24T12:00:00.000Z",
|
||||||
|
oversizedDetail,
|
||||||
|
"executor",
|
||||||
|
);
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].detail).toContain(truncationMarker);
|
||||||
|
expect(logs[0].detail!.match(/\[tool output truncated to keep dashboard log views responsive\]/g)).toHaveLength(1);
|
||||||
|
expect(logs[0].detail!.length).toBeLessThan(oversizedDetail.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendAgentLog persists and reads back the agent field", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "hello", "text", undefined, "executor");
|
||||||
|
await store.appendAgentLog(task.id, "Read", "tool", "file.ts", "triage");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(2);
|
||||||
|
expect(logs[0].agent).toBe("executor");
|
||||||
|
expect(logs[1].agent).toBe("triage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appendAgentLog omits agent field when not provided", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "hello", "text");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0]).not.toHaveProperty("agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("new type values (thinking, tool_result, tool_error) round-trip correctly", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "internal thought", "thinking", undefined, "executor");
|
||||||
|
await store.appendAgentLog(task.id, "Bash", "tool_result", "output summary", "executor");
|
||||||
|
await store.appendAgentLog(task.id, "Read", "tool_error", "file not found", "reviewer");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(3);
|
||||||
|
|
||||||
|
expect(logs[0].type).toBe("thinking");
|
||||||
|
expect(logs[0].text).toBe("internal thought");
|
||||||
|
expect(logs[0].agent).toBe("executor");
|
||||||
|
|
||||||
|
expect(logs[1].type).toBe("tool_result");
|
||||||
|
expect(logs[1].text).toBe("Bash");
|
||||||
|
expect(logs[1].detail).toBe("output summary");
|
||||||
|
|
||||||
|
expect(logs[2].type).toBe("tool_error");
|
||||||
|
expect(logs[2].text).toBe("Read");
|
||||||
|
expect(logs[2].detail).toBe("file not found");
|
||||||
|
expect(logs[2].agent).toBe("reviewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves long multiline text without truncation", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const longText = [
|
||||||
|
"## Analysis",
|
||||||
|
"",
|
||||||
|
"After reviewing the codebase, I found several issues:",
|
||||||
|
"",
|
||||||
|
"1. The first issue is that the function `processData` does not handle",
|
||||||
|
" edge cases where the input array is empty. This can cause unexpected",
|
||||||
|
" behavior downstream when consumers expect at least one element.",
|
||||||
|
"",
|
||||||
|
"2. The second issue relates to the caching layer. The TTL is set to",
|
||||||
|
" a very low value (60 seconds) which causes excessive cache misses.",
|
||||||
|
"",
|
||||||
|
"```typescript",
|
||||||
|
"function processData(data: unknown[]): Result {",
|
||||||
|
" // This is a very long code block that should not be truncated",
|
||||||
|
" if (!data || data.length === 0) {",
|
||||||
|
" throw new Error('Data array must not be empty');",
|
||||||
|
" }",
|
||||||
|
" return data.map(item => transform(item)).filter(Boolean);",
|
||||||
|
"}",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"Line " + "A".repeat(500) + " end of long line",
|
||||||
|
].join("\n");
|
||||||
|
// Total length should be well over 1000 characters
|
||||||
|
expect(longText.length).toBeGreaterThan(1000);
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, longText, "text");
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].text).toBe(longText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves long detail strings without truncation", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const longDetail = "path/to/a/very/deeply/nested/directory/structure/that/contains/many/segments/".repeat(20)
|
||||||
|
+ "src/components/features/dashboard/panels/AgentLogViewer.tsx";
|
||||||
|
// Total length should be well over 500 characters
|
||||||
|
expect(longDetail.length).toBeGreaterThan(500);
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "Read", "tool", longDetail);
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].detail).toBe(longDetail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves both long text and long detail simultaneously", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const longText = "X".repeat(2000);
|
||||||
|
const longDetail = "Y".repeat(2000);
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, longText, "tool", longDetail, "executor");
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].text).toBe(longText);
|
||||||
|
expect(logs[0].text.length).toBe(2000);
|
||||||
|
expect(logs[0].detail).toBe(longDetail);
|
||||||
|
expect(logs[0].detail!.length).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAgentLogsByTimeRange filters entries by start and end timestamps (inclusive)", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "before start", "text", "2024-01-01T00:00:00.000Z");
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "at start", "text", "2024-01-01T01:00:00.000Z");
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "middle", "text", "2024-01-01T02:00:00.000Z");
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "at end", "text", "2024-01-01T03:00:00.000Z");
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "after end", "text", "2024-01-01T04:00:00.000Z");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogsByTimeRange(
|
||||||
|
task.id,
|
||||||
|
"2024-01-01T01:00:00.000Z",
|
||||||
|
"2024-01-01T03:00:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logs).toHaveLength(3);
|
||||||
|
expect(logs.map((l) => l.text)).toEqual(["at start", "middle", "at end"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAgentLogsByTimeRange uses current time when endIso is null", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "entry1", "text", "2024-01-01T00:00:00.000Z");
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "entry2", "text", "2024-06-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogsByTimeRange(
|
||||||
|
task.id,
|
||||||
|
"2024-01-01T00:00:00.000Z",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAgentLogsByTimeRange returns empty array when no entries match", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "entry1", "text", "2024-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogsByTimeRange(
|
||||||
|
task.id,
|
||||||
|
"2025-01-01T00:00:00.000Z",
|
||||||
|
"2025-12-31T23:59:59.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getAgentLogsByTimeRange returns empty array when no entries exist", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogsByTimeRange(
|
||||||
|
task.id,
|
||||||
|
"2024-01-01T00:00:00.000Z",
|
||||||
|
"2024-12-31T23:59:59.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask refuses when another live task depends on this id", async () => {
|
||||||
|
// Regression for the triage-split bug: splitting a parent into children
|
||||||
|
// used to hard-delete the parent even when a child carried the parent id
|
||||||
|
// in its dependencies array, permanently blocking the child because the
|
||||||
|
// scheduler treats missing-dep ids as unmet.
|
||||||
|
const parent = await store.createTask({ description: "Parent to be split" });
|
||||||
|
const child = await store.createTask({
|
||||||
|
description: "Child that accidentally depends on parent",
|
||||||
|
});
|
||||||
|
await store.updateTask(child.id, { dependencies: [parent.id] });
|
||||||
|
|
||||||
|
await expect(store.deleteTask(parent.id)).rejects.toBeInstanceOf(TaskHasDependentsError);
|
||||||
|
|
||||||
|
// Parent must still exist so the dependent isn't stranded.
|
||||||
|
const stillThere = await store.getTask(parent.id);
|
||||||
|
expect(stillThere.id).toBe(parent.id);
|
||||||
|
|
||||||
|
// The error must name the dependent so callers/logs can triage it.
|
||||||
|
try {
|
||||||
|
await store.deleteTask(parent.id);
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(TaskHasDependentsError);
|
||||||
|
expect((err as TaskHasDependentsError).dependentIds).toContain(child.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the dependent's reference is removed, delete succeeds.
|
||||||
|
await store.updateTask(child.id, { dependencies: [] });
|
||||||
|
await expect(store.deleteTask(parent.id)).resolves.toMatchObject({ id: parent.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask removes incoming dependency references when explicitly requested", async () => {
|
||||||
|
const parent = await store.createTask({ description: "Parent to delete" });
|
||||||
|
const dependentOne = await store.createTask({ description: "Dependent one" });
|
||||||
|
const dependentTwo = await store.createTask({ description: "Dependent two" });
|
||||||
|
|
||||||
|
await store.updateTask(dependentOne.id, { dependencies: [parent.id, "FN-UNRELATED"] });
|
||||||
|
await store.updateTask(dependentTwo.id, { dependencies: [parent.id] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.deleteTask(parent.id, { removeDependencyReferences: true }),
|
||||||
|
).resolves.toMatchObject({ id: parent.id });
|
||||||
|
|
||||||
|
const updatedOne = await store.getTask(dependentOne.id);
|
||||||
|
const updatedTwo = await store.getTask(dependentTwo.id);
|
||||||
|
|
||||||
|
expect(updatedOne.dependencies).toEqual(["FN-UNRELATED"]);
|
||||||
|
expect(updatedTwo.dependencies).toEqual([]);
|
||||||
|
expect(updatedOne.dependencies).not.toContain(parent.id);
|
||||||
|
expect(updatedTwo.dependencies).not.toContain(parent.id);
|
||||||
|
await expect(store.getTask(parent.id)).rejects.toThrow(`Task ${parent.id} not found`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask allows deletion when a similarly-named id contains the target (substring false-positive guard)", async () => {
|
||||||
|
// The LIKE probe uses '%id%'; ensure we don't misidentify e.g. FN-1 as
|
||||||
|
// referencing FN-10 just because the id string appears inside a JSON
|
||||||
|
// array containing "FN-10".
|
||||||
|
const targetTask = await store.createTask({ description: "Target" }); // e.g. FN-001
|
||||||
|
const similarId = `${targetTask.id}X`; // definitely not a real task id
|
||||||
|
const other = await store.createTask({ description: "Other" });
|
||||||
|
await store.updateTask(other.id, { dependencies: [similarId] });
|
||||||
|
|
||||||
|
// Should NOT throw — the LIKE probe's string match is disambiguated by
|
||||||
|
// JSON.parse + array.includes.
|
||||||
|
await expect(store.deleteTask(targetTask.id)).resolves.toMatchObject({ id: targetTask.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleting a task cascades agent log entry deletion", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
await store.appendAgentLog(task.id, "cascade me", "text");
|
||||||
|
(store as any).flushAgentLogBuffer();
|
||||||
|
|
||||||
|
const before = (store as any).db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
|
||||||
|
).get(task.id) as { count: number };
|
||||||
|
expect(before.count).toBe(1);
|
||||||
|
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
|
||||||
|
const after = (store as any).db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
|
||||||
|
).get(task.id) as { count: number };
|
||||||
|
expect(after.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteTask clears linked agent task assignments", async () => {
|
||||||
|
store.close();
|
||||||
|
store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||||
|
await agentStore.init();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await store.createTask({ description: "Delete me" });
|
||||||
|
const agent = await agentStore.createAgent({ name: "Delete watcher", role: "executor" });
|
||||||
|
await agentStore.assignTask(agent.id, task.id);
|
||||||
|
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
|
||||||
|
const updatedAgent = await agentStore.getAgent(agent.id);
|
||||||
|
expect(updatedAgent?.taskId).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
agentStore.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("importLegacyAgentLogs imports JSONL entries from existing agent.log files", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
const legacyEntries = [
|
||||||
|
{
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z",
|
||||||
|
taskId: task.id,
|
||||||
|
text: "legacy line 1",
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: "2024-01-01T01:00:00.000Z",
|
||||||
|
taskId: task.id,
|
||||||
|
text: "legacy line 2",
|
||||||
|
type: "tool",
|
||||||
|
detail: "legacy detail",
|
||||||
|
agent: "executor",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeFile(join(dir, "agent.log"), `${legacyEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`);
|
||||||
|
|
||||||
|
const imported = await store.importLegacyAgentLogs();
|
||||||
|
|
||||||
|
expect(imported).toBe(2);
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(2);
|
||||||
|
expect(logs.map((log) => log.text)).toEqual(["legacy line 1", "legacy line 2"]);
|
||||||
|
expect(logs[1].detail).toBe("legacy detail");
|
||||||
|
expect(logs[1].agent).toBe("executor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("importLegacyAgentLogsOnce is idempotent via __meta guard", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||||
|
const logPath = join(dir, "agent.log");
|
||||||
|
|
||||||
|
(store as any).db.prepare("DELETE FROM __meta WHERE key = ?").run("agentLogLegacyFileImportVersion");
|
||||||
|
|
||||||
|
await writeFile(logPath, `${JSON.stringify({
|
||||||
|
timestamp: "2024-01-01T00:00:00.000Z",
|
||||||
|
taskId: task.id,
|
||||||
|
text: "legacy line 1",
|
||||||
|
type: "text",
|
||||||
|
})}\n`);
|
||||||
|
|
||||||
|
await (store as any).importLegacyAgentLogsOnce();
|
||||||
|
expect(await store.getAgentLogCount(task.id)).toBe(1);
|
||||||
|
|
||||||
|
await appendFile(logPath, `${JSON.stringify({
|
||||||
|
timestamp: "2024-01-01T01:00:00.000Z",
|
||||||
|
taskId: task.id,
|
||||||
|
text: "legacy line 2",
|
||||||
|
type: "text",
|
||||||
|
})}\n`);
|
||||||
|
|
||||||
|
await (store as any).importLegacyAgentLogsOnce();
|
||||||
|
expect(await store.getAgentLogCount(task.id)).toBe(1);
|
||||||
|
|
||||||
|
const migrationRow = (store as any).db.prepare(
|
||||||
|
"SELECT value FROM __meta WHERE key = ?",
|
||||||
|
).get("agentLogLegacyFileImportVersion") as { value: string } | undefined;
|
||||||
|
expect(migrationRow?.value).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agent log buffering", () => {
|
||||||
|
it("buffers entries and flushes in a single transaction when buffer is full", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
// Fill the buffer to its max size (50)
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await store.appendAgentLog(task.id, `entry ${i}`, "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate DB persistence without invoking read-path auto-flush helpers.
|
||||||
|
const row = (store as any).db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?")
|
||||||
|
.get(task.id) as { count: number };
|
||||||
|
expect(row.count).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-flushes buffered entries when getAgentLogs is called", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
// Write fewer than BUFFER_SIZE entries — these stay buffered
|
||||||
|
await store.appendAgentLog(task.id, "buffered 1", "text");
|
||||||
|
await store.appendAgentLog(task.id, "buffered 2", "text");
|
||||||
|
|
||||||
|
// getAgentLogs triggers a flush
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(2);
|
||||||
|
expect(logs[0].text).toBe("buffered 1");
|
||||||
|
expect(logs[1].text).toBe("buffered 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-flushes buffered entries when getAgentLogCount is called", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "counted", "text");
|
||||||
|
const count = await store.getAgentLogCount(task.id);
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-flushes before deleteTask so FK cascade finds the rows", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "to be cascaded", "text");
|
||||||
|
// Prove flush happens before delete
|
||||||
|
const flushSpy = vi.spyOn(store as any, "flushAgentLogBuffer");
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
expect(flushSpy).toHaveBeenCalled();
|
||||||
|
flushSpy.mockRestore();
|
||||||
|
|
||||||
|
const after = (store as any).db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM agentLogEntries WHERE taskId = ?",
|
||||||
|
).get(task.id) as { count: number };
|
||||||
|
expect(after.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes remaining entries on close without throwing", async () => {
|
||||||
|
// Disk-backed store required — in-memory data doesn't survive close+reopen
|
||||||
|
store.close();
|
||||||
|
store = new TaskStore(rootDir, globalDir); // no inMemoryDb
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
const task = await store.createTask({ description: "Test task" });
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "flush on close", "text");
|
||||||
|
// close() should flush the buffer gracefully
|
||||||
|
expect(() => store.close()).not.toThrow();
|
||||||
|
|
||||||
|
// Re-open and verify the entry was persisted
|
||||||
|
store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs).toHaveLength(1);
|
||||||
|
expect(logs[0].text).toBe("flush on close");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close does not throw when flushing entries for already-deleted tasks", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "orphaned entry", "text");
|
||||||
|
// Flush so the entry is in the DB, then delete the task
|
||||||
|
(store as any).flushAgentLogBuffer();
|
||||||
|
await store.deleteTask(task.id);
|
||||||
|
|
||||||
|
// Now buffer another entry for the deleted task
|
||||||
|
await store.appendAgentLog(task.id, "ghost entry", "text");
|
||||||
|
// close() should not throw despite FK constraint violation on flush
|
||||||
|
expect(() => store.close()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits agent:log event immediately even when buffered", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const events: any[] = [];
|
||||||
|
store.on("agent:log", (entry) => events.push(entry));
|
||||||
|
|
||||||
|
await store.appendAgentLog(task.id, "immediate event", "text");
|
||||||
|
|
||||||
|
// Event fires immediately, even though DB write is deferred
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].text).toBe("immediate event");
|
||||||
|
expect(events[0].taskId).toBe(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes interleaved entries from multiple tasks correctly", async () => {
|
||||||
|
const taskA = await createTestTask();
|
||||||
|
const taskB = await store.createTask({ description: "Task B" });
|
||||||
|
|
||||||
|
// Interleave entries for two tasks
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
await store.appendAgentLog(taskA.id, `A-${i}`, "text");
|
||||||
|
await store.appendAgentLog(taskB.id, `B-${i}`, "text");
|
||||||
|
}
|
||||||
|
// 50 total = buffer full, triggers flush
|
||||||
|
|
||||||
|
const countA = await store.getAgentLogCount(taskA.id);
|
||||||
|
const countB = await store.getAgentLogCount(taskB.id);
|
||||||
|
expect(countA).toBe(25);
|
||||||
|
expect(countB).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user