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("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();
|
||||
|
||||
@@ -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)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user