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 f35515ed19
commit bae9d8cd2a
6 changed files with 629 additions and 13 deletions

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.