feat: add Temporal-backed TaskTree service
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user