feat(FN-2575): merge fusion/fn-2575

This commit is contained in:
gsxdsm
2026-04-25 21:43:38 -07:00
parent 9aff532787
commit 0db86c9371
14 changed files with 706 additions and 24 deletions

View File

@@ -131,7 +131,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
});
it("seeds lastModified", () => {
@@ -154,7 +154,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
});
it("does not overwrite existing config on re-init", () => {
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
db.close();
});
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -976,7 +976,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
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" }]);
@@ -1000,7 +1000,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
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" }]);
@@ -1104,7 +1104,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1473,7 +1473,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -776,7 +776,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(45);
expect(db1.getSchemaVersion()).toBe(46);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -811,7 +811,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(45);
expect(db3.getSchemaVersion()).toBe(46);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -842,12 +842,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(45);
expect(db1.getSchemaVersion()).toBe(46);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(45);
expect(db2.getSchemaVersion()).toBe(46);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

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

View File

@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(45);
expect(db.getSchemaVersion()).toBe(46);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,263 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { createDatabase, type Database } from "../db.js";
import { TodoStore } from "../todo-store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-todo-store-"));
}
let fusionDir: string;
let db: Database;
let store: TodoStore;
afterEach(() => {
db.close();
rmSync(fusionDir, { recursive: true, force: true });
});
beforeEach(() => {
fusionDir = makeTmpDir();
db = createDatabase(fusionDir);
db.init();
store = new TodoStore(db);
});
describe("TodoStore", () => {
describe("list CRUD", () => {
it("createList returns a list with generated id and timestamps", () => {
const list = store.createList("proj-a", { title: "Inbox" });
expect(list.id).toMatch(/^TDL-[A-Z0-9]+-[A-Z0-9]+$/);
expect(list.projectId).toBe("proj-a");
expect(list.title).toBe("Inbox");
expect(list.createdAt).toBeTruthy();
expect(list.updatedAt).toBeTruthy();
});
it("getList returns list by id and undefined when missing", () => {
const list = store.createList("proj-a", { title: "Backlog" });
expect(store.getList(list.id)).toEqual(list);
expect(store.getList("TDL-MISSING")).toBeUndefined();
});
it("listLists returns lists ordered by createdAt and scoped by project", () => {
const now = new Date();
db.prepare(
"INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
).run("TDL-OLD", "proj-a", "Older", new Date(now.getTime() - 10_000).toISOString(), new Date(now.getTime() - 10_000).toISOString());
db.prepare(
"INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
).run("TDL-NEW", "proj-a", "Newer", now.toISOString(), now.toISOString());
store.createList("proj-b", { title: "Other project" });
const lists = store.listLists("proj-a");
expect(lists.map((l) => l.id)).toEqual(["TDL-OLD", "TDL-NEW"]);
});
it("updateList updates title and updatedAt; returns undefined when missing", () => {
const list = store.createList("proj-a", { title: "Before" });
const updated = store.updateList(list.id, { title: "After" });
expect(updated).toBeDefined();
expect(updated?.title).toBe("After");
expect(updated?.updatedAt >= list.updatedAt).toBe(true);
expect(store.updateList("TDL-MISSING", { title: "x" })).toBeUndefined();
});
it("deleteList removes list, returns true/false, and cascades items", () => {
const list = store.createList("proj-a", { title: "Delete me" });
const item = store.createItem(list.id, { text: "child" });
expect(store.deleteList(list.id)).toBe(true);
expect(store.getList(list.id)).toBeUndefined();
expect(store.getItem(item.id)).toBeUndefined();
expect(store.deleteList(list.id)).toBe(false);
});
});
describe("item CRUD", () => {
it("createItem auto-increments sortOrder and accepts explicit sortOrder", () => {
const list = store.createList("proj-a", { title: "L" });
const first = store.createItem(list.id, { text: "first" });
const second = store.createItem(list.id, { text: "second" });
const explicit = store.createItem(list.id, { text: "explicit", sortOrder: 10 });
expect(first.sortOrder).toBe(0);
expect(second.sortOrder).toBe(1);
expect(explicit.sortOrder).toBe(10);
});
it("getItem retrieves an item by id", () => {
const list = store.createList("proj-a", { title: "L" });
const item = store.createItem(list.id, { text: "fetch me" });
expect(store.getItem(item.id)).toEqual(item);
expect(store.getItem("TDI-MISSING")).toBeUndefined();
});
it("listItems returns items ordered by sortOrder then createdAt", () => {
const list = store.createList("proj-a", { title: "L" });
const now = new Date().toISOString();
db.prepare(
`INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run("TDI-B", list.id, "b", 0, null, 0, now, now);
db.prepare(
`INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run("TDI-A", list.id, "a", 0, null, 0, new Date(Date.now() - 1000).toISOString(), new Date(Date.now() - 1000).toISOString());
db.prepare(
`INSERT INTO todo_items (id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run("TDI-C", list.id, "c", 0, null, 1, now, now);
const items = store.listItems(list.id);
expect(items.map((i) => i.id)).toEqual(["TDI-A", "TDI-B", "TDI-C"]);
});
it("updateItem updates text and bumps updatedAt", () => {
const list = store.createList("proj-a", { title: "L" });
const item = store.createItem(list.id, { text: "before" });
const updated = store.updateItem(item.id, { text: "after" });
expect(updated?.text).toBe("after");
expect(updated?.updatedAt >= item.updatedAt).toBe(true);
});
it("toggleItem flips completion and completedAt", () => {
const list = store.createList("proj-a", { title: "L" });
const item = store.createItem(list.id, { text: "toggle" });
const completed = store.toggleItem(item.id)!;
expect(completed.completed).toBe(true);
expect(completed.completedAt).toBeTruthy();
const reopened = store.toggleItem(item.id)!;
expect(reopened.completed).toBe(false);
expect(reopened.completedAt).toBeNull();
});
it.each([
{ completed: true, expectedCompletedAt: "set" },
{ completed: false, expectedCompletedAt: "cleared" },
])("updateItem handles completed=$completed by setting/clearing completedAt", ({ completed, expectedCompletedAt }) => {
const list = store.createList("proj-a", { title: "L" });
const item = store.createItem(list.id, { text: "status" });
if (!completed) {
store.updateItem(item.id, { completed: true });
}
const updated = store.updateItem(item.id, { completed })!;
expect(updated.completed).toBe(completed);
if (expectedCompletedAt === "set") {
expect(updated.completedAt).toBeTruthy();
} else {
expect(updated.completedAt).toBeNull();
}
});
it("deleteItem removes item and returns true/false", () => {
const list = store.createList("proj-a", { title: "L" });
const item = store.createItem(list.id, { text: "x" });
expect(store.deleteItem(item.id)).toBe(true);
expect(store.getItem(item.id)).toBeUndefined();
expect(store.deleteItem(item.id)).toBe(false);
});
it("reorderItems reassigns sortOrder and validates list membership", () => {
const list = store.createList("proj-a", { title: "L" });
const i1 = store.createItem(list.id, { text: "1" });
const i2 = store.createItem(list.id, { text: "2" });
const i3 = store.createItem(list.id, { text: "3" });
const reordered = store.reorderItems(list.id, [i3.id, i1.id, i2.id]);
expect(reordered.map((i) => [i.id, i.sortOrder])).toEqual([
[i3.id, 0],
[i1.id, 1],
[i2.id, 2],
]);
const other = store.createList("proj-a", { title: "Other" });
const otherItem = store.createItem(other.id, { text: "other" });
expect(() => store.reorderItems(list.id, [i1.id, i2.id, otherItem.id])).toThrow(/does not belong to list/);
expect(() => store.reorderItems(list.id, [i1.id, i2.id])).toThrow(/must include all items/);
});
});
describe("composite queries", () => {
it("getListsWithItems returns all lists with populated items", () => {
const l1 = store.createList("proj-a", { title: "A" });
const l2 = store.createList("proj-a", { title: "B" });
const i1 = store.createItem(l1.id, { text: "a1" });
const i2 = store.createItem(l1.id, { text: "a2" });
const i3 = store.createItem(l2.id, { text: "b1" });
const lists = store.getListsWithItems("proj-a");
expect(lists).toHaveLength(2);
expect(lists.find((l) => l.id === l1.id)?.items.map((i) => i.id)).toEqual([i1.id, i2.id]);
expect(lists.find((l) => l.id === l2.id)?.items.map((i) => i.id)).toEqual([i3.id]);
});
it("getListsWithItems is scoped by projectId", () => {
const listA = store.createList("proj-a", { title: "A" });
store.createItem(listA.id, { text: "a1" });
const listB = store.createList("proj-b", { title: "B" });
store.createItem(listB.id, { text: "b1" });
const lists = store.getListsWithItems("proj-a");
expect(lists).toHaveLength(1);
expect(lists[0].id).toBe(listA.id);
});
});
describe("event emissions", () => {
it("emits list events with expected payloads", () => {
const createdHandler = vi.fn();
const updatedHandler = vi.fn();
const deletedHandler = vi.fn();
store.on("list:created", createdHandler);
store.on("list:updated", updatedHandler);
store.on("list:deleted", deletedHandler);
const list = store.createList("proj-a", { title: "Events" });
const updated = store.updateList(list.id, { title: "Events 2" })!;
store.deleteList(list.id);
expect(createdHandler).toHaveBeenCalledWith(list);
expect(updatedHandler).toHaveBeenCalledWith(updated);
expect(deletedHandler).toHaveBeenCalledWith(list.id);
});
it("emits item and reorder events", () => {
const list = store.createList("proj-a", { title: "Events" });
const createdHandler = vi.fn();
const updatedHandler = vi.fn();
const deletedHandler = vi.fn();
const reorderedHandler = vi.fn();
store.on("item:created", createdHandler);
store.on("item:updated", updatedHandler);
store.on("item:deleted", deletedHandler);
store.on("items:reordered", reorderedHandler);
const a = store.createItem(list.id, { text: "A" });
const b = store.createItem(list.id, { text: "B" });
const updated = store.updateItem(a.id, { text: "A+" })!;
store.reorderItems(list.id, [b.id, a.id]);
store.deleteItem(a.id);
expect(createdHandler).toHaveBeenCalledTimes(2);
expect(updatedHandler).toHaveBeenCalledWith(updated);
expect(reorderedHandler).toHaveBeenCalledWith({
listId: list.id,
items: expect.any(Array),
});
expect(deletedHandler).toHaveBeenCalledWith(a.id);
});
});
});

View File

@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 45;
const SCHEMA_VERSION = 46;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -619,6 +619,33 @@ CREATE INDEX IF NOT EXISTS idxProjectInsightsCategory
-- Index for filtering runs by projectId
CREATE INDEX IF NOT EXISTS idxInsightRunsProjectId
ON project_insight_runs(projectId);
-- Todo list persistence tables (FN-2575)
-- Project-scoped todo lists and ordered checklist items
CREATE TABLE IF NOT EXISTS todo_lists (
id TEXT PRIMARY KEY,
projectId TEXT NOT NULL,
title TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS todo_items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
text TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
completedAt TEXT,
sortOrder INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (listId) REFERENCES todo_lists(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId);
CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId);
CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder);
`;
// ── Database Class ───────────────────────────────────────────────────
@@ -1753,6 +1780,38 @@ export class Database {
});
}
if (version < 46) {
this.applyMigration(46, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS todo_lists (
id TEXT PRIMARY KEY,
projectId TEXT NOT NULL,
title TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS todo_items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
text TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
completedAt TEXT,
sortOrder INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (listId) REFERENCES todo_lists(id) ON DELETE CASCADE
)
`);
this.db.exec("CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId)");
this.db.exec("CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId)");
this.db.exec("CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder)");
});
}
}
/**

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,
@@ -532,6 +532,8 @@ export type {
InsightRunListOptions,
InsightStoreEvents,
} from "./insight-types.js";
export { TodoStore } from "./todo-store.js";
export type { TodoStoreEvents } from "./todo-store.js";
// ── Agent Companies Types ──────────────────────────────────

View File

@@ -14,6 +14,7 @@ import { MissionStore } from "./mission-store.js";
import { PluginStore } from "./plugin-store.js";
import { RoadmapStore } from "./roadmap-store.js";
import { InsightStore } from "./insight-store.js";
import { TodoStore } from "./todo-store.js";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { getTaskMergeBlocker } from "./task-merge.js";
@@ -359,6 +360,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private roadmapStore: RoadmapStore | null = null;
/** Cached InsightStore instance */
private insightStore: InsightStore | null = null;
/** Cached TodoStore instance */
private todoStore: TodoStore | null = null;
constructor(private rootDir: string, globalSettingsDir?: string) {
super();
@@ -5666,6 +5669,17 @@ ${notificationsSection}`;
return this.insightStore;
}
/**
* Get the TodoStore instance for project-scoped todo list operations.
* Lazily initializes the TodoStore on first access.
*/
getTodoStore(): TodoStore {
if (!this.todoStore) {
this.todoStore = new TodoStore(this.db);
}
return this.todoStore;
}
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
}

View File

@@ -0,0 +1,297 @@
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import type {
TodoList,
TodoItem,
TodoListCreateInput,
TodoListUpdateInput,
TodoItemCreateInput,
TodoItemUpdateInput,
TodoListWithItems,
} from "./types.js";
export interface TodoStoreEvents {
"list:created": [TodoList];
"list:updated": [TodoList];
"list:deleted": [string];
"item:created": [TodoItem];
"item:updated": [TodoItem];
"item:deleted": [string];
"items:reordered": [{ listId: string; items: TodoItem[] }];
}
interface TodoListRow {
id: string;
projectId: string;
title: string;
createdAt: string;
updatedAt: string;
}
interface TodoItemRow {
id: string;
listId: string;
text: string;
completed: number;
completedAt: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export class TodoStore extends EventEmitter<TodoStoreEvents> {
constructor(private db: Database) {
super();
this.setMaxListeners(50);
}
getDatabase(): Database {
return this.db;
}
private generateListId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).slice(2, 6).toUpperCase();
return `TDL-${timestamp}-${random}`;
}
private generateItemId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).slice(2, 6).toUpperCase();
return `TDI-${timestamp}-${random}`;
}
private rowToTodoList(row: TodoListRow): TodoList {
return {
id: row.id,
projectId: row.projectId,
title: row.title,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private rowToTodoItem(row: TodoItemRow): TodoItem {
return {
id: row.id,
listId: row.listId,
text: row.text,
completed: row.completed === 1,
completedAt: row.completedAt,
sortOrder: row.sortOrder,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
createList(projectId: string, input: TodoListCreateInput): TodoList {
const now = new Date().toISOString();
const list: TodoList = {
id: this.generateListId(),
projectId,
title: input.title,
createdAt: now,
updatedAt: now,
};
this.db.prepare(
"INSERT INTO todo_lists (id, projectId, title, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)"
).run(list.id, list.projectId, list.title, list.createdAt, list.updatedAt);
this.db.bumpLastModified();
this.emit("list:created", list);
return list;
}
getList(id: string): TodoList | undefined {
const row = this.db.prepare("SELECT * FROM todo_lists WHERE id = ?").get(id) as TodoListRow | undefined;
return row ? this.rowToTodoList(row) : undefined;
}
listLists(projectId: string): TodoList[] {
const rows = this.db.prepare(
"SELECT * FROM todo_lists WHERE projectId = ? ORDER BY createdAt ASC, id ASC"
).all(projectId) as unknown as TodoListRow[];
return rows.map((row) => this.rowToTodoList(row));
}
updateList(id: string, input: TodoListUpdateInput): TodoList | undefined {
const existing = this.getList(id);
if (!existing) return undefined;
const now = new Date().toISOString();
const title = input.title ?? existing.title;
this.db.prepare("UPDATE todo_lists SET title = ?, updatedAt = ? WHERE id = ?").run(title, now, id);
this.db.bumpLastModified();
const updated = this.getList(id)!;
this.emit("list:updated", updated);
return updated;
}
deleteList(id: string): boolean {
const result = this.db.prepare("DELETE FROM todo_lists WHERE id = ?").run(id) as { changes?: number };
if ((result.changes ?? 0) < 1) return false;
this.db.bumpLastModified();
this.emit("list:deleted", id);
return true;
}
createItem(listId: string, input: TodoItemCreateInput): TodoItem {
const list = this.getList(listId);
if (!list) {
throw new Error(`Todo list ${listId} not found`);
}
const nextSortOrder = (() => {
if (input.sortOrder !== undefined) return input.sortOrder;
const row = this.db.prepare("SELECT MAX(sortOrder) AS maxSortOrder FROM todo_items WHERE listId = ?").get(listId) as
| { maxSortOrder: number | null }
| undefined;
return (row?.maxSortOrder ?? -1) + 1;
})();
const now = new Date().toISOString();
const item: TodoItem = {
id: this.generateItemId(),
listId,
text: input.text,
completed: false,
completedAt: null,
sortOrder: nextSortOrder,
createdAt: now,
updatedAt: now,
};
this.db.prepare(
`INSERT INTO todo_items
(id, listId, text, completed, completedAt, sortOrder, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(item.id, item.listId, item.text, 0, null, item.sortOrder, item.createdAt, item.updatedAt);
this.db.bumpLastModified();
this.emit("item:created", item);
return item;
}
getItem(id: string): TodoItem | undefined {
const row = this.db.prepare("SELECT * FROM todo_items WHERE id = ?").get(id) as TodoItemRow | undefined;
return row ? this.rowToTodoItem(row) : undefined;
}
listItems(listId: string): TodoItem[] {
const rows = this.db.prepare(
"SELECT * FROM todo_items WHERE listId = ? ORDER BY sortOrder ASC, createdAt ASC, id ASC"
).all(listId) as unknown as TodoItemRow[];
return rows.map((row) => this.rowToTodoItem(row));
}
updateItem(id: string, input: TodoItemUpdateInput): TodoItem | undefined {
const existing = this.getItem(id);
if (!existing) return undefined;
const now = new Date().toISOString();
const sets: string[] = ["updatedAt = ?"];
const params: Array<string | number | null> = [now];
if (input.text !== undefined) {
sets.push("text = ?");
params.push(input.text);
}
if (input.sortOrder !== undefined) {
sets.push("sortOrder = ?");
params.push(input.sortOrder);
}
if (input.completed !== undefined) {
sets.push("completed = ?");
params.push(input.completed ? 1 : 0);
sets.push("completedAt = ?");
params.push(input.completed ? now : null);
}
params.push(id);
this.db.prepare(`UPDATE todo_items SET ${sets.join(", ")} WHERE id = ?`).run(...params);
this.db.bumpLastModified();
const updated = this.getItem(id)!;
this.emit("item:updated", updated);
return updated;
}
deleteItem(id: string): boolean {
const result = this.db.prepare("DELETE FROM todo_items WHERE id = ?").run(id) as { changes?: number };
if ((result.changes ?? 0) < 1) return false;
this.db.bumpLastModified();
this.emit("item:deleted", id);
return true;
}
toggleItem(id: string): TodoItem | undefined {
const existing = this.getItem(id);
if (!existing) return undefined;
return this.updateItem(id, { completed: !existing.completed });
}
reorderItems(listId: string, itemIds: string[]): TodoItem[] {
const items = this.listItems(listId);
const existingIds = items.map((item) => item.id);
if (new Set(itemIds).size !== itemIds.length) {
throw new Error("Cannot reorder items: duplicate item IDs provided");
}
if (existingIds.length !== itemIds.length) {
throw new Error("Cannot reorder items: provided IDs must include all items in the list");
}
const existingIdSet = new Set(existingIds);
for (const itemId of itemIds) {
if (!existingIdSet.has(itemId)) {
throw new Error(`Cannot reorder items: item ${itemId} does not belong to list ${listId}`);
}
}
const now = new Date().toISOString();
this.db.transaction(() => {
for (let index = 0; index < itemIds.length; index++) {
this.db
.prepare("UPDATE todo_items SET sortOrder = ?, updatedAt = ? WHERE id = ? AND listId = ?")
.run(index, now, itemIds[index], listId);
}
});
this.db.bumpLastModified();
const reordered = this.listItems(listId);
this.emit("items:reordered", { listId, items: reordered });
return reordered;
}
getListsWithItems(projectId: string): TodoListWithItems[] {
const lists = this.listLists(projectId);
if (lists.length === 0) return [];
const rows = this.db.prepare(
`SELECT * FROM todo_items
WHERE listId IN (SELECT id FROM todo_lists WHERE projectId = ?)
ORDER BY listId ASC, sortOrder ASC, createdAt ASC, id ASC`
).all(projectId) as unknown as TodoItemRow[];
const itemsByListId = new Map<string, TodoItem[]>();
for (const row of rows) {
const item = this.rowToTodoItem(row);
const listItems = itemsByListId.get(item.listId) ?? [];
listItems.push(item);
itemsByListId.set(item.listId, listItems);
}
return lists.map((list) => ({
...list,
items: itemsByListId.get(list.id) ?? [],
}));
}
}

View File

@@ -925,6 +925,50 @@ export interface TaskCreateInput {
executionMode?: ExecutionMode;
}
// ── Todo List Types ──────────────────────────────────────────────────────
export interface TodoList {
id: string;
projectId: string;
title: string;
createdAt: string;
updatedAt: string;
}
export interface TodoItem {
id: string;
listId: string;
text: string;
completed: boolean;
completedAt: string | null;
createdAt: string;
updatedAt: string;
sortOrder: number;
}
export interface TodoListCreateInput {
title: string;
}
export interface TodoListUpdateInput {
title?: string;
}
export interface TodoItemCreateInput {
text: string;
sortOrder?: number;
}
export interface TodoItemUpdateInput {
text?: string;
completed?: boolean;
sortOrder?: number;
}
export interface TodoListWithItems extends TodoList {
items: TodoItem[];
}
// ── Settings Scope Types ────────────────────────────────────────────────
//
// Settings are split into two scopes: