- Add mission contract assertions model with types: ContractAssertion, ContractAssertionStatus, ContractAssertionType - Add assertions table to schema v29 with migration from v28 - Implement assertion APIs: create, update, link/unlink to features, list, getWithContext - Add rollup computation (assertion counts, pass rates) for missions and milestones - Add many-to-many feature-to-assertion linking via featureAssertions table - Add project memory documentation for assertion lifecycle and patterns - Update schema version assertions in db.test.ts, run-audit.test.ts, and task-documents.test.ts
1318 lines
49 KiB
TypeScript
1318 lines
49 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "./db.js";
|
|
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
|
|
import { mkdtempSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { rm } from "node:fs/promises";
|
|
|
|
function makeTmpDir(): string {
|
|
return mkdtempSync(join(tmpdir(), "kb-db-test-"));
|
|
}
|
|
|
|
describe("Database", () => {
|
|
let tmpDir: string;
|
|
let kbDir: string;
|
|
let db: Database;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = makeTmpDir();
|
|
kbDir = join(tmpDir, ".fusion");
|
|
db = new Database(kbDir);
|
|
db.init(); // Explicit init required — createDatabase() does not auto-init
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
db.close();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("initialization", () => {
|
|
it("creates the database file", () => {
|
|
expect(existsSync(join(kbDir, "fusion.db"))).toBe(true);
|
|
});
|
|
|
|
it("creates the .fusion directory if missing", () => {
|
|
expect(existsSync(kbDir)).toBe(true);
|
|
});
|
|
|
|
it("sets WAL journal mode", () => {
|
|
const row = db.prepare("PRAGMA journal_mode").get() as { journal_mode: string };
|
|
expect(row.journal_mode).toBe("wal");
|
|
});
|
|
|
|
it("enables foreign keys", () => {
|
|
const row = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys: number };
|
|
expect(row.foreign_keys).toBe(1);
|
|
});
|
|
|
|
it("creates all expected tables", () => {
|
|
const tables = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
).all() as { name: string }[];
|
|
const tableNames = tables.map((t) => t.name).sort();
|
|
|
|
expect(tableNames).toContain("tasks");
|
|
expect(tableNames).toContain("config");
|
|
expect(tableNames).toContain("activityLog");
|
|
expect(tableNames).toContain("archivedTasks");
|
|
expect(tableNames).toContain("automations");
|
|
expect(tableNames).toContain("agents");
|
|
expect(tableNames).toContain("agentHeartbeats");
|
|
expect(tableNames).toContain("__meta");
|
|
// Mission hierarchy tables
|
|
expect(tableNames).toContain("missions");
|
|
expect(tableNames).toContain("milestones");
|
|
expect(tableNames).toContain("slices");
|
|
expect(tableNames).toContain("mission_features");
|
|
expect(tableNames).toContain("mission_events");
|
|
expect(tableNames).toContain("ai_sessions");
|
|
expect(tableNames).toContain("messages");
|
|
expect(tableNames).toContain("agentRatings");
|
|
expect(tableNames).toContain("task_documents");
|
|
expect(tableNames).toContain("task_document_revisions");
|
|
});
|
|
|
|
it("creates all expected indexes", () => {
|
|
const indexes = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
).all() as { name: string }[];
|
|
const indexNames = indexes.map((i) => i.name).sort();
|
|
|
|
expect(indexNames).toContain("idxActivityLogTimestamp");
|
|
expect(indexNames).toContain("idxActivityLogType");
|
|
expect(indexNames).toContain("idxActivityLogTaskId");
|
|
expect(indexNames).toContain("idxActivityLogTaskIdTimestamp");
|
|
expect(indexNames).toContain("idxActivityLogTypeTimestamp");
|
|
expect(indexNames).toContain("idxArchivedTasksId");
|
|
expect(indexNames).toContain("idxAgentHeartbeatsAgentId");
|
|
expect(indexNames).toContain("idxAgentHeartbeatsAgentIdTimestamp");
|
|
expect(indexNames).toContain("idxAgentHeartbeatsRunId");
|
|
expect(indexNames).toContain("idxAiSessionsStatus");
|
|
expect(indexNames).toContain("idxAiSessionsStatusUpdatedAt");
|
|
expect(indexNames).toContain("idxAiSessionsType");
|
|
expect(indexNames).toContain("idxAiSessionsLock");
|
|
expect(indexNames).toContain("idxAgentsState");
|
|
expect(indexNames).toContain("idxMessagesCreatedAt");
|
|
expect(indexNames).toContain("idxMessagesFrom");
|
|
expect(indexNames).toContain("idxMessagesTo");
|
|
expect(indexNames).toContain("idxAgentRatingsAgentId");
|
|
expect(indexNames).toContain("idxAgentRatingsCreatedAt");
|
|
expect(indexNames).toContain("idxMissionEventsMissionId");
|
|
expect(indexNames).toContain("idxMissionEventsTimestamp");
|
|
expect(indexNames).toContain("idxMissionEventsType");
|
|
expect(indexNames).toContain("idxTaskDocumentsTaskKey");
|
|
expect(indexNames).toContain("idxTaskDocumentsTaskId");
|
|
expect(indexNames).toContain("idxTaskDocumentRevisionsTaskKey");
|
|
expect(indexNames).toContain("idxTasksCreatedAt");
|
|
});
|
|
|
|
it("seeds schema version", () => {
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
});
|
|
|
|
it("seeds lastModified", () => {
|
|
const ts = db.getLastModified();
|
|
expect(ts).toBeGreaterThan(0);
|
|
expect(ts).toBeLessThanOrEqual(Date.now());
|
|
});
|
|
|
|
it("seeds config row with all required fields", () => {
|
|
const row = db.prepare("SELECT * FROM config WHERE id = 1").get() as any;
|
|
expect(row).toBeDefined();
|
|
expect(row.nextId).toBe(1);
|
|
expect(row.nextWorkflowStepId).toBe(1);
|
|
expect(row.settings).toBe(JSON.stringify(DEFAULT_PROJECT_SETTINGS));
|
|
expect(row.workflowSteps).toBe("[]");
|
|
expect(row.updatedAt).toBeTruthy();
|
|
// updatedAt should be a valid ISO timestamp
|
|
expect(new Date(row.updatedAt).toISOString()).toBe(row.updatedAt);
|
|
});
|
|
|
|
it("is idempotent - calling init() twice does not fail", () => {
|
|
expect(() => db.init()).not.toThrow();
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
});
|
|
|
|
it("does not overwrite existing config on re-init", () => {
|
|
// Update the config
|
|
db.prepare("UPDATE config SET nextId = 42 WHERE id = 1").run();
|
|
|
|
// Re-init
|
|
db.init();
|
|
|
|
// Should keep updated value
|
|
const row = db.prepare("SELECT nextId FROM config WHERE id = 1").get() as any;
|
|
expect(row.nextId).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe("change detection", () => {
|
|
it("getLastModified returns a timestamp", () => {
|
|
const ts = db.getLastModified();
|
|
expect(typeof ts).toBe("number");
|
|
expect(ts).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("bumpLastModified strictly increases the timestamp", () => {
|
|
// Set lastModified to a known past value
|
|
db.prepare("UPDATE __meta SET value = '1000' WHERE key = 'lastModified'").run();
|
|
expect(db.getLastModified()).toBe(1000);
|
|
|
|
db.bumpLastModified();
|
|
const after = db.getLastModified();
|
|
expect(after).toBeGreaterThan(1000);
|
|
});
|
|
|
|
it("bumpLastModified is monotonic across rapid consecutive calls", () => {
|
|
const values: number[] = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
db.bumpLastModified();
|
|
values.push(db.getLastModified());
|
|
}
|
|
// Each value must be strictly greater than the previous
|
|
for (let i = 1; i < values.length; i++) {
|
|
expect(values[i]).toBeGreaterThan(values[i - 1]);
|
|
}
|
|
});
|
|
|
|
it("lastModified survives close and reopen", () => {
|
|
db.bumpLastModified();
|
|
const ts = db.getLastModified();
|
|
expect(ts).toBeGreaterThan(0);
|
|
|
|
// Close and reopen
|
|
db.close();
|
|
const db2 = new Database(kbDir);
|
|
db2.init();
|
|
|
|
expect(db2.getLastModified()).toBe(ts);
|
|
db2.close();
|
|
|
|
// Re-assign so afterEach doesn't fail
|
|
db = new Database(kbDir);
|
|
db.init();
|
|
});
|
|
|
|
it("lastModified is stored as a row in __meta", () => {
|
|
db.bumpLastModified();
|
|
const row = db.prepare("SELECT key, value FROM __meta WHERE key = 'lastModified'").get() as { key: string; value: string };
|
|
expect(row).toBeDefined();
|
|
expect(row.key).toBe("lastModified");
|
|
expect(parseInt(row.value, 10)).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("both schemaVersion and lastModified exist in __meta", () => {
|
|
const rows = db.prepare("SELECT key FROM __meta ORDER BY key").all() as { key: string }[];
|
|
const keys = rows.map(r => r.key);
|
|
expect(keys).toContain("schemaVersion");
|
|
expect(keys).toContain("lastModified");
|
|
});
|
|
});
|
|
|
|
describe("walCheckpoint", () => {
|
|
it("runs WAL checkpoint and returns stats", () => {
|
|
const result = db.walCheckpoint();
|
|
expect(result).toHaveProperty("busy");
|
|
expect(result).toHaveProperty("log");
|
|
expect(result).toHaveProperty("checkpointed");
|
|
expect(typeof result.busy).toBe("number");
|
|
expect(typeof result.log).toBe("number");
|
|
expect(typeof result.checkpointed).toBe("number");
|
|
});
|
|
});
|
|
|
|
describe("transactions", () => {
|
|
it("commits on success", () => {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-001", "Test task", "triage", "2025-01-01", "2025-01-01");
|
|
});
|
|
|
|
const row = db.prepare("SELECT * FROM tasks WHERE id = 'FN-001'").get() as any;
|
|
expect(row).toBeDefined();
|
|
expect(row.description).toBe("Test task");
|
|
});
|
|
|
|
it("rolls back on error", () => {
|
|
expect(() => {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-002", "Test task 2", "triage", "2025-01-01", "2025-01-01");
|
|
throw new Error("Simulated failure");
|
|
});
|
|
}).toThrow("Simulated failure");
|
|
|
|
const row = db.prepare("SELECT * FROM tasks WHERE id = 'KB-002'").get();
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it("returns the function result", async () => {
|
|
const result = db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-003", "Test", "todo", "2025-01-01", "2025-01-01");
|
|
return 42;
|
|
});
|
|
expect(result).toBe(42);
|
|
});
|
|
|
|
it("supports nested transactions via savepoints", () => {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-OUTER", "Outer task", "triage", "2025-01-01", "2025-01-01");
|
|
|
|
// Nested transaction
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-INNER", "Inner task", "triage", "2025-01-01", "2025-01-01");
|
|
});
|
|
});
|
|
|
|
// Both should exist
|
|
const outer = db.prepare("SELECT * FROM tasks WHERE id = 'FN-OUTER'").get();
|
|
const inner = db.prepare("SELECT * FROM tasks WHERE id = 'FN-INNER'").get();
|
|
expect(outer).toBeDefined();
|
|
expect(inner).toBeDefined();
|
|
});
|
|
|
|
it("nested transaction rollback only affects inner scope", () => {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-OUTER2", "Outer task 2", "triage", "2025-01-01", "2025-01-01");
|
|
|
|
try {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-INNER2", "Inner task 2", "triage", "2025-01-01", "2025-01-01");
|
|
throw new Error("Inner failure");
|
|
});
|
|
} catch {
|
|
// Expected — inner transaction rolled back
|
|
}
|
|
});
|
|
|
|
// Outer should exist, inner should not
|
|
const outer = db.prepare("SELECT * FROM tasks WHERE id = 'FN-OUTER2'").get();
|
|
const inner = db.prepare("SELECT * FROM tasks WHERE id = 'FN-INNER2'").get();
|
|
expect(outer).toBeDefined();
|
|
expect(inner).toBeUndefined();
|
|
});
|
|
|
|
it("outer transaction can continue after inner rollback", () => {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-PRE", "Before inner", "triage", "2025-01-01", "2025-01-01");
|
|
|
|
// Inner transaction fails
|
|
try {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-FAIL", "Inner fail", "triage", "2025-01-01", "2025-01-01");
|
|
throw new Error("Inner failure");
|
|
});
|
|
} catch {
|
|
// Expected
|
|
}
|
|
|
|
// Additional work in outer transaction after inner rollback
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-POST", "After inner", "triage", "2025-01-01", "2025-01-01");
|
|
});
|
|
|
|
// PRE and POST should exist, FAIL should not
|
|
expect(db.prepare("SELECT * FROM tasks WHERE id = 'FN-PRE'").get()).toBeDefined();
|
|
expect(db.prepare("SELECT * FROM tasks WHERE id = 'FN-POST'").get()).toBeDefined();
|
|
expect(db.prepare("SELECT * FROM tasks WHERE id = 'FN-FAIL'").get()).toBeUndefined();
|
|
});
|
|
|
|
it("transaction is atomic — partial writes roll back", () => {
|
|
try {
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-A", "Task A", "triage", "2025-01-01", "2025-01-01");
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-B", "Task B", "triage", "2025-01-01", "2025-01-01");
|
|
// This should fail - duplicate PK
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
|
|
).run("FN-A", "Duplicate", "triage", "2025-01-01", "2025-01-01");
|
|
});
|
|
} catch {
|
|
// expected
|
|
}
|
|
|
|
// Neither task should exist
|
|
const rowA = db.prepare("SELECT * FROM tasks WHERE id = 'KB-A'").get();
|
|
const rowB = db.prepare("SELECT * FROM tasks WHERE id = 'KB-B'").get();
|
|
expect(rowA).toBeUndefined();
|
|
expect(rowB).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("foreign key cascade", () => {
|
|
it("deleting an agent cascades to heartbeats", () => {
|
|
const now = new Date().toISOString();
|
|
db.prepare(
|
|
"INSERT INTO agents (id, name, role, state, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)"
|
|
).run("agent-1", "Agent 1", "executor", "idle", now, now);
|
|
|
|
db.prepare(
|
|
"INSERT INTO agentHeartbeats (agentId, timestamp, status, runId) VALUES (?, ?, ?, ?)"
|
|
).run("agent-1", now, "ok", "run-1");
|
|
|
|
db.prepare(
|
|
"INSERT INTO agentHeartbeats (agentId, timestamp, status, runId) VALUES (?, ?, ?, ?)"
|
|
).run("agent-1", now, "ok", "run-1");
|
|
|
|
// Delete agent
|
|
db.prepare("DELETE FROM agents WHERE id = 'agent-1'").run();
|
|
|
|
// Heartbeats should be cascade-deleted
|
|
const heartbeats = db.prepare("SELECT * FROM agentHeartbeats WHERE agentId = 'agent-1'").all();
|
|
expect(heartbeats).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("foreign key cascade across reopen", () => {
|
|
it("cascade delete works after closing and reopening the database", () => {
|
|
const now = new Date().toISOString();
|
|
|
|
// Insert agent and heartbeats
|
|
db.prepare(
|
|
"INSERT INTO agents (id, name, role, state, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)"
|
|
).run("agent-reopen", "Agent", "executor", "idle", now, now);
|
|
db.prepare(
|
|
"INSERT INTO agentHeartbeats (agentId, timestamp, status, runId) VALUES (?, ?, ?, ?)"
|
|
).run("agent-reopen", now, "ok", "run-1");
|
|
|
|
// Close and reopen
|
|
db.close();
|
|
db = new Database(kbDir);
|
|
db.init();
|
|
|
|
// Verify foreign key enforcement is active after reopen
|
|
const fk = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys: number };
|
|
expect(fk.foreign_keys).toBe(1);
|
|
|
|
// Delete agent — heartbeats should cascade
|
|
db.prepare("DELETE FROM agents WHERE id = 'agent-reopen'").run();
|
|
const heartbeats = db.prepare("SELECT * FROM agentHeartbeats WHERE agentId = 'agent-reopen'").all();
|
|
expect(heartbeats).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("task round-trip", () => {
|
|
it("stores and retrieves a fully populated task record", () => {
|
|
const now = new Date().toISOString();
|
|
const task = {
|
|
id: "FN-100",
|
|
title: "Full task test",
|
|
description: "Test all fields",
|
|
column: "in-progress",
|
|
status: "running",
|
|
size: "L",
|
|
reviewLevel: 3,
|
|
currentStep: 2,
|
|
worktree: "/tmp/wt",
|
|
blockedBy: "FN-099",
|
|
paused: 1,
|
|
baseBranch: "main",
|
|
modelPresetId: "complex",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
validatorModelProvider: "openai",
|
|
validatorModelId: "gpt-4o",
|
|
mergeRetries: 2,
|
|
error: "Something went wrong",
|
|
summary: "Fixed the bug",
|
|
thinkingLevel: "high",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
columnMovedAt: now,
|
|
dependencies: JSON.stringify(["FN-098", "FN-097"]),
|
|
steps: JSON.stringify([{ name: "Step 1", status: "done" }, { name: "Step 2", status: "in-progress" }]),
|
|
log: JSON.stringify([{ timestamp: now, action: "Created" }]),
|
|
attachments: JSON.stringify([{ filename: "test.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: now }]),
|
|
comments: JSON.stringify([{ id: "c1", text: "Do this", createdAt: now, author: "user" }]),
|
|
workflowStepResults: JSON.stringify([{ workflowStepId: "WS-001", workflowStepName: "QA", status: "passed" }]),
|
|
prInfo: JSON.stringify({ url: "https://github.com/test/pr/1", number: 1, status: "open", title: "PR", headBranch: "feature", baseBranch: "main", commentCount: 0 }),
|
|
issueInfo: JSON.stringify({ url: "https://github.com/test/issues/1", number: 1, state: "open", title: "Issue" }),
|
|
breakIntoSubtasks: 1,
|
|
enabledWorkflowSteps: JSON.stringify(["WS-001", "WS-002"]),
|
|
};
|
|
|
|
db.prepare(`
|
|
INSERT INTO tasks (
|
|
id, title, description, "column", status, size, reviewLevel, currentStep,
|
|
worktree, blockedBy, paused, baseBranch, modelPresetId, modelProvider,
|
|
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
|
|
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
|
dependencies, steps, log, attachments, comments,
|
|
workflowStepResults, prInfo, issueInfo, breakIntoSubtasks,
|
|
enabledWorkflowSteps
|
|
) VALUES (
|
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
)
|
|
`).run(
|
|
task.id, task.title, task.description, task.column, task.status,
|
|
task.size, task.reviewLevel, task.currentStep, task.worktree,
|
|
task.blockedBy, task.paused, task.baseBranch, task.modelPresetId,
|
|
task.modelProvider, task.modelId, task.validatorModelProvider,
|
|
task.validatorModelId, task.mergeRetries, task.error, task.summary,
|
|
task.thinkingLevel, task.createdAt, task.updatedAt, task.columnMovedAt,
|
|
task.dependencies, task.steps, task.log, task.attachments,
|
|
task.comments, task.workflowStepResults, task.prInfo,
|
|
task.issueInfo, task.breakIntoSubtasks, task.enabledWorkflowSteps,
|
|
);
|
|
|
|
const row = db.prepare("SELECT * FROM tasks WHERE id = 'FN-100'").get() as any;
|
|
expect(row.id).toBe("FN-100");
|
|
expect(row.title).toBe("Full task test");
|
|
expect(row.column).toBe("in-progress");
|
|
expect(row.thinkingLevel).toBe("high");
|
|
expect(row.mergeRetries).toBe(2);
|
|
expect(row.paused).toBe(1);
|
|
expect(row.breakIntoSubtasks).toBe(1);
|
|
|
|
// Verify JSON round-trip
|
|
expect(JSON.parse(row.dependencies)).toEqual(["FN-098", "FN-097"]);
|
|
expect(JSON.parse(row.steps)).toHaveLength(2);
|
|
expect(JSON.parse(row.log)).toHaveLength(1);
|
|
expect(JSON.parse(row.attachments)).toHaveLength(1);
|
|
expect(JSON.parse(row.comments)).toHaveLength(1);
|
|
expect(JSON.parse(row.workflowStepResults)).toHaveLength(1);
|
|
expect(JSON.parse(row.prInfo).number).toBe(1);
|
|
expect(JSON.parse(row.issueInfo).state).toBe("open");
|
|
expect(JSON.parse(row.enabledWorkflowSteps)).toEqual(["WS-001", "WS-002"]);
|
|
});
|
|
});
|
|
|
|
describe("config round-trip", () => {
|
|
it("stores and retrieves config with nested settings and workflow steps", () => {
|
|
const settings = {
|
|
maxConcurrent: 4,
|
|
autoMerge: false,
|
|
taskPrefix: "PROJ",
|
|
};
|
|
const workflowSteps = [
|
|
{ id: "WS-001", name: "Doc Review", description: "Review docs", prompt: "Check docs", enabled: true, createdAt: "2025-01-01", updatedAt: "2025-01-01" },
|
|
];
|
|
|
|
db.prepare("UPDATE config SET settings = ?, workflowSteps = ?, nextId = ?, nextWorkflowStepId = ? WHERE id = 1")
|
|
.run(JSON.stringify(settings), JSON.stringify(workflowSteps), 42, 2);
|
|
|
|
const row = db.prepare("SELECT * FROM config WHERE id = 1").get() as any;
|
|
expect(row.nextId).toBe(42);
|
|
expect(row.nextWorkflowStepId).toBe(2);
|
|
expect(JSON.parse(row.settings).maxConcurrent).toBe(4);
|
|
expect(JSON.parse(row.settings).taskPrefix).toBe("PROJ");
|
|
expect(JSON.parse(row.workflowSteps)).toHaveLength(1);
|
|
expect(JSON.parse(row.workflowSteps)[0].id).toBe("WS-001");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("comment normalization", () => {
|
|
it("merges overlapping legacy and unified comments exactly once", () => {
|
|
const normalized = normalizeTaskComments(
|
|
[{ id: "c1", text: "Legacy note", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }],
|
|
[{ id: "c1", text: "Legacy note", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" }],
|
|
);
|
|
|
|
expect(normalized.comments).toEqual([
|
|
{
|
|
id: "c1",
|
|
text: "Legacy note",
|
|
author: "user",
|
|
createdAt: "2025-01-01T00:00:00.000Z",
|
|
updatedAt: "2025-01-02T00:00:00.000Z",
|
|
},
|
|
]);
|
|
expect(normalized.steeringComments).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("JSON helpers", () => {
|
|
describe("toJson", () => {
|
|
it("stringifies arrays", () => {
|
|
expect(toJson(["a", "b"])).toBe('["a","b"]');
|
|
});
|
|
|
|
it("stringifies objects", () => {
|
|
expect(toJson({ a: 1 })).toBe('{"a":1}');
|
|
});
|
|
|
|
it("returns '[]' for empty arrays", () => {
|
|
expect(toJson([])).toBe("[]");
|
|
});
|
|
|
|
it("returns '[]' for undefined", () => {
|
|
expect(toJson(undefined)).toBe("[]");
|
|
});
|
|
|
|
it("returns '[]' for null", () => {
|
|
expect(toJson(null)).toBe("[]");
|
|
});
|
|
|
|
it("stringifies booleans", () => {
|
|
expect(toJson(true)).toBe("true");
|
|
});
|
|
|
|
it("stringifies numbers", () => {
|
|
expect(toJson(42)).toBe("42");
|
|
});
|
|
});
|
|
|
|
describe("toJsonNullable", () => {
|
|
it("stringifies objects", () => {
|
|
expect(toJsonNullable({ a: 1 })).toBe('{"a":1}');
|
|
});
|
|
|
|
it("returns null for undefined", () => {
|
|
expect(toJsonNullable(undefined)).toBeNull();
|
|
});
|
|
|
|
it("returns null for null", () => {
|
|
expect(toJsonNullable(null)).toBeNull();
|
|
});
|
|
|
|
it("stringifies arrays", () => {
|
|
expect(toJsonNullable(["a"])).toBe('["a"]');
|
|
});
|
|
});
|
|
|
|
describe("fromJson", () => {
|
|
it("parses arrays", () => {
|
|
expect(fromJson<string[]>('["a","b"]')).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("parses objects", () => {
|
|
expect(fromJson<{ a: number }>('{"a":1}')).toEqual({ a: 1 });
|
|
});
|
|
|
|
it("returns undefined for null", () => {
|
|
expect(fromJson(null)).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined for undefined", () => {
|
|
expect(fromJson(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined for empty string", () => {
|
|
expect(fromJson("")).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined for 'null' string", () => {
|
|
expect(fromJson("null")).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined for invalid JSON", () => {
|
|
expect(fromJson("{bad json")).toBeUndefined();
|
|
});
|
|
|
|
it("round-trips: fromJson(toJson([])) returns empty array", () => {
|
|
expect(fromJson(toJson([]))).toEqual([]);
|
|
});
|
|
|
|
it("round-trips: fromJson(toJson(['a'])) returns the array", () => {
|
|
expect(fromJson(toJson(["a"]))).toEqual(["a"]);
|
|
});
|
|
|
|
it("round-trips: fromJson(toJson({a:1})) returns the object", () => {
|
|
expect(fromJson(toJson({ a: 1 }))).toEqual({ a: 1 });
|
|
});
|
|
|
|
it("round-trips: fromJson(toJson(undefined)) returns empty array (array-default)", () => {
|
|
// toJson(undefined) = '[]', fromJson('[]') = []
|
|
const result = fromJson(toJson(undefined));
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("schema migrations", () => {
|
|
let tmpDir: string;
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("migrates a v1 database by adding missing columns", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
|
|
// Create a v1 database manually (without comments and mergeDetails columns)
|
|
const db = new Database(kbDir);
|
|
// Create tables without the new columns
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
description TEXT NOT NULL,
|
|
"column" TEXT NOT NULL,
|
|
status TEXT,
|
|
size TEXT,
|
|
reviewLevel INTEGER,
|
|
currentStep INTEGER DEFAULT 0,
|
|
worktree TEXT,
|
|
blockedBy TEXT,
|
|
paused INTEGER DEFAULT 0,
|
|
baseBranch TEXT,
|
|
modelPresetId TEXT,
|
|
modelProvider TEXT,
|
|
modelId TEXT,
|
|
validatorModelProvider TEXT,
|
|
validatorModelId TEXT,
|
|
mergeRetries INTEGER,
|
|
error TEXT,
|
|
summary TEXT,
|
|
thinkingLevel TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT NOT NULL,
|
|
columnMovedAt TEXT,
|
|
dependencies TEXT DEFAULT '[]',
|
|
steps TEXT DEFAULT '[]',
|
|
log TEXT DEFAULT '[]',
|
|
attachments TEXT DEFAULT '[]',
|
|
steeringComments TEXT DEFAULT '[]',
|
|
workflowStepResults TEXT DEFAULT '[]',
|
|
prInfo TEXT,
|
|
issueInfo TEXT,
|
|
breakIntoSubtasks INTEGER DEFAULT 0,
|
|
enabledWorkflowSteps TEXT DEFAULT '[]'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
nextId INTEGER DEFAULT 1,
|
|
nextWorkflowStepId INTEGER DEFAULT 1,
|
|
settings TEXT DEFAULT '{}',
|
|
workflowSteps TEXT DEFAULT '[]',
|
|
updatedAt TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS activityLog (
|
|
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
|
|
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS automations (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
|
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
|
|
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
|
|
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
|
|
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agents (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
|
|
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agentHeartbeats (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
|
|
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '1')");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
|
|
|
// Insert a task on the v1 schema
|
|
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('KB-1', 'test', 'triage', '2025-01-01', '2025-01-01')`);
|
|
|
|
// Now run init() which should trigger migration
|
|
db.init();
|
|
|
|
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
|
|
// Verify new columns exist and existing data is intact
|
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
|
const colNames = cols.map((c) => c.name);
|
|
expect(colNames).toContain("comments");
|
|
expect(colNames).toContain("mergeDetails");
|
|
|
|
// Existing task should still be readable
|
|
const task = db.prepare("SELECT * FROM tasks WHERE id = 'KB-1'").get() as any;
|
|
expect(task.description).toBe("test");
|
|
|
|
// New columns should have defaults
|
|
expect(task.comments).toBe("[]");
|
|
expect(task.mergeDetails).toBeNull();
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("skips migration if already at target version", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
const db = new Database(kbDir);
|
|
db.init();
|
|
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
|
|
// Re-init should not fail
|
|
db.init();
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("applies migration 14+15 by creating agentRatings and ai_sessions indexes", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
|
|
const db = new Database(kbDir);
|
|
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '13')");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
|
|
|
db.init();
|
|
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
|
|
|
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name = 'agentRatings' ORDER BY name").all() as Array<{ name: string }>;
|
|
const indexNames = indexes.map((index) => index.name);
|
|
expect(indexNames).toContain("idxAgentRatingsAgentId");
|
|
expect(indexNames).toContain("idxAgentRatingsCreatedAt");
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("migrates a v16 database by creating mission_events table and indexes", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
|
|
const db = new Database(kbDir);
|
|
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '16')");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
|
|
|
db.init();
|
|
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
|
|
|
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name = 'mission_events' ORDER BY name").all() as Array<{ name: string }>;
|
|
const indexNames = indexes.map((index) => index.name);
|
|
expect(indexNames).toContain("idxMissionEventsMissionId");
|
|
expect(indexNames).toContain("idxMissionEventsTimestamp");
|
|
expect(indexNames).toContain("idxMissionEventsType");
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("migrates a v2 database by adding missionId and sliceId columns", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
|
|
// Create a v2 database manually (without missionId and sliceId columns)
|
|
const db = new Database(kbDir);
|
|
// Create tables without the new columns (matching v2 schema)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
description TEXT NOT NULL,
|
|
"column" TEXT NOT NULL,
|
|
status TEXT,
|
|
size TEXT,
|
|
reviewLevel INTEGER,
|
|
currentStep INTEGER DEFAULT 0,
|
|
worktree TEXT,
|
|
blockedBy TEXT,
|
|
paused INTEGER DEFAULT 0,
|
|
baseBranch TEXT,
|
|
modelPresetId TEXT,
|
|
modelProvider TEXT,
|
|
modelId TEXT,
|
|
validatorModelProvider TEXT,
|
|
validatorModelId TEXT,
|
|
mergeRetries INTEGER,
|
|
error TEXT,
|
|
summary TEXT,
|
|
thinkingLevel TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT NOT NULL,
|
|
columnMovedAt TEXT,
|
|
dependencies TEXT DEFAULT '[]',
|
|
steps TEXT DEFAULT '[]',
|
|
log TEXT DEFAULT '[]',
|
|
attachments TEXT DEFAULT '[]',
|
|
steeringComments TEXT DEFAULT '[]',
|
|
comments TEXT DEFAULT '[]',
|
|
workflowStepResults TEXT DEFAULT '[]',
|
|
prInfo TEXT,
|
|
issueInfo TEXT,
|
|
mergeDetails TEXT,
|
|
breakIntoSubtasks INTEGER DEFAULT 0,
|
|
enabledWorkflowSteps TEXT DEFAULT '[]'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
nextId INTEGER DEFAULT 1,
|
|
nextWorkflowStepId INTEGER DEFAULT 1,
|
|
settings TEXT DEFAULT '{}',
|
|
workflowSteps TEXT DEFAULT '[]',
|
|
updatedAt TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS activityLog (
|
|
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
|
|
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS automations (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
|
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
|
|
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
|
|
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
|
|
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agents (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
|
|
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agentHeartbeats (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
|
|
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '2')");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
|
|
|
// Insert a task on the v2 schema
|
|
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('KB-2', 'test v2', 'triage', '2025-01-01', '2025-01-01')`);
|
|
|
|
// Now run init() which should trigger migrations v2→v3→v4
|
|
db.init();
|
|
|
|
// Verify version bumped to 29
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
|
|
// Verify new columns exist and existing data is intact
|
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
|
const colNames = cols.map((c) => c.name);
|
|
expect(colNames).toContain("missionId");
|
|
expect(colNames).toContain("sliceId");
|
|
expect(colNames).toContain("branch");
|
|
|
|
// Existing task should still be readable
|
|
const task = db.prepare("SELECT * FROM tasks WHERE id = 'KB-2'").get() as any;
|
|
expect(task.description).toBe("test v2");
|
|
|
|
// New columns should have null defaults
|
|
expect(task.missionId).toBeNull();
|
|
expect(task.sliceId).toBeNull();
|
|
|
|
// Mission tables should be created
|
|
const tables = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
).all() as { name: string }[];
|
|
const tableNames = tables.map((t) => t.name);
|
|
expect(tableNames).toContain("missions");
|
|
expect(tableNames).toContain("milestones");
|
|
expect(tableNames).toContain("slices");
|
|
expect(tableNames).toContain("mission_features");
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("migrates pre-comments databases by copying steering comments into unified comments exactly once", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
|
|
const db = new Database(kbDir);
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
description TEXT NOT NULL,
|
|
"column" TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT NOT NULL,
|
|
steeringComments TEXT DEFAULT '[]'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
nextId INTEGER DEFAULT 1,
|
|
nextWorkflowStepId INTEGER DEFAULT 1,
|
|
settings TEXT DEFAULT '{}',
|
|
workflowSteps TEXT DEFAULT '[]',
|
|
updatedAt TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS activityLog (
|
|
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
|
|
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS automations (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
|
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
|
|
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
|
|
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
|
|
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agents (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
|
|
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agentHeartbeats (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
|
|
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '1')");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
|
db.prepare("INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt, steeringComments) VALUES (?, ?, ?, ?, ?, ?)")
|
|
.run(
|
|
"FN-100",
|
|
"legacy comments",
|
|
"todo",
|
|
"2025-01-01T00:00:00.000Z",
|
|
"2025-01-01T00:00:00.000Z",
|
|
JSON.stringify([{ id: "legacy-1", text: "Use TypeScript", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }]),
|
|
);
|
|
|
|
db.init();
|
|
|
|
const row = db.prepare("SELECT steeringComments, comments FROM tasks WHERE id = 'FN-100'").get() as any;
|
|
expect(JSON.parse(row.steeringComments)).toHaveLength(1);
|
|
expect(JSON.parse(row.comments)).toEqual([
|
|
{
|
|
id: "legacy-1",
|
|
text: "Use TypeScript",
|
|
author: "user",
|
|
createdAt: "2025-01-01T00:00:00.000Z",
|
|
},
|
|
]);
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("deduplicates overlapping steeringComments and comments during schema upgrade", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
|
|
const db = new Database(kbDir);
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
description TEXT NOT NULL,
|
|
"column" TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT NOT NULL,
|
|
steeringComments TEXT DEFAULT '[]',
|
|
comments TEXT DEFAULT '[]',
|
|
mergeDetails TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
nextId INTEGER DEFAULT 1,
|
|
nextWorkflowStepId INTEGER DEFAULT 1,
|
|
settings TEXT DEFAULT '{}',
|
|
workflowSteps TEXT DEFAULT '[]',
|
|
updatedAt TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS activityLog (
|
|
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
|
|
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS automations (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
|
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
|
|
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
|
|
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
|
|
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agents (
|
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
|
|
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
|
|
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
|
|
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agentHeartbeats (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
|
|
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
|
|
);
|
|
`);
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '4')");
|
|
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
|
db.prepare("INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt, steeringComments, comments) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
.run(
|
|
"FN-101",
|
|
"mixed comments",
|
|
"todo",
|
|
"2025-01-01T00:00:00.000Z",
|
|
"2025-01-01T00:00:00.000Z",
|
|
JSON.stringify([{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }]),
|
|
JSON.stringify([
|
|
{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" },
|
|
{ id: "c2", text: "Already unified", author: "alice", createdAt: "2025-01-03T00:00:00.000Z" },
|
|
]),
|
|
);
|
|
|
|
db.init();
|
|
|
|
const row = db.prepare("SELECT comments FROM tasks WHERE id = 'FN-101'").get() as any;
|
|
expect(JSON.parse(row.comments)).toEqual([
|
|
{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" },
|
|
{ id: "c2", text: "Already unified", author: "alice", createdAt: "2025-01-03T00:00:00.000Z" },
|
|
]);
|
|
|
|
db.close();
|
|
});
|
|
});
|
|
|
|
describe("FTS5 full-text search", () => {
|
|
let tmpDir: string;
|
|
let kbDir: string;
|
|
let db: Database;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = makeTmpDir();
|
|
kbDir = join(tmpDir, ".fusion");
|
|
db = new Database(kbDir);
|
|
db.init();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
db.close();
|
|
} catch {
|
|
// already closed
|
|
}
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates tasks_fts virtual table after init", () => {
|
|
const row = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='tasks_fts'"
|
|
).get() as { name: string } | undefined;
|
|
expect(row?.name).toBe("tasks_fts");
|
|
});
|
|
|
|
it("creates FTS5 triggers after init", () => {
|
|
const triggers = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='trigger'"
|
|
).all() as { name: string }[];
|
|
const triggerNames = triggers.map((t) => t.name);
|
|
|
|
expect(triggerNames).toContain("tasks_fts_ai");
|
|
expect(triggerNames).toContain("tasks_fts_au");
|
|
expect(triggerNames).toContain("tasks_fts_ad");
|
|
});
|
|
|
|
it("populates FTS index from existing tasks on migration", () => {
|
|
// Insert a task directly into the database (bypassing triggers for this test)
|
|
db.prepare(
|
|
"INSERT INTO tasks (id, title, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)"
|
|
).run(
|
|
"FN-FTS-001",
|
|
"Full-text search test",
|
|
"Testing the FTS index",
|
|
"todo",
|
|
"2025-01-01T00:00:00.000Z",
|
|
"2025-01-01T00:00:00.000Z"
|
|
);
|
|
|
|
// Verify the task appears in the FTS index by joining with tasks table
|
|
const ftsRow = db.prepare(`
|
|
SELECT t.* FROM tasks t
|
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
|
WHERE t.id = 'FN-FTS-001'
|
|
`).get() as any;
|
|
|
|
expect(ftsRow).toBeDefined();
|
|
expect(ftsRow.id).toBe("FN-FTS-001");
|
|
expect(ftsRow.title).toBe("Full-text search test");
|
|
expect(ftsRow.description).toBe("Testing the FTS index");
|
|
});
|
|
|
|
it("INSERT trigger indexes new tasks", () => {
|
|
// Use upsertTask equivalent via direct insert
|
|
db.prepare(`
|
|
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt)
|
|
VALUES ('FN-FTS-002', 'New task title', 'New task description', 'triage', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')
|
|
`).run();
|
|
|
|
// Verify the task appears in the FTS index via trigger by joining with tasks
|
|
const ftsRow = db.prepare(`
|
|
SELECT t.* FROM tasks t
|
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
|
WHERE t.id = 'FN-FTS-002'
|
|
`).get() as any;
|
|
|
|
expect(ftsRow).toBeDefined();
|
|
expect(ftsRow.id).toBe("FN-FTS-002");
|
|
expect(ftsRow.title).toBe("New task title");
|
|
});
|
|
|
|
it("UPDATE trigger reindexes updated tasks", () => {
|
|
// Insert a task
|
|
db.prepare(`
|
|
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt)
|
|
VALUES ('FN-FTS-003', 'Original title', 'Original description', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')
|
|
`).run();
|
|
|
|
// Update the task
|
|
db.prepare(`
|
|
UPDATE tasks SET title = 'Updated title', updatedAt = '2025-01-02T00:00:00.000Z' WHERE id = 'FN-FTS-003'
|
|
`).run();
|
|
|
|
// Verify FTS index has the updated content
|
|
const ftsRow = db.prepare(`
|
|
SELECT t.* FROM tasks t
|
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
|
WHERE t.id = 'FN-FTS-003'
|
|
`).get() as any;
|
|
|
|
expect(ftsRow).toBeDefined();
|
|
expect(ftsRow.title).toBe("Updated title");
|
|
expect(ftsRow.description).toBe("Original description"); // description should still be there
|
|
});
|
|
|
|
it("DELETE trigger removes tasks from index", () => {
|
|
// Insert a task
|
|
db.prepare(`
|
|
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt)
|
|
VALUES ('FN-FTS-004', 'Task to delete', 'Will be removed', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')
|
|
`).run();
|
|
|
|
// Verify it's in the FTS index
|
|
const beforeDelete = db.prepare(`
|
|
SELECT t.* FROM tasks t
|
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
|
WHERE t.id = 'FN-FTS-004'
|
|
`).get();
|
|
expect(beforeDelete).toBeDefined();
|
|
|
|
// Delete the task
|
|
db.prepare("DELETE FROM tasks WHERE id = 'FN-FTS-004'").run();
|
|
|
|
// Verify it's no longer in the FTS index
|
|
const afterDelete = db.prepare(`
|
|
SELECT t.* FROM tasks t
|
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
|
WHERE t.id = 'FN-FTS-004'
|
|
`).get();
|
|
expect(afterDelete).toBeUndefined();
|
|
});
|
|
|
|
it("FTS index includes comments in JSON format", () => {
|
|
// Insert a task with comments
|
|
db.prepare(`
|
|
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt, comments)
|
|
VALUES ('FN-FTS-005', 'Task with comments', 'Has a comment', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '[{"id":"c1","text":"xylophone_plan_keyword","author":"tester","createdAt":"2025-01-01T00:00:00.000Z"}]')
|
|
`).run();
|
|
|
|
// Verify the task appears in FTS with comments tokenized using MATCH
|
|
const ftsRows = db.prepare(`
|
|
SELECT t.* FROM tasks t
|
|
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
|
WHERE tasks_fts MATCH 'xylophone'
|
|
`).all() as any[];
|
|
|
|
expect(ftsRows.length).toBeGreaterThan(0);
|
|
const ftsRow = ftsRows.find((r) => r.id === "FN-FTS-005");
|
|
expect(ftsRow).toBeDefined();
|
|
expect(ftsRow.comments).toContain("xylophone");
|
|
});
|
|
});
|
|
|
|
describe("createDatabase factory", () => {
|
|
let tmpDir: string;
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates a database instance without auto-init", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
const db = createDatabase(kbDir);
|
|
|
|
// DB file exists (created on open) but schema not initialized
|
|
expect(existsSync(join(kbDir, "fusion.db"))).toBe(true);
|
|
// Schema is NOT yet created — querying __meta would fail
|
|
expect(() => db.getSchemaVersion()).toThrow();
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("works after explicit init()", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
const db = createDatabase(kbDir);
|
|
db.init();
|
|
|
|
expect(db.getSchemaVersion()).toBe(29);
|
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("getPath returns the database file path", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
const db = createDatabase(kbDir);
|
|
|
|
expect(db.getPath()).toBe(join(kbDir, "fusion.db"));
|
|
|
|
db.close();
|
|
});
|
|
|
|
it("is idempotent when init() called multiple times", () => {
|
|
tmpDir = makeTmpDir();
|
|
const kbDir = join(tmpDir, ".fusion");
|
|
|
|
// First call
|
|
const db1 = createDatabase(kbDir);
|
|
db1.init();
|
|
db1.prepare("UPDATE config SET nextId = 99 WHERE id = 1").run();
|
|
db1.close();
|
|
|
|
// Second call — init should not overwrite data
|
|
const db2 = createDatabase(kbDir);
|
|
db2.init();
|
|
const row = db2.prepare("SELECT nextId FROM config WHERE id = 1").get() as any;
|
|
expect(row.nextId).toBe(99);
|
|
db2.close();
|
|
});
|
|
});
|