feat: add Temporal-backed TaskTree service

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-16 20:59:51 +02:00
parent de43ed5508
commit 23de918e94
32 changed files with 3410 additions and 3 deletions
+11 -1
View File
@@ -2,7 +2,17 @@
import { main } from "../../src/hwlab-cli-lib.ts";
const argv = process.argv.slice(2);
if (argv[0] === "kafka") {
if (argv[0] === "tasktree") {
const { runTaskTreeCli } = await import("../../src/tasktree-cli.ts");
try {
const result = await runTaskTreeCli(argv.slice(1));
console.log(JSON.stringify(result, null, 2));
process.exitCode = result?.ok === false ? 1 : 0;
} catch (error) {
console.log(JSON.stringify({ ok: false, operation: "tasktree", error: { code: (error as any)?.code ?? "tasktree_cli_error", message: (error as any)?.message ?? String(error) } }, null, 2));
process.exitCode = 1;
}
} else if (argv[0] === "kafka") {
const { mainKafkaCli } = await import("../../src/hwlab-cli/kafka-regenerate.ts");
await mainKafkaCli(argv.slice(1));
} else if (argv[0] === "case" && argv[1] === "audit") {
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { runTaskTreeCli } from "./tasktree-cli.ts";
test("--overapi preserves the command DTO and only changes transport", async () => {
const originalFetch = globalThis.fetch;
let received: unknown;
globalThis.fetch = (async (input, init) => {
assert.equal(String(input), "http://tasktree.test/v1/tasktree/commands");
received = JSON.parse(String(init?.body));
return Response.json({ ok: true, operation: "timeline.get", data: { group: { id: "tg_1" }, tasks: [], milestones: [], reports: [] } });
}) as typeof fetch;
try {
const result = await runTaskTreeCli(["timeline", "--group", "tg_1", "--overapi"], {
HWLAB_TASKTREE_API_URL: "http://tasktree.test"
});
assert.deepEqual(received, { operation: "timeline.get", groupId: "tg_1" });
assert.equal(result.transport, "api");
assert.equal(result.httpStatus, 200);
assert.equal(result.operation, "timeline.get");
} finally {
globalThis.fetch = originalFetch;
}
});
+81
View File
@@ -0,0 +1,81 @@
import { readFile } from "node:fs/promises";
import type { TaskStatus, TaskTreeCommand } from "../../internal/tasktree/contracts.ts";
import { createTaskTreeDispatcher } from "../../internal/tasktree/dispatcher.ts";
import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts";
export async function runTaskTreeCli(argv: string[], env: Record<string, string | undefined> = process.env) {
const parsed = parse(argv);
if (parsed.help || parsed.positionals.length === 0) return help();
const command = await commandFrom(parsed);
if (parsed.overapi) return overApi(command, env, parsed);
const runtime = taskTreeRuntime(env);
try {
return await createTaskTreeDispatcher(runtime)(command);
} finally {
await runtime.store.close();
}
}
function help() {
return {
ok: true,
operation: "help",
mode: "local-dispatcher-default",
usage: [
"hwlab-cli tasktree health [--overapi]",
"hwlab-cli tasktree group list|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 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 timeline --group ID [--overapi]",
"hwlab-cli tasktree workflow start --task ID"
],
transportContract: "--overapi only changes transport; commands and options are identical"
};
}
async function commandFrom(parsed: Parsed): Promise<TaskTreeCommand> {
const [group, action = ""] = parsed.positionals;
if (group === "health") return { operation: "health" };
if (group === "timeline") return { operation: "timeline.get", groupId: required(parsed, "group") };
if (group === "group" && action === "list") return { operation: "group.list" };
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 === "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 === "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 === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
throw new Error(`unsupported TaskTree command: ${group} ${action}`.trim());
}
async function overApi(command: TaskTreeCommand, env: Record<string, string | undefined>, parsed: Parsed) {
const baseUrl = parsed.values["api-url"] || env.HWLAB_TASKTREE_API_URL;
if (!baseUrl) throw new Error("HWLAB_TASKTREE_API_URL or --api-url is required with --overapi");
const response = await fetch(`${baseUrl.replace(/\/$/u, "")}/v1/tasktree/commands`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(command) });
const body = await response.json().catch(() => null);
if (!body || typeof body !== "object") throw new Error(`TaskTree API returned invalid JSON (${response.status})`);
return { ...(body as object), transport: "api", httpStatus: response.status };
}
type Parsed = { positionals: string[]; values: Record<string, string>; overapi: boolean; help: boolean };
function parse(argv: string[]): Parsed {
const parsed: Parsed = { positionals: [], values: {}, overapi: false, help: false };
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--overapi") parsed.overapi = true;
else if (token === "--help" || token === "-h") parsed.help = true;
else if (token.startsWith("--")) {
const key = token.slice(2);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`);
parsed.values[key] = value;
index += 1;
} else parsed.positionals.push(token);
}
return parsed;
}
function required(parsed: Parsed, key: string) { const value = parsed.values[key]; if (!value) throw new Error(`--${key} is required`); return value; }