feat: complete TaskTree CLI parity

This commit is contained in:
root
2026-07-18 06:41:31 +02:00
parent 9dc1562486
commit e62a9c4b0e
9 changed files with 445 additions and 43 deletions
+3 -2
View File
@@ -3,9 +3,10 @@ import { TaskTreeStore } from "./store.ts";
export function createTaskTreeActivities(store: TaskTreeStore) {
return {
async recordTaskExecution(input: { taskId: string }) {
const task = await store.updateTask(input.taskId, { status: "completed" });
const task = await store.getTask(input.taskId);
if (!task) throw Object.assign(new Error("task was not found"), { code: "task_not_found" });
const report = await store.createReport({ taskId: task.id, title: "Temporal execution report", body: `Task ${task.title} completed by Temporal worker.`, status: "succeeded" });
const { report } = await store.writeReport({ taskId: task.id, title: "Temporal execution report", body: `Task ${task.title} completed by Temporal worker.`, status: "succeeded" });
await store.completeTask(task.id);
return { taskId: task.id, reportId: report.id };
}
};
+10 -1
View File
@@ -66,12 +66,21 @@ export type TaskTreeCommand =
| { operation: "group.get"; groupId: string }
| { operation: "group.create"; name: string; description?: string }
| { operation: "group.delete"; groupId: string }
| { operation: "group.stats"; groupId: string }
| { operation: "group.import-markdown"; sourcePath: string; sourceModifiedAt: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string; modifiedAt: string }>; groupName?: string; dryRun?: boolean }
| { operation: "task.list"; groupId: string }
| { operation: "task.get"; taskId: string }
| { operation: "task.create"; groupId: string; title: string; description?: string; startAt?: string; dueAt?: string; parentId?: string }
| { operation: "task.create-batch"; groupId: string; titles: string[]; parentId?: string; description?: string; startAt?: string; dueAt?: string }
| { operation: "task.update"; taskId: string; title?: string; description?: string; status?: TaskStatus; startAt?: string | null; dueAt?: string | null }
| { operation: "task.delete"; taskId: string }
| { operation: "task.start"; taskId: string }
| { operation: "task.complete"; taskId: string }
| { operation: "milestone.create"; groupId: string; title: string; occursAt: string; taskId?: string }
| { operation: "report.list"; taskId: string }
| { operation: "report.get"; reportId: string }
| { operation: "report.write"; taskId: string; title: string; body: string; status?: string }
| { operation: "report.create"; taskId: string; title: string; body: string; status?: string }
| { operation: "mdtodo.import"; sourcePath: string; sourceModifiedAt: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string; modifiedAt: string }>; groupName?: string; dryRun?: boolean }
| { operation: "timeline.get"; groupId: string }
| { operation: "workflow.start"; taskId: string };
+84 -8
View File
@@ -1,6 +1,6 @@
import { Client, Connection } from "@temporalio/client";
import type { TaskTreeCommand, TaskTreeResult } from "./contracts.ts";
import type { TaskItem, TaskTreeCommand, TaskTreeResult } from "./contracts.ts";
import { parseMdtodoImport } from "./mdtodo-import.ts";
import { TaskTreeStore } from "./store.ts";
@@ -22,16 +22,67 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
else if (operation === "group.overview") data = { groups: await store.groupOverview() };
else if (operation === "group.get") data = required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found");
else if (operation === "group.create") data = await store.createGroup(requiredText(command.name, "name"), command.description);
else if (operation === "group.delete") data = { deleted: await store.deleteGroup(command.groupId) };
else if (operation === "task.create") data = await store.createTask({ ...command, title: requiredText(command.title, "title") });
else if (operation === "task.update") data = required(await store.updateTask(command.taskId, command), "task_not_found", "task was not found");
else if (operation === "task.delete") data = { deleted: await store.deleteTask(command.taskId) };
else if (operation === "milestone.create") data = await store.createMilestone({ ...command, title: requiredText(command.title, "title"), occursAt: validDate(command.occursAt, "occursAt") });
else if (operation === "report.create") data = await store.createReport({ ...command, title: requiredText(command.title, "title"), body: requiredText(command.body, "body") });
else if (operation === "mdtodo.import") {
else if (operation === "group.delete") data = {
deleted: requiredMutation(await store.deleteGroup(command.groupId), "group_not_found", "taskgroup was not found"),
mutation: true
};
else if (operation === "group.stats") {
required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found");
data = await store.groupStats(command.groupId);
}
else if (operation === "group.import-markdown") {
const plan = parseMdtodoImport(command);
data = command.dryRun ? { dryRun: true, ...plan, tasks: undefined } : await store.importMdtodo(plan);
}
else if (operation === "task.list") {
required(await store.getGroup(command.groupId), "group_not_found", "taskgroup was not found");
data = { tasks: taskTree(await store.listTasks(command.groupId)) };
}
else if (operation === "task.get") {
const task = required(await store.getTask(command.taskId), "task_not_found", "task was not found");
const tasks = await store.listTasks(task.groupId);
data = { ...taskNode(task, tasks), reports: await store.listReports(task.id) };
}
else if (operation === "task.create") data = await store.createTask({
...command,
title: validTitle(command.title),
startAt: optionalDate(command.startAt, "startAt"),
dueAt: optionalDate(command.dueAt, "dueAt")
});
else if (operation === "task.create-batch") {
const titles = command.titles.map(validTitle);
if (titles.length < 1) throw codedError("invalid_input", "at least one title is required");
data = { tasks: await store.createTasks({
...command,
titles,
startAt: optionalDate(command.startAt, "startAt"),
dueAt: optionalDate(command.dueAt, "dueAt")
}) };
}
else if (operation === "task.update") {
if (command.status === "completed") throw codedError("completion_command_required", "use task complete so the execution report requirement is enforced");
data = required(await store.updateTask(command.taskId, {
...command,
title: command.title === undefined ? undefined : validTitle(command.title),
status: command.status === undefined ? undefined : validStatus(command.status),
startAt: nullableDate(command.startAt, "startAt"),
dueAt: nullableDate(command.dueAt, "dueAt")
}), "task_not_found", "task was not found");
}
else if (operation === "task.delete") data = {
deleted: requiredMutation(await store.deleteTask(command.taskId), "task_not_found", "task was not found"),
mutation: true
};
else if (operation === "task.start") data = required(await store.updateTask(command.taskId, { status: "in_progress" }), "task_not_found", "task was not found");
else if (operation === "task.complete") data = required(await store.completeTask(command.taskId), "task_not_found", "task was not found");
else if (operation === "milestone.create") data = await store.createMilestone({ ...command, title: requiredText(command.title, "title"), occursAt: validDate(command.occursAt, "occursAt") });
else if (operation === "report.list") {
required(await store.getTask(command.taskId), "task_not_found", "task was not found");
data = { reports: await store.listReports(command.taskId) };
}
else if (operation === "report.get") data = required(await store.getReport(command.reportId), "report_not_found", "execution report was not found");
else if (operation === "report.write") data = await store.writeReport({ ...command, title: requiredText(command.title, "title"), body: requiredText(command.body, "body") });
else if (operation === "report.create") data = await store.createReport({ ...command, title: requiredText(command.title, "title"), body: requiredText(command.body, "body") });
else if (operation === "timeline.get") data = required(await store.timeline(command.groupId), "group_not_found", "taskgroup was not found");
else if (operation === "workflow.start") data = await startTaskWorkflow(options, command.taskId);
else throw codedError("unsupported_operation", `unsupported operation: ${String(operation)}`);
@@ -56,6 +107,31 @@ async function startTaskWorkflow(options: TaskTreeDispatcherOptions, taskId: str
}
function required<T>(value: T | null, code: string, message: string): T { if (value === null) throw codedError(code, message); return value; }
function requiredMutation(value: boolean, code: string, message: string): true { if (!value) throw codedError(code, message); return true; }
function requiredText(value: unknown, field: string): string { const text = String(value ?? "").trim(); if (!text) throw codedError("invalid_input", `${field} is required`); return text; }
function validTitle(value: unknown): string {
const title = requiredText(value, "title");
if (/[\r\n\u0000]/u.test(title)) throw codedError("invalid_title", "title must be non-empty single-line text");
return title;
}
function validStatus(value: unknown): "pending" | "in_progress" | "completed" | "blocked" {
if (value === "pending" || value === "in_progress" || value === "completed" || value === "blocked") return value;
throw codedError("invalid_status", "status must be pending, in_progress, completed, or blocked");
}
function optionalDate(value: string | undefined, field: string): string | undefined { return value === undefined ? undefined : validDate(value, field); }
function nullableDate(value: string | null | undefined, field: string): string | null | undefined { return value === null || value === undefined ? value : validDate(value, field); }
function validDate(value: unknown, field: string): string { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("invalid_input", `${field} must be an ISO date`); return date.toISOString(); }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
type TaskNode = TaskItem & { children: TaskNode[] };
function taskTree(tasks: TaskItem[]): TaskNode[] {
return tasks.filter((task) => task.parentId === null).map((task) => taskNode(task, tasks));
}
function taskNode(task: TaskItem, tasks: TaskItem[]): TaskNode {
return {
...task,
children: tasks.filter((candidate) => candidate.parentId === task.id).map((child) => taskNode(child, tasks))
};
}
+145 -15
View File
@@ -108,22 +108,86 @@ export class TaskTreeStore {
return (await this.pool.query("DELETE FROM tasktree_groups WHERE id=$1", [id])).rowCount === 1;
}
async createTask(input: { groupId: string; title: string; description?: string; startAt?: string; dueAt?: string; parentId?: string }): Promise<TaskItem> {
async groupStats(groupId: string) {
await this.ensureSchema();
let kind = "task";
if (input.parentId) {
const parent = await this.pool.query("SELECT group_id,kind FROM tasktree_tasks WHERE id=$1", [input.parentId]);
if (!parent.rows[0]) throw domainError("parent_not_found", "parent task was not found");
if (parent.rows[0].group_id !== input.groupId) throw domainError("parent_group_mismatch", "parent task belongs to another taskgroup");
if (parent.rows[0].kind === "subsubtask") throw domainError("maximum_depth_exceeded", "TaskTree supports task, subtask, and subsubtask only");
kind = parent.rows[0].kind === "task" ? "subtask" : "subsubtask";
const result = await this.pool.query(`
SELECT COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status='completed')::int AS completed,
COUNT(*) FILTER (WHERE status='in_progress')::int AS in_progress,
COUNT(*) FILTER (WHERE status='pending')::int AS pending,
COUNT(*) FILTER (WHERE status='blocked')::int AS blocked,
COUNT(*) FILTER (WHERE kind='task')::int AS tasks,
COUNT(*) FILTER (WHERE kind='subtask')::int AS subtasks,
COUNT(*) FILTER (WHERE kind='subsubtask')::int AS subsubtasks,
(SELECT COUNT(*)::int FROM tasktree_execution_reports r
JOIN tasktree_tasks rt ON rt.id=r.task_id WHERE rt.group_id=$1) AS reports
FROM tasktree_tasks WHERE group_id=$1
`, [groupId]);
const row = result.rows[0] ?? {};
return {
total: Number(row.total),
completed: Number(row.completed),
inProgress: Number(row.in_progress),
pending: Number(row.pending),
blocked: Number(row.blocked),
tasks: Number(row.tasks),
subtasks: Number(row.subtasks),
subsubtasks: Number(row.subsubtasks),
reports: Number(row.reports)
};
}
async listTasks(groupId: string): Promise<TaskItem[]> {
await this.ensureSchema();
const result = await this.pool.query("SELECT * FROM tasktree_tasks WHERE group_id=$1 ORDER BY sort_order,created_at", [groupId]);
return result.rows.map(taskRow);
}
async getTask(id: string): Promise<TaskItem | null> {
await this.ensureSchema();
const result = await this.pool.query("SELECT * FROM tasktree_tasks WHERE id=$1", [id]);
return result.rows[0] ? taskRow(result.rows[0]) : null;
}
async createTask(input: { groupId: string; title: string; description?: string; startAt?: string; dueAt?: string; parentId?: string }): Promise<TaskItem> {
return (await this.createTasks({ ...input, titles: [input.title] }))[0];
}
async createTasks(input: { groupId: string; titles: string[]; description?: string; startAt?: string; dueAt?: string; parentId?: string }): Promise<TaskItem[]> {
await this.ensureSchema();
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const group = await client.query("SELECT id FROM tasktree_groups WHERE id=$1 FOR UPDATE", [input.groupId]);
if (!group.rows[0]) throw domainError("group_not_found", "taskgroup was not found");
let kind: TaskItem["kind"] = "task";
if (input.parentId) {
const parent = await client.query("SELECT group_id,kind FROM tasktree_tasks WHERE id=$1", [input.parentId]);
if (!parent.rows[0]) throw domainError("parent_not_found", "parent task was not found");
if (parent.rows[0].group_id !== input.groupId) throw domainError("parent_group_mismatch", "parent task belongs to another taskgroup");
if (parent.rows[0].kind === "subsubtask") throw domainError("maximum_depth_exceeded", "TaskTree supports task, subtask, and subsubtask only");
kind = parent.rows[0].kind === "task" ? "subtask" : "subsubtask";
}
const orderResult = await client.query("SELECT COALESCE(MAX(sort_order),-1)::int AS value FROM tasktree_tasks WHERE group_id=$1", [input.groupId]);
const firstOrder = Number(orderResult.rows[0]?.value ?? -1) + 1;
const tasks: TaskItem[] = [];
for (let index = 0; index < input.titles.length; index += 1) {
const result = await client.query(
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,start_at,due_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[`tt_${randomUUID()}`, input.groupId, input.parentId ?? null, kind, input.titles[index], input.description ?? "", input.startAt ?? null, input.dueAt ?? null, firstOrder + index]
);
tasks.push(taskRow(result.rows[0]));
}
await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [input.groupId]);
await client.query("COMMIT");
return tasks;
} catch (error) {
await client.query("ROLLBACK").catch(() => {});
throw error;
} finally {
client.release();
}
const result = await this.pool.query(
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,start_at,due_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,(SELECT COALESCE(MAX(sort_order),-1)+1 FROM tasktree_tasks WHERE group_id=$2)) RETURNING *`,
[`tt_${randomUUID()}`, input.groupId, input.parentId ?? null, kind, input.title, input.description ?? "", input.startAt ?? null, input.dueAt ?? null]
);
return taskRow(result.rows[0]);
}
async updateTask(id: string, patch: { title?: string; description?: string; status?: TaskStatus; startAt?: string | null; dueAt?: string | null }): Promise<TaskItem | null> {
@@ -136,12 +200,41 @@ export class TaskTreeStore {
[id, patch.title ?? row.title, patch.description ?? row.description, patch.status ?? row.status,
patch.startAt === undefined ? row.startAt : patch.startAt, patch.dueAt === undefined ? row.dueAt : patch.dueAt]
);
await this.pool.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [row.groupId]);
return taskRow(result.rows[0]);
}
async deleteTask(id: string): Promise<boolean> {
await this.ensureSchema();
return (await this.pool.query("DELETE FROM tasktree_tasks WHERE id=$1", [id])).rowCount === 1;
const current = await this.getTask(id);
if (!current) return false;
const deleted = (await this.pool.query("DELETE FROM tasktree_tasks WHERE id=$1", [id])).rowCount === 1;
if (deleted) await this.pool.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [current.groupId]);
return deleted;
}
async completeTask(id: string): Promise<TaskItem | null> {
await this.ensureSchema();
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const current = await client.query("SELECT * FROM tasktree_tasks WHERE id=$1 FOR UPDATE", [id]);
if (!current.rows[0]) {
await client.query("ROLLBACK");
return null;
}
const reports = await client.query("SELECT COUNT(*)::int AS count FROM tasktree_execution_reports WHERE task_id=$1", [id]);
if (Number(reports.rows[0]?.count ?? 0) < 1) throw domainError("execution_report_required", "an execution report is required before completing a task");
const result = await client.query("UPDATE tasktree_tasks SET status='completed',updated_at=now() WHERE id=$1 RETURNING *", [id]);
await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [result.rows[0].group_id]);
await client.query("COMMIT");
return taskRow(result.rows[0]);
} catch (error) {
await client.query("ROLLBACK").catch(() => {});
throw error;
} finally {
client.release();
}
}
async createMilestone(input: { groupId: string; title: string; occursAt: string; taskId?: string }): Promise<Milestone> {
@@ -162,6 +255,43 @@ export class TaskTreeStore {
return reportRow(result.rows[0]);
}
async listReports(taskId: string): Promise<ExecutionReport[]> {
await this.ensureSchema();
const result = await this.pool.query("SELECT * FROM tasktree_execution_reports WHERE task_id=$1 ORDER BY created_at DESC,id", [taskId]);
return result.rows.map(reportRow);
}
async getReport(reportId: string): Promise<ExecutionReport | null> {
await this.ensureSchema();
const result = await this.pool.query("SELECT * FROM tasktree_execution_reports WHERE id=$1", [reportId]);
return result.rows[0] ? reportRow(result.rows[0]) : null;
}
async writeReport(input: { taskId: string; title: string; body: string; status?: string }): Promise<{ report: ExecutionReport; mutation: boolean }> {
await this.ensureSchema();
const task = await this.getTask(input.taskId);
if (!task) throw domainError("task_not_found", "task was not found");
const current = await this.pool.query(
"SELECT * FROM tasktree_execution_reports WHERE task_id=$1 AND title=$2 ORDER BY created_at DESC,id LIMIT 1",
[input.taskId, input.title]
);
const status = input.status ?? "succeeded";
if (current.rows[0] && current.rows[0].body === input.body && current.rows[0].status === status) {
return { report: reportRow(current.rows[0]), mutation: false };
}
const result = current.rows[0]
? await this.pool.query(
"UPDATE tasktree_execution_reports SET body=$2,status=$3,created_at=now() WHERE id=$1 RETURNING *",
[current.rows[0].id, input.body, status]
)
: await this.pool.query(
"INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5) RETURNING *",
[`tr_${randomUUID()}`, input.taskId, input.title, input.body, status]
);
await this.pool.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [task.groupId]);
return { report: reportRow(result.rows[0]), mutation: true };
}
async importMdtodo(plan: MdtodoImportPlan) {
await this.ensureSchema();
const client = await this.pool.connect();
+55 -3
View File
@@ -29,6 +29,31 @@ test("HTTP command endpoint returns the same dispatcher DTO", async () => {
assert.deepEqual(await response.json(), expected);
});
test("dispatcher keeps batch validation atomic and completion gated", async () => {
let batchCalls = 0;
let updateCalls = 0;
const store = {
async createTasks() { batchCalls += 1; return []; },
async updateTask() { updateCalls += 1; return null; },
async deleteTask() { return false; }
};
const dispatch = createTaskTreeDispatcher({ store: store as TaskTreeStore });
const batch = await dispatch({ operation: "task.create-batch", groupId: "tg_1", titles: ["valid", "invalid\nline"] });
assert.equal(batch.ok, false);
assert.equal(batch.error?.code, "invalid_title");
assert.equal(batchCalls, 0);
const bypass = await dispatch({ operation: "task.update", taskId: "tt_1", status: "completed" });
assert.equal(bypass.ok, false);
assert.equal(bypass.error?.code, "completion_command_required");
assert.equal(updateCalls, 0);
const missing = await dispatch({ operation: "task.delete", taskId: "tt_missing" });
assert.equal(missing.ok, false);
assert.equal(missing.error?.code, "task_not_found");
});
test("native PostgreSQL store exposes overview and three task levels", async () => {
const databaseUrl = process.env.TASKTREE_DATABASE_URL;
assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test");
@@ -43,23 +68,50 @@ test("native PostgreSQL store exposes overview and three task levels", async ()
});
const subtask = await store.createTask({ groupId: group.id, parentId: task.id, title: "Subtask" });
const subsubtask = await store.createTask({ groupId: group.id, parentId: subtask.id, title: "Subsubtask" });
const batch = await store.createTasks({ groupId: group.id, parentId: task.id, titles: ["Batch one", "Batch two"] });
assert.deepEqual(batch.map((item) => item.title), ["Batch one", "Batch two"]);
await store.createMilestone({ groupId: group.id, taskId: task.id, title: "Review", occursAt: "2026-07-17T00:00:00.000Z" });
await store.createReport({ taskId: subtask.id, title: "Execution", body: "passed" });
await assert.rejects(
store.completeTask(task.id),
(error: any) => error?.code === "execution_report_required"
);
const firstReport = await store.writeReport({ taskId: task.id, title: "Execution", body: "passed" });
const repeatedReport = await store.writeReport({ taskId: task.id, title: "Execution", body: "passed" });
const changedReport = await store.writeReport({ taskId: task.id, title: "Execution", body: "passed with evidence" });
assert.equal(firstReport.mutation, true);
assert.equal(repeatedReport.mutation, false);
assert.equal(changedReport.mutation, true);
assert.equal(firstReport.report.id, repeatedReport.report.id);
assert.equal(firstReport.report.id, changedReport.report.id);
assert.equal((await store.completeTask(task.id))?.status, "completed");
await assert.rejects(
store.createTask({ groupId: group.id, parentId: subsubtask.id, title: "Unsupported fourth level" }),
(error: any) => error?.code === "maximum_depth_exceeded"
);
const countBeforeRejectedBatch = (await store.listTasks(group.id)).length;
await assert.rejects(
store.createTasks({ groupId: group.id, parentId: subsubtask.id, titles: ["Unsupported one", "Unsupported two"] }),
(error: any) => error?.code === "maximum_depth_exceeded"
);
assert.equal((await store.listTasks(group.id)).length, countBeforeRejectedBatch);
const timeline = await store.timeline(group.id);
assert.equal(timeline?.tasks.length, 3);
assert.equal(timeline?.tasks.length, 5);
assert.equal(timeline?.tasks[0]?.kind, "task");
assert.equal(timeline?.tasks[1]?.kind, "subtask");
assert.equal(timeline?.tasks[2]?.kind, "subsubtask");
assert.equal(timeline?.milestones.length, 1);
assert.equal(timeline?.reports.length, 1);
assert.equal((await store.getTask(subtask.id))?.title, "Subtask");
assert.equal((await store.listReports(task.id)).length, 1);
assert.equal((await store.getReport(firstReport.report.id))?.body, "passed with evidence");
const stats = await store.groupStats(group.id);
assert.equal(stats.total, 5);
assert.equal(stats.completed, 1);
assert.equal(stats.reports, 1);
const overview = (await store.groupOverview()).find((item) => item.group.id === group.id);
assert.equal(overview?.taskCount, 1);
assert.equal(overview?.subtaskCount, 1);
assert.equal(overview?.subtaskCount, 3);
assert.equal(overview?.subsubtaskCount, 1);
assert.equal(overview?.reportCount, 1);
assert.equal(overview?.startAt, "2026-07-16T00:00:00.000Z");