feat(FN-1270): add task document storage with revision history

- Add task document domain types and key validation helpers in core types
- Introduce schema v18 migration creating task_documents and task_document_revisions tables with indexes
- Implement TaskStore CRUD/upsert APIs for task documents, including revision archiving and task update events
- Export task document types from the core index for downstream consumers
- Add comprehensive task document tests and update database schema/version assertions
This commit is contained in:
gsxdsm
2026-04-08 12:09:41 -07:00
parent 105d224b7f
commit a997436b7c
6 changed files with 629 additions and 13 deletions

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Database } from "../db.js";
import { TaskStore } from "../store.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-task-docs-test-"));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("TaskStore task documents", () => {
let rootDir: string;
let kbDir: string;
let db: Database;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
kbDir = join(rootDir, ".fusion");
db = new Database(kbDir);
db.init();
store = new TaskStore(rootDir);
await store.init();
});
afterEach(async () => {
try {
store.close();
} catch {
// ignore
}
try {
db.close();
} catch {
// ignore
}
await rm(rootDir, { recursive: true, force: true });
});
it("creates task document tables/indexes and bumps schema version", () => {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>;
const tableNames = new Set(tables.map((table) => table.name));
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(18);
const index = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'task_documents' AND name = 'idxTaskDocumentsTaskKey'",
)
.get() as { name: string } | undefined;
expect(index?.name).toBe("idxTaskDocumentsTaskKey");
});
it("creates a document with revision 1, default author, and optional metadata", async () => {
const task = await store.createTask({ description: "Document task" });
const created = await store.upsertTaskDocument(task.id, {
key: "plan",
content: "Initial plan",
});
expect(created.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(created.taskId).toBe(task.id);
expect(created.key).toBe("plan");
expect(created.content).toBe("Initial plan");
expect(created.revision).toBe(1);
expect(created.author).toBe("user");
expect(created.metadata).toBeUndefined();
const withMetadata = await store.upsertTaskDocument(task.id, {
key: "notes",
content: "Captured notes",
author: "agent",
metadata: { source: "brainstorm", tags: ["todo"] },
});
expect(withMetadata.revision).toBe(1);
expect(withMetadata.author).toBe("agent");
expect(withMetadata.metadata).toEqual({ source: "brainstorm", tags: ["todo"] });
});
it("validates keys and task existence on create", async () => {
const task = await store.createTask({ description: "Validation task" });
const invalidKeys = ["", "my plan", "plan!", "a".repeat(65)];
for (const key of invalidKeys) {
await expect(
store.upsertTaskDocument(task.id, {
key,
content: "x",
}),
).rejects.toThrow(/Invalid document key/);
}
await expect(
store.upsertTaskDocument("KB-DOES-NOT-EXIST", {
key: "plan",
content: "x",
}),
).rejects.toThrow("Task KB-DOES-NOT-EXIST not found");
});
it("updates a document, increments revision, and archives previous content", async () => {
const task = await store.createTask({ description: "Update task" });
const first = await store.upsertTaskDocument(task.id, {
key: "plan",
content: "v1",
author: "user",
metadata: { stage: 1 },
});
await sleep(2);
const second = await store.upsertTaskDocument(task.id, {
key: "plan",
content: "v2",
author: "agent",
metadata: { stage: 2 },
});
expect(second.revision).toBe(2);
expect(second.content).toBe("v2");
expect(second.author).toBe("agent");
expect(second.metadata).toEqual({ stage: 2 });
expect(new Date(second.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(first.updatedAt).getTime(),
);
const revisions = await store.getTaskDocumentRevisions(task.id, "plan");
expect(revisions).toHaveLength(1);
expect(revisions[0].revision).toBe(1);
expect(revisions[0].content).toBe("v1");
expect(revisions[0].author).toBe("user");
expect(revisions[0].metadata).toEqual({ stage: 1 });
});
it("supports multiple updates with archived revisions queryable", async () => {
const task = await store.createTask({ description: "Multi update task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1", author: "user" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2", author: "agent" });
const latest = await store.upsertTaskDocument(task.id, {
key: "plan",
content: "v3",
author: "system",
});
expect(latest.revision).toBe(3);
const revisions = await store.getTaskDocumentRevisions(task.id, "plan");
expect(revisions.map((revision) => revision.revision)).toEqual([2, 1]);
const current = await store.getTaskDocument(task.id, "plan");
expect(current?.revision).toBe(3);
expect(current?.content).toBe("v3");
});
it("returns document revisions newest-first and supports limit", async () => {
const task = await store.createTask({ description: "Revision list task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v3" });
const all = await store.getTaskDocumentRevisions(task.id, "plan");
expect(all.map((revision) => revision.revision)).toEqual([2, 1]);
const limited = await store.getTaskDocumentRevisions(task.id, "plan", { limit: 1 });
expect(limited).toHaveLength(1);
expect(limited[0].revision).toBe(2);
const missing = await store.getTaskDocumentRevisions(task.id, "missing");
expect(missing).toEqual([]);
});
it("gets the latest document revision by key and returns null when missing", async () => {
const task = await store.createTask({ description: "Get doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
const document = await store.getTaskDocument(task.id, "plan");
expect(document?.revision).toBe(2);
expect(document?.content).toBe("v2");
const missing = await store.getTaskDocument(task.id, "unknown");
expect(missing).toBeNull();
});
it("lists all task documents ordered by key", async () => {
const task = await store.createTask({ description: "List docs task" });
const emptyTask = await store.createTask({ description: "Empty docs task" });
await store.upsertTaskDocument(task.id, { key: "zeta", content: "z" });
await store.upsertTaskDocument(task.id, { key: "alpha", content: "a" });
await store.upsertTaskDocument(task.id, { key: "middle", content: "m" });
const docs = await store.getTaskDocuments(task.id);
expect(docs.map((doc) => doc.key)).toEqual(["alpha", "middle", "zeta"]);
const empty = await store.getTaskDocuments(emptyTask.id);
expect(empty).toEqual([]);
});
it("enforces one document per key per task via upsert semantics", async () => {
const task = await store.createTask({ description: "Unique key task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "notes", content: "v1" });
const updated = await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
expect(updated.revision).toBe(2);
const docs = await store.getTaskDocuments(task.id);
expect(docs).toHaveLength(2);
expect(docs.find((doc) => doc.key === "plan")?.revision).toBe(2);
});
it("deletes a document and its revisions, and throws if the document is missing", async () => {
const task = await store.createTask({ description: "Delete doc task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v2" });
await store.deleteTaskDocument(task.id, "plan");
const afterDelete = await store.getTaskDocument(task.id, "plan");
expect(afterDelete).toBeNull();
const revisions = await store.getTaskDocumentRevisions(task.id, "plan");
expect(revisions).toEqual([]);
await expect(store.deleteTaskDocument(task.id, "plan")).rejects.toThrow(
`Document plan not found for task ${task.id}`,
);
});
it("deletes task documents via foreign key cascade when a task is deleted", async () => {
const task = await store.createTask({ description: "Cascade task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "v1" });
await store.deleteTask(task.id);
const documents = await store.getTaskDocuments(task.id);
expect(documents).toEqual([]);
const document = await store.getTaskDocument(task.id, "plan");
expect(document).toBeNull();
});
it("accepts valid key edge cases and rejects invalid ones", async () => {
const task = await store.createTask({ description: "Key edge case task" });
const validKeys = ["plan", "PLAN", "my-notes", "doc_123", "a", "a".repeat(64)];
for (const [index, key] of validKeys.entries()) {
await expect(
store.upsertTaskDocument(task.id, {
key,
content: `content-${index}`,
}),
).resolves.toBeDefined();
}
const invalidKeys = ["", "my plan", "plan!", "a".repeat(65)];
for (const key of invalidKeys) {
await expect(
store.upsertTaskDocument(task.id, {
key,
content: "invalid",
}),
).rejects.toThrow(/Invalid document key/);
}
});
});

View File

@@ -73,6 +73,8 @@ describe("Database", () => {
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", () => {
@@ -97,10 +99,13 @@ describe("Database", () => {
expect(indexNames).toContain("idxMissionEventsMissionId");
expect(indexNames).toContain("idxMissionEventsTimestamp");
expect(indexNames).toContain("idxMissionEventsType");
expect(indexNames).toContain("idxTaskDocumentsTaskKey");
expect(indexNames).toContain("idxTaskDocumentsTaskId");
expect(indexNames).toContain("idxTaskDocumentRevisionsTaskKey");
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
});
it("seeds lastModified", () => {
@@ -123,7 +128,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
});
it("does not overwrite existing config on re-init", () => {
@@ -730,7 +735,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -755,11 +760,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
db.close();
});
@@ -775,7 +780,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
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" }]);
@@ -799,7 +804,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
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" }]);
@@ -903,7 +908,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1113,7 +1118,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(17);
expect(db.getSchemaVersion()).toBe(18);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 17;
const SCHEMA_VERSION = 18;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -268,6 +268,35 @@ CREATE TABLE IF NOT EXISTS agentHeartbeats (
CREATE INDEX IF NOT EXISTS idxAgentHeartbeatsAgentId ON agentHeartbeats(agentId);
CREATE INDEX IF NOT EXISTS idxAgentHeartbeatsRunId ON agentHeartbeats(runId);
-- Task documents (key-value store per task with revision tracking)
CREATE TABLE IF NOT EXISTS task_documents (
id TEXT PRIMARY KEY,
taskId TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
revision INTEGER NOT NULL DEFAULT 1,
author TEXT NOT NULL DEFAULT 'user',
metadata TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idxTaskDocumentsTaskKey ON task_documents(taskId, key);
CREATE INDEX IF NOT EXISTS idxTaskDocumentsTaskId ON task_documents(taskId);
-- Task document revision history (shadow table for archived snapshots)
CREATE TABLE IF NOT EXISTS task_document_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
taskId TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL,
revision INTEGER NOT NULL,
author TEXT NOT NULL,
metadata TEXT,
createdAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxTaskDocumentRevisionsTaskKey ON task_document_revisions(taskId, key);
-- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY,
@@ -668,6 +697,40 @@ export class Database {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMissionEventsType ON mission_events(eventType)`);
});
}
if (version < 18) {
this.applyMigration(18, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS task_documents (
id TEXT PRIMARY KEY,
taskId TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
revision INTEGER NOT NULL DEFAULT 1,
author TEXT NOT NULL DEFAULT 'user',
metadata TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE CASCADE
)
`);
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idxTaskDocumentsTaskKey ON task_documents(taskId, key)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTaskDocumentsTaskId ON task_documents(taskId)`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS task_document_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
taskId TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL,
revision INTEGER NOT NULL,
author TEXT NOT NULL,
metadata TEXT,
createdAt TEXT NOT NULL
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTaskDocumentRevisionsTaskKey ON task_document_revisions(taskId, key)`);
});
}
}
/**

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, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, 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, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, 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, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -1,10 +1,11 @@
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, WORKFLOW_STEP_TEMPLATES } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js";
@@ -232,6 +233,39 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
};
}
/**
* Convert a task_documents row to a TaskDocument object.
*/
private rowToTaskDocument(row: any): TaskDocument {
return {
id: row.id,
taskId: row.taskId,
key: row.key,
content: row.content,
revision: row.revision,
author: row.author,
metadata: fromJson<Record<string, unknown>>(row.metadata),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Convert a task_document_revisions row to a TaskDocumentRevision object.
*/
private rowToTaskDocumentRevision(row: any): TaskDocumentRevision {
return {
id: row.id,
taskId: row.taskId,
key: row.key,
content: row.content,
revision: row.revision,
author: row.author,
metadata: fromJson<Record<string, unknown>>(row.metadata),
createdAt: row.createdAt,
};
}
/**
* Upsert a task to the database. Used by create and update operations.
*/
@@ -2560,6 +2594,172 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return task;
}
/**
* List all current task documents for a task, ordered by key.
*/
async getTaskDocuments(taskId: string): Promise<TaskDocument[]> {
const rows = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? ORDER BY key")
.all(taskId) as any[];
return rows.map((row) => this.rowToTaskDocument(row));
}
/**
* Get the current revision of a specific task document.
*/
async getTaskDocument(taskId: string, key: string): Promise<TaskDocument | null> {
const row = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?")
.get(taskId, key) as any | undefined;
if (!row) return null;
return this.rowToTaskDocument(row);
}
/**
* Create or update a task document while archiving previous revisions.
*/
async upsertTaskDocument(taskId: string, input: TaskDocumentCreateInput): Promise<TaskDocument> {
try {
validateDocumentKey(input.key);
} catch {
throw new Error(
`Invalid document key: "${input.key}". Must be 1-64 alphanumeric characters, hyphens, or underscores.`,
);
}
const taskExists = this.db.prepare("SELECT id FROM tasks WHERE id = ?").get(taskId) as
| { id: string }
| undefined;
if (!taskExists) {
throw new Error(`Task ${taskId} not found`);
}
const now = new Date().toISOString();
const author = input.author ?? "user";
const metadata = toJsonNullable(input.metadata);
const document = this.db.transaction(() => {
const existing = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?")
.get(taskId, input.key) as any | undefined;
if (existing) {
this.db.prepare(
`INSERT INTO task_document_revisions (taskId, key, content, revision, author, metadata, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(
taskId,
input.key,
existing.content,
existing.revision,
existing.author,
existing.metadata ?? null,
now,
);
this.db.prepare(
`UPDATE task_documents
SET content = ?, revision = ?, author = ?, metadata = ?, updatedAt = ?
WHERE taskId = ? AND key = ?`
).run(
input.content,
existing.revision + 1,
author,
metadata,
now,
taskId,
input.key,
);
} else {
this.db.prepare(
`INSERT INTO task_documents (id, taskId, key, content, revision, author, metadata, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
randomUUID(),
taskId,
input.key,
input.content,
1,
author,
metadata,
now,
now,
);
}
const row = this.db
.prepare("SELECT * FROM task_documents WHERE taskId = ? AND key = ?")
.get(taskId, input.key) as any | undefined;
if (!row) {
throw new Error(`Failed to upsert document ${input.key} for task ${taskId}`);
}
return this.rowToTaskDocument(row);
});
this.db.bumpLastModified();
const task = await this.getTask(taskId);
this.emit("task:updated", task);
return document;
}
/**
* List archived revisions for a task document, newest first.
*/
async getTaskDocumentRevisions(
taskId: string,
key: string,
options?: { limit?: number },
): Promise<TaskDocumentRevision[]> {
const hasLimit = options?.limit !== undefined;
const rows = hasLimit
? (this.db
.prepare(
"SELECT * FROM task_document_revisions WHERE taskId = ? AND key = ? ORDER BY revision DESC LIMIT ?",
)
.all(taskId, key, Math.max(0, options.limit ?? 0)) as any[])
: (this.db
.prepare(
"SELECT * FROM task_document_revisions WHERE taskId = ? AND key = ? ORDER BY revision DESC",
)
.all(taskId, key) as any[]);
return rows.map((row) => this.rowToTaskDocumentRevision(row));
}
/**
* Delete a task document and all archived revisions for its key.
*/
async deleteTaskDocument(taskId: string, key: string): Promise<void> {
const existing = this.db
.prepare("SELECT id FROM task_documents WHERE taskId = ? AND key = ?")
.get(taskId, key) as { id: string } | undefined;
if (!existing) {
throw new Error(`Document ${key} not found for task ${taskId}`);
}
this.db.transaction(() => {
this.db
.prepare("DELETE FROM task_document_revisions WHERE taskId = ? AND key = ?")
.run(taskId, key);
const result = this.db
.prepare("DELETE FROM task_documents WHERE taskId = ? AND key = ?")
.run(taskId, key) as { changes?: number };
if ((result.changes ?? 0) === 0) {
throw new Error(`Document ${key} not found for task ${taskId}`);
}
});
this.db.bumpLastModified();
const task = await this.getTask(taskId);
this.emit("task:updated", task);
}
/**
* Update or clear PR information for a task.
* Updates task.json atomically and emits `task:updated` event.

View File

@@ -464,6 +464,67 @@ export interface TaskCommentInput {
author: string;
}
export interface TaskDocument {
/** UUID primary key */
id: string;
/** Task this document belongs to */
taskId: string;
/** Document key (e.g., "plan", "notes", "research"). Alphanumeric, hyphens, underscores. */
key: string;
/** Document body content */
content: string;
/** Monotonically increasing revision number (starts at 1) */
revision: number;
/** Who created/last-edited this revision: "user" | "agent" | "system" */
author: string;
/** Optional extensible metadata (JSON object) */
metadata?: Record<string, unknown>;
/** ISO-8601 creation timestamp */
createdAt: string;
/** ISO-8601 last-update timestamp */
updatedAt: string;
}
export interface TaskDocumentRevision {
/** Auto-increment row ID */
id: number;
/** Task this revision belongs to */
taskId: string;
/** Document key */
key: string;
/** Snapshot of document content at this revision */
content: string;
/** Revision number of this snapshot */
revision: number;
/** Author who created this revision */
author: string;
/** Optional metadata snapshot */
metadata?: Record<string, unknown>;
/** ISO-8601 timestamp when this revision was archived */
createdAt: string;
}
export interface TaskDocumentCreateInput {
/** Document key. Must match /^[a-zA-Z0-9_-]{1,64}$/ */
key: string;
/** Document body content */
content: string;
/** Author (defaults to "user" if not provided) */
author?: string;
/** Optional extensible metadata */
metadata?: Record<string, unknown>;
}
export const DOCUMENT_KEY_RE = /^[a-zA-Z0-9_-]{1,64}$/;
export function validateDocumentKey(key: string): void {
if (!DOCUMENT_KEY_RE.test(key)) {
throw new Error(
`Invalid document key: "${key}". Must be 1-64 characters: letters, digits, hyphens, or underscores.`,
);
}
}
export interface MergeDetails {
commitSha?: string;
filesChanged?: number;