feat: complete TaskTree CLI parity

This commit is contained in:
root
2026-07-18 06:41:31 +02:00
parent 16fed1bdba
commit cc1a946ef2
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");
+3 -1
View File
@@ -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) {
+57
View File
@@ -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"
);
});
+80 -12
View File
@@ -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<string, string | undefined> = process.env) {
export async function runTaskTreeCli(argv: string[], env: Record<string, string | undefined> = 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<TaskTreeCommand> {
async function commandFrom(parsed: Parsed, stdin?: string): Promise<TaskTreeCommand> {
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<TaskTreeCommand> {
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 }); }
@@ -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<boolean> {
const deadline = Date.now() + 5000;
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (!isRunning(pid)) return true;
await Bun.sleep(25);