diff --git a/internal/tasktree/activities.ts b/internal/tasktree/activities.ts index f55c8b68..5999bbb8 100644 --- a/internal/tasktree/activities.ts +++ b/internal/tasktree/activities.ts @@ -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 }; } }; diff --git a/internal/tasktree/contracts.ts b/internal/tasktree/contracts.ts index 18541351..e7d16887 100644 --- a/internal/tasktree/contracts.ts +++ b/internal/tasktree/contracts.ts @@ -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 }; diff --git a/internal/tasktree/dispatcher.ts b/internal/tasktree/dispatcher.ts index 8fb24f7d..41e90246 100644 --- a/internal/tasktree/dispatcher.ts +++ b/internal/tasktree/dispatcher.ts @@ -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(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)) + }; +} diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index 012ed685..d21150bc 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { @@ -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 { 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 { + 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 { @@ -162,6 +255,43 @@ export class TaskTreeStore { return reportRow(result.rows[0]); } + async listReports(taskId: string): Promise { + 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 { + 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(); diff --git a/internal/tasktree/tasktree.test.ts b/internal/tasktree/tasktree.test.ts index 722fac3f..b4a2f2ad 100644 --- a/internal/tasktree/tasktree.test.ts +++ b/internal/tasktree/tasktree.test.ts @@ -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"); diff --git a/tools/hwlab-cli/bin/hwlab-cli.ts b/tools/hwlab-cli/bin/hwlab-cli.ts index 0b2ce917..1b5385a7 100644 --- a/tools/hwlab-cli/bin/hwlab-cli.ts +++ b/tools/hwlab-cli/bin/hwlab-cli.ts @@ -5,7 +5,9 @@ const argv = process.argv.slice(2); if (argv[0] === "tasktree") { const { runTaskTreeCli } = await import("../../src/tasktree-cli.ts"); try { - const result = await runTaskTreeCli(argv.slice(1)); + const tasktreeArgs = argv.slice(1); + const stdin = tasktreeArgs.includes("--stdin") ? await Bun.stdin.text() : undefined; + const result = await runTaskTreeCli(tasktreeArgs, process.env, stdin); console.log(JSON.stringify(result, null, 2)); process.exitCode = result?.ok === false ? 1 : 0; } catch (error) { diff --git a/tools/src/tasktree-cli.test.ts b/tools/src/tasktree-cli.test.ts index 3377574a..5b365123 100644 --- a/tools/src/tasktree-cli.test.ts +++ b/tools/src/tasktree-cli.test.ts @@ -23,3 +23,60 @@ test("--overapi preserves the command DTO and only changes transport", async () globalThis.fetch = originalFetch; } }); + +test("TaskTree command surface covers hierarchy, lifecycle, batch, stats, and reports", async () => { + const originalFetch = globalThis.fetch; + const received: unknown[] = []; + globalThis.fetch = (async (_input, init) => { + const command = JSON.parse(String(init?.body)); + received.push(command); + return Response.json({ ok: true, operation: command.operation, data: {} }); + }) as typeof fetch; + const env = { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }; + try { + const cases: Array<[string[], unknown, string?]> = [ + [["group", "stats", "--group", "tg_1", "--overapi"], { operation: "group.stats", groupId: "tg_1" }], + [["task", "list", "--group", "tg_1", "--overapi"], { operation: "task.list", groupId: "tg_1" }], + [["task", "get", "--task", "tt_1", "--overapi"], { operation: "task.get", taskId: "tt_1" }], + [["task", "create", "--group", "tg_1", "--parent", "tt_1", "--stdin", "--overapi"], { + operation: "task.create", groupId: "tg_1", parentId: "tt_1", title: "Child" + }, "Child\n"], + [["task", "create-batch", "--group", "tg_1", "--parent", "tt_1", "One", "Two", "--overapi"], { + operation: "task.create-batch", groupId: "tg_1", parentId: "tt_1", titles: ["One", "Two"] + }], + [["task", "update", "--task", "tt_1", "--stdin", "--overapi"], { + operation: "task.update", taskId: "tt_1", title: "Renamed" + }, "Renamed\n"], + [["task", "start", "--task", "tt_1", "--overapi"], { operation: "task.start", taskId: "tt_1" }], + [["task", "done", "--task", "tt_1", "--overapi"], { operation: "task.complete", taskId: "tt_1" }], + [["task", "remove", "--task", "tt_1", "--overapi"], { operation: "task.delete", taskId: "tt_1" }], + [["report", "list", "--task", "tt_1", "--overapi"], { operation: "report.list", taskId: "tt_1" }], + [["report", "get", "--report", "tr_1", "--overapi"], { operation: "report.get", reportId: "tr_1" }], + [["report", "write", "--task", "tt_1", "--title", "Execution", "--stdin", "--overapi"], { + operation: "report.write", taskId: "tt_1", title: "Execution", body: "# Result\n\nPassed.\n" + }, "# Result\n\nPassed.\n"] + ]; + for (const [argv, expected, stdin] of cases) { + await runTaskTreeCli(argv, env, stdin); + assert.deepEqual(received.at(-1), expected); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("TaskTree help and public operations do not expose the legacy domain term", async () => { + const result = await runTaskTreeCli(["--help"]); + assert.doesNotMatch(JSON.stringify(result), /mdtodo/iu); +}); + +test("TaskTree rejects ambiguous title input and empty update patches before transport", async () => { + await assert.rejects( + runTaskTreeCli(["task", "create", "--group", "tg_1", "--title", "One", "--stdin", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }, "Two"), + (error: any) => error?.code === "title_source_conflict" + ); + await assert.rejects( + runTaskTreeCli(["task", "update", "--task", "tt_1", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }), + (error: any) => error?.code === "update_patch_required" + ); +}); diff --git a/tools/src/tasktree-cli.ts b/tools/src/tasktree-cli.ts index 35c72ddb..d8247add 100644 --- a/tools/src/tasktree-cli.ts +++ b/tools/src/tasktree-cli.ts @@ -5,10 +5,10 @@ import { createTaskTreeDispatcher } from "../../internal/tasktree/dispatcher.ts" import { loadMdtodoImportPayload } from "../../internal/tasktree/mdtodo-import.ts"; import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts"; -export async function runTaskTreeCli(argv: string[], env: Record = process.env) { +export async function runTaskTreeCli(argv: string[], env: Record = process.env, stdin?: string) { const parsed = parse(argv); if (parsed.help || parsed.positionals.length === 0) return help(); - const command = await commandFrom(parsed); + const command = await commandFrom(parsed, stdin); if (parsed.overapi) return overApi(command, env, parsed); const runtime = taskTreeRuntime(env); try { @@ -25,19 +25,26 @@ function help() { mode: "local-dispatcher-default", usage: [ "hwlab-cli tasktree health [--overapi]", - "hwlab-cli tasktree group list|overview|get|create|delete [--group ID] [--name TEXT]", - "hwlab-cli tasktree task create|update|delete --group ID|--task ID [--parent ID] --title TEXT --start ISO --due ISO --status STATUS", + "hwlab-cli tasktree group list|overview|get|create|delete|stats [--group ID] [--name TEXT]", + "hwlab-cli tasktree group import-markdown --file PATH [--name TEXT] [--dry-run]", + "hwlab-cli tasktree task list|get --group ID|--task ID", + "hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin)", + "hwlab-cli tasktree task create-batch --group ID [--parent ID] TITLE...", + "hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS]", + "hwlab-cli tasktree task start|complete|done|delete|remove --task ID", "hwlab-cli tasktree milestone create --group ID [--task ID] --title TEXT --at ISO", - "hwlab-cli tasktree report create --task ID --title TEXT (--body TEXT|--body-file PATH)", - "hwlab-cli tasktree import mdtodo --file PATH [--name TEXT] [--dry-run] [--overapi]", + "hwlab-cli tasktree report list|get --task ID|--report ID", + "hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin)", "hwlab-cli tasktree timeline --group ID [--overapi]", "hwlab-cli tasktree workflow start --task ID" ], - transportContract: "--overapi only changes transport; commands and options are identical" + transportContract: "--overapi only changes transport; commands and options are identical", + hierarchy: "taskgroup -> task -> subtask -> subsubtask", + completionContract: "a task requires at least one execution report before completion" }; } -async function commandFrom(parsed: Parsed): Promise { +async function commandFrom(parsed: Parsed, stdin?: string): Promise { const [group, action = ""] = parsed.positionals; if (group === "health") return { operation: "health" }; if (group === "timeline") return { operation: "timeline.get", groupId: required(parsed, "group") }; @@ -46,12 +53,54 @@ async function commandFrom(parsed: Parsed): Promise { if (group === "group" && action === "get") return { operation: "group.get", groupId: required(parsed, "group") }; if (group === "group" && action === "create") return { operation: "group.create", name: required(parsed, "name"), description: parsed.values.description }; if (group === "group" && action === "delete") return { operation: "group.delete", groupId: required(parsed, "group") }; - if (group === "task" && action === "create") return { operation: "task.create", groupId: required(parsed, "group"), parentId: parsed.values.parent, title: required(parsed, "title"), description: parsed.values.description, startAt: parsed.values.start, dueAt: parsed.values.due }; - if (group === "task" && action === "update") return { operation: "task.update", taskId: required(parsed, "task"), title: parsed.values.title, description: parsed.values.description, status: parsed.values.status as TaskStatus | undefined, startAt: parsed.values.start, dueAt: parsed.values.due }; - if (group === "task" && action === "delete") return { operation: "task.delete", taskId: required(parsed, "task") }; + if (group === "group" && action === "stats") return { operation: "group.stats", groupId: required(parsed, "group") }; + if (group === "group" && action === "import-markdown") return { + operation: "group.import-markdown", + ...await loadMdtodoImportPayload(required(parsed, "file"), { groupName: parsed.values.name, dryRun: parsed.flags.has("dry-run") }) + }; + if (group === "task" && action === "list") return { operation: "task.list", groupId: required(parsed, "group") }; + if (group === "task" && action === "get") return { operation: "task.get", taskId: required(parsed, "task") }; + if (group === "task" && action === "create") return { + operation: "task.create", + groupId: required(parsed, "group"), + parentId: parsed.values.parent, + title: titleInput(parsed, stdin), + description: parsed.values.description, + startAt: parsed.values.start, + dueAt: parsed.values.due + }; + if (group === "task" && action === "create-batch") return { + operation: "task.create-batch", + groupId: required(parsed, "group"), + parentId: parsed.values.parent, + titles: parsed.positionals.slice(2), + description: parsed.values.description, + startAt: parsed.values.start, + dueAt: parsed.values.due + }; + if (group === "task" && action === "update") { + const command: TaskTreeCommand = { + operation: "task.update", + taskId: required(parsed, "task"), + title: parsed.flags.has("stdin") ? titleInput(parsed, stdin) : parsed.values.title, + description: parsed.values.description, + status: parsed.values.status as TaskStatus | undefined, + startAt: nullableDateOption(parsed, "start"), + dueAt: nullableDateOption(parsed, "due") + }; + if (command.title === undefined && command.description === undefined && command.status === undefined && command.startAt === undefined && command.dueAt === undefined) { + throw codedError("update_patch_required", "task update requires at least one field"); + } + return command; + } + if (group === "task" && (action === "delete" || action === "remove")) return { operation: "task.delete", taskId: required(parsed, "task") }; + if (group === "task" && action === "start") return { operation: "task.start", taskId: required(parsed, "task") }; + if (group === "task" && (action === "complete" || action === "done")) return { operation: "task.complete", taskId: required(parsed, "task") }; if (group === "milestone" && action === "create") return { operation: "milestone.create", groupId: required(parsed, "group"), taskId: parsed.values.task, title: required(parsed, "title"), occursAt: required(parsed, "at") }; + if (group === "report" && action === "list") return { operation: "report.list", taskId: required(parsed, "task") }; + if (group === "report" && action === "get") return { operation: "report.get", reportId: required(parsed, "report") }; + if (group === "report" && action === "write") return { operation: "report.write", taskId: required(parsed, "task"), title: required(parsed, "title"), body: await bodyInput(parsed, stdin), status: parsed.values.status }; if (group === "report" && action === "create") return { operation: "report.create", taskId: required(parsed, "task"), title: required(parsed, "title"), body: parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8"), status: parsed.values.status }; - if (group === "import" && action === "mdtodo") return { operation: "mdtodo.import", ...await loadMdtodoImportPayload(required(parsed, "file"), { groupName: parsed.values.name, dryRun: parsed.flags.has("dry-run") }) }; if (group === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") }; throw new Error(`unsupported TaskTree command: ${group} ${action}`.trim()); } @@ -72,6 +121,7 @@ function parse(argv: string[]): Parsed { const token = argv[index]; if (token === "--overapi") parsed.overapi = true; else if (token === "--dry-run") parsed.flags.add("dry-run"); + else if (token === "--stdin") parsed.flags.add("stdin"); else if (token === "--help" || token === "-h") parsed.help = true; else if (token.startsWith("--")) { const key = token.slice(2); @@ -84,3 +134,21 @@ function parse(argv: string[]): Parsed { return parsed; } function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw new Error(`--${key} is required`); return value; } +function titleInput(parsed: Parsed, stdin?: string) { + if (parsed.flags.has("stdin") && parsed.values.title !== undefined) throw codedError("title_source_conflict", "--title and --stdin are mutually exclusive"); + const value = parsed.flags.has("stdin") ? stripOneLineEnding(stdin ?? "") : parsed.values.title; + if (value === undefined || value.length === 0) throw codedError("title_required", "--title or --stdin is required"); + return value; +} +async function bodyInput(parsed: Parsed, stdin?: string) { + const sources = Number(parsed.values.body !== undefined) + Number(parsed.values["body-file"] !== undefined) + Number(parsed.flags.has("stdin")); + if (sources !== 1) throw codedError("body_source_invalid", "exactly one of --body, --body-file, or --stdin is required"); + if (parsed.flags.has("stdin")) return stdin ?? ""; + return parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8"); +} +function nullableDateOption(parsed: Parsed, key: string): string | null | undefined { + const value = parsed.values[key]; + return value === "null" ? null : value; +} +function stripOneLineEnding(value: string) { return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; } +function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); } diff --git a/web/hwlab-cloud-web/scripts/tasktree-native-service.ts b/web/hwlab-cloud-web/scripts/tasktree-native-service.ts index 45dada89..242c8996 100644 --- a/web/hwlab-cloud-web/scripts/tasktree-native-service.ts +++ b/web/hwlab-cloud-web/scripts/tasktree-native-service.ts @@ -21,6 +21,13 @@ function isRunning(pid: number | null): boolean { if (!pid) return false; try { process.kill(pid, 0); + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const state = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[0]; + if (state === "Z") return false; + } catch { + // Non-Linux hosts do not expose /proc; the signal probe remains authoritative there. + } return true; } catch { return false; @@ -28,7 +35,7 @@ function isRunning(pid: number | null): boolean { } async function waitForExit(pid: number): Promise { - const deadline = Date.now() + 5000; + const deadline = Date.now() + 15000; while (Date.now() < deadline) { if (!isRunning(pid)) return true; await Bun.sleep(25);