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:
287
packages/core/src/__tests__/task-documents.test.ts
Normal file
287
packages/core/src/__tests__/task-documents.test.ts
Normal 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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,6 +73,8 @@ describe("Database", () => {
|
|||||||
expect(tableNames).toContain("ai_sessions");
|
expect(tableNames).toContain("ai_sessions");
|
||||||
expect(tableNames).toContain("messages");
|
expect(tableNames).toContain("messages");
|
||||||
expect(tableNames).toContain("agentRatings");
|
expect(tableNames).toContain("agentRatings");
|
||||||
|
expect(tableNames).toContain("task_documents");
|
||||||
|
expect(tableNames).toContain("task_document_revisions");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates all expected indexes", () => {
|
it("creates all expected indexes", () => {
|
||||||
@@ -97,10 +99,13 @@ describe("Database", () => {
|
|||||||
expect(indexNames).toContain("idxMissionEventsMissionId");
|
expect(indexNames).toContain("idxMissionEventsMissionId");
|
||||||
expect(indexNames).toContain("idxMissionEventsTimestamp");
|
expect(indexNames).toContain("idxMissionEventsTimestamp");
|
||||||
expect(indexNames).toContain("idxMissionEventsType");
|
expect(indexNames).toContain("idxMissionEventsType");
|
||||||
|
expect(indexNames).toContain("idxTaskDocumentsTaskKey");
|
||||||
|
expect(indexNames).toContain("idxTaskDocumentsTaskId");
|
||||||
|
expect(indexNames).toContain("idxTaskDocumentRevisionsTaskKey");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(17);
|
expect(db.getSchemaVersion()).toBe(18);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
@@ -123,7 +128,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(17);
|
expect(db.getSchemaVersion()).toBe(18);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
@@ -730,7 +735,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
// 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
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
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);
|
const db = new Database(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(17);
|
expect(db.getSchemaVersion()).toBe(18);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(17);
|
expect(db.getSchemaVersion()).toBe(18);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -775,7 +780,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -799,7 +804,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||||
@@ -903,7 +908,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 5
|
// Verify version bumped to 5
|
||||||
expect(db.getSchemaVersion()).toBe(17);
|
expect(db.getSchemaVersion()).toBe(18);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1113,7 +1118,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(kbDir);
|
const db = createDatabase(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(17);
|
expect(db.getSchemaVersion()).toBe(18);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 17;
|
const SCHEMA_VERSION = 18;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
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 idxAgentHeartbeatsAgentId ON agentHeartbeats(agentId);
|
||||||
CREATE INDEX IF NOT EXISTS idxAgentHeartbeatsRunId ON agentHeartbeats(runId);
|
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
|
-- Schema version tracking
|
||||||
CREATE TABLE IF NOT EXISTS __meta (
|
CREATE TABLE IF NOT EXISTS __meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -668,6 +697,40 @@ export class Database {
|
|||||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMissionEventsType ON mission_events(eventType)`);
|
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)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { 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 { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export {
|
export {
|
||||||
BUILTIN_AGENT_PROMPTS,
|
BUILTIN_AGENT_PROMPTS,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
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 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 } 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 { GlobalSettingsStore } from "./global-settings.js";
|
||||||
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
|
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
|
||||||
import { detectLegacyData, migrateFromLegacy } from "./db-migrate.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.
|
* Upsert a task to the database. Used by create and update operations.
|
||||||
*/
|
*/
|
||||||
@@ -2560,6 +2594,172 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
return task;
|
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.
|
* Update or clear PR information for a task.
|
||||||
* Updates task.json atomically and emits `task:updated` event.
|
* Updates task.json atomically and emits `task:updated` event.
|
||||||
|
|||||||
@@ -464,6 +464,67 @@ export interface TaskCommentInput {
|
|||||||
author: string;
|
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 {
|
export interface MergeDetails {
|
||||||
commitSha?: string;
|
commitSha?: string;
|
||||||
filesChanged?: number;
|
filesChanged?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user