feat: add Temporal-backed TaskTree service
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ spec:
|
||||
- name: registry-prefix
|
||||
value: 127.0.0.1:5000/hwlab
|
||||
- name: services
|
||||
value: "hwlab-cloud-api,hwlab-workbench-runtime,hwlab-user-billing,hwlab-project-management,hwlab-cloud-web,hwlab-gateway,hwlab-edge-proxy,hwlab-agent-skills"
|
||||
value: "hwlab-cloud-api,hwlab-workbench-runtime,hwlab-user-billing,hwlab-project-management,hwlab-tasktree-api,hwlab-tasktree-worker,hwlab-cloud-web,hwlab-gateway,hwlab-edge-proxy,hwlab-agent-skills"
|
||||
- name: base-image
|
||||
value: 127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim
|
||||
- name: build-cache-mode
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createTaskTreeDispatcher } from "../../internal/tasktree/dispatcher.ts";
|
||||
import { createTaskTreeHttpApp } from "../../internal/tasktree/http.ts";
|
||||
import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts";
|
||||
|
||||
const runtime = taskTreeRuntime();
|
||||
const dispatch = createTaskTreeDispatcher(runtime);
|
||||
const app = createTaskTreeHttpApp({ dispatch, close: () => runtime.store.close() });
|
||||
const host = process.env.TASKTREE_API_HOST || "0.0.0.0";
|
||||
const port = Number.parseInt(process.env.TASKTREE_API_PORT || process.env.PORT || "6673", 10);
|
||||
const server = Bun.serve({ hostname: host, port, fetch: (request) => app.fetch(request) });
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-tasktree-api", status: "listening", host, port: server.port })}\n`);
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.once(signal, async () => {
|
||||
server.stop(true);
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bun
|
||||
import { NativeConnection, Worker } from "@temporalio/worker";
|
||||
import path from "node:path";
|
||||
|
||||
import { createTaskTreeActivities } from "../../internal/tasktree/activities.ts";
|
||||
import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts";
|
||||
|
||||
const runtime = taskTreeRuntime();
|
||||
if (!runtime.temporalAddress) throw new Error("TASKTREE_TEMPORAL_ADDRESS is required");
|
||||
await runtime.store.ensureSchema();
|
||||
const connection = await NativeConnection.connect({ address: runtime.temporalAddress });
|
||||
const worker = await Worker.create({
|
||||
connection,
|
||||
namespace: runtime.temporalNamespace,
|
||||
taskQueue: runtime.taskQueue,
|
||||
workflowsPath: path.resolve(import.meta.dir, "../../internal/tasktree/workflows.ts"),
|
||||
activities: createTaskTreeActivities(runtime.store)
|
||||
});
|
||||
const healthPort = Number.parseInt(process.env.TASKTREE_WORKER_HEALTH_PORT || "6674", 10);
|
||||
const health = Bun.serve({
|
||||
hostname: process.env.TASKTREE_WORKER_HEALTH_HOST || "0.0.0.0",
|
||||
port: healthPort,
|
||||
fetch(request) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (pathname !== "/health/live" && pathname !== "/health/ready") return Response.json({ ok: false, error: "not_found" }, { status: 404 });
|
||||
return Response.json({ ok: true, serviceId: "hwlab-tasktree-worker", namespace: runtime.temporalNamespace, taskQueue: runtime.taskQueue });
|
||||
}
|
||||
});
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-tasktree-worker", status: "started", namespace: runtime.temporalNamespace, taskQueue: runtime.taskQueue })}\n`);
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => worker.shutdown());
|
||||
try {
|
||||
await worker.run();
|
||||
} finally {
|
||||
health.stop(true);
|
||||
await connection.close();
|
||||
await runtime.store.close();
|
||||
process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-tasktree-worker", status: "stopped" })}\n`);
|
||||
}
|
||||
@@ -14,3 +14,4 @@ spec:
|
||||
allowedIds:
|
||||
- workbench.code
|
||||
- project.mdtodo
|
||||
- project.tasktree
|
||||
|
||||
@@ -509,6 +509,8 @@ lanes:
|
||||
- hwlab-workbench-runtime
|
||||
- hwlab-user-billing
|
||||
- hwlab-project-management
|
||||
- hwlab-tasktree-api
|
||||
- hwlab-tasktree-worker
|
||||
- hwlab-cloud-web
|
||||
- hwlab-gateway
|
||||
- hwlab-edge-proxy
|
||||
@@ -518,6 +520,8 @@ lanes:
|
||||
hwlab-workbench-runtime: deploy/runtime/boot/hwlab-workbench-runtime.sh
|
||||
hwlab-user-billing: deploy/runtime/boot/hwlab-user-billing.sh
|
||||
hwlab-project-management: deploy/runtime/boot/hwlab-project-management.sh
|
||||
hwlab-tasktree-api: deploy/runtime/boot/hwlab-tasktree-api.sh
|
||||
hwlab-tasktree-worker: deploy/runtime/boot/hwlab-tasktree-worker.sh
|
||||
hwlab-cloud-web: deploy/runtime/boot/hwlab-cloud-web.sh
|
||||
hwlab-gateway: deploy/runtime/boot/hwlab-gateway.sh
|
||||
hwlab-edge-proxy: deploy/runtime/boot/hwlab-edge-proxy.sh
|
||||
@@ -633,6 +637,37 @@ lanes:
|
||||
env:
|
||||
TZ: Asia/Shanghai
|
||||
observable: true
|
||||
hwlab-tasktree-api:
|
||||
runtimeKind: bun-command
|
||||
entrypoint: cmd/hwlab-tasktree-api/main.ts
|
||||
artifactKind: bun-command
|
||||
healthPath: /health/ready
|
||||
healthPort: 6673
|
||||
componentPaths:
|
||||
- cmd/hwlab-tasktree-api/
|
||||
- internal/tasktree/
|
||||
- tools/src/tasktree-cli.ts
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- deploy/runtime/boot/hwlab-tasktree-api.sh
|
||||
env:
|
||||
TZ: Asia/Shanghai
|
||||
observable: true
|
||||
hwlab-tasktree-worker:
|
||||
runtimeKind: bun-command
|
||||
entrypoint: cmd/hwlab-tasktree-worker/main.ts
|
||||
artifactKind: bun-command
|
||||
healthPath: /health/ready
|
||||
healthPort: 6674
|
||||
componentPaths:
|
||||
- cmd/hwlab-tasktree-worker/
|
||||
- internal/tasktree/
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- deploy/runtime/boot/hwlab-tasktree-worker.sh
|
||||
env:
|
||||
TZ: Asia/Shanghai
|
||||
observable: true
|
||||
hwlab-cloud-web:
|
||||
runtimeKind: cloud-web
|
||||
entrypoint: web/hwlab-cloud-web/index.html
|
||||
@@ -860,6 +895,7 @@ lanes:
|
||||
HWLAB_USER_BILLING_URL: http://hwlab-user-billing.hwlab-v03.svc.cluster.local:6670
|
||||
HWLAB_WORKBENCH_RUNTIME_URL: http://hwlab-workbench-runtime.hwlab-v03.svc.cluster.local:6671
|
||||
HWLAB_PROJECT_MANAGEMENT_URL: http://hwlab-project-management.hwlab-v03.svc.cluster.local:6672
|
||||
HWLAB_TASKTREE_URL: http://hwlab-tasktree-api.hwlab-v03.svc.cluster.local:6673
|
||||
HWLAB_HWPOD_SPEC_REGISTRY_DIRS: /etc/hwlab/hwpod-specs
|
||||
HWLAB_HWPOD_NODE_WS_TOKEN: secretRef:hwlab-v03-hwpod-node-auth/token
|
||||
HWLAB_USER_BILLING_LOGIN_RETRY_ATTEMPTS: "2"
|
||||
@@ -967,6 +1003,24 @@ lanes:
|
||||
configMapName: hwlab-v03-project-management-bootstrap
|
||||
mountPath: /etc/hwlab/project-management
|
||||
readOnly: true
|
||||
- serviceId: hwlab-tasktree-api
|
||||
replicas: 1
|
||||
env:
|
||||
TASKTREE_DATABASE_URL: secretRef:hwlab-tasktree-db/database-url
|
||||
TASKTREE_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233
|
||||
TASKTREE_TEMPORAL_NAMESPACE: unidesk
|
||||
TASKTREE_TEMPORAL_TASK_QUEUE: hwlab-v03-tasktree
|
||||
TASKTREE_API_PORT: "6673"
|
||||
OTEL_SERVICE_NAME: hwlab-tasktree-api
|
||||
- serviceId: hwlab-tasktree-worker
|
||||
replicas: 1
|
||||
env:
|
||||
TASKTREE_DATABASE_URL: secretRef:hwlab-tasktree-db/database-url
|
||||
TASKTREE_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233
|
||||
TASKTREE_TEMPORAL_NAMESPACE: unidesk
|
||||
TASKTREE_TEMPORAL_TASK_QUEUE: hwlab-v03-tasktree
|
||||
TASKTREE_WORKER_HEALTH_PORT: "6674"
|
||||
OTEL_SERVICE_NAME: hwlab-tasktree-worker
|
||||
production:
|
||||
name: Production
|
||||
node: NC01
|
||||
@@ -1031,6 +1085,7 @@ lanes:
|
||||
HWLAB_USER_BILLING_URL: http://hwlab-user-billing.hwlab-production.svc.cluster.local:6670
|
||||
HWLAB_WORKBENCH_RUNTIME_URL: http://hwlab-workbench-runtime.hwlab-production.svc.cluster.local:6671
|
||||
HWLAB_PROJECT_MANAGEMENT_URL: http://hwlab-project-management.hwlab-production.svc.cluster.local:6672
|
||||
HWLAB_TASKTREE_URL: http://hwlab-tasktree-api.hwlab-production.svc.cluster.local:6673
|
||||
HWLAB_USER_BILLING_LOGIN_RETRY_ATTEMPTS: "2"
|
||||
HWLAB_USER_BILLING_LOGIN_RETRY_DELAY_MS: "250"
|
||||
HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1"
|
||||
@@ -1083,9 +1138,33 @@ lanes:
|
||||
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY: secretRef:hwlab-production-master-server-admin-api-key/api-key
|
||||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces
|
||||
OTEL_SERVICE_NAME: hwlab-project-management
|
||||
- serviceId: hwlab-tasktree-api
|
||||
replicas: 1
|
||||
env:
|
||||
TASKTREE_DATABASE_URL: secretRef:hwlab-tasktree-db/database-url
|
||||
TASKTREE_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233
|
||||
TASKTREE_TEMPORAL_NAMESPACE: unidesk
|
||||
TASKTREE_TEMPORAL_TASK_QUEUE: hwlab-production-tasktree
|
||||
TASKTREE_API_PORT: "6673"
|
||||
OTEL_SERVICE_NAME: hwlab-tasktree-api
|
||||
- serviceId: hwlab-tasktree-worker
|
||||
replicas: 1
|
||||
env:
|
||||
TASKTREE_DATABASE_URL: secretRef:hwlab-tasktree-db/database-url
|
||||
TASKTREE_TEMPORAL_ADDRESS: temporal-frontend.temporal.svc.cluster.local:7233
|
||||
TASKTREE_TEMPORAL_NAMESPACE: unidesk
|
||||
TASKTREE_TEMPORAL_TASK_QUEUE: hwlab-production-tasktree
|
||||
TASKTREE_WORKER_HEALTH_PORT: "6674"
|
||||
OTEL_SERVICE_NAME: hwlab-tasktree-worker
|
||||
envRecipe: *v03EnvRecipe
|
||||
bootConfig: *v03BootConfig
|
||||
services:
|
||||
- serviceId: hwlab-tasktree-api
|
||||
namespace: hwlab-dev
|
||||
healthPath: /health/live
|
||||
- serviceId: hwlab-tasktree-worker
|
||||
namespace: hwlab-dev
|
||||
healthPath: /health/live
|
||||
- serviceId: hwlab-cloud-api
|
||||
namespace: hwlab-dev
|
||||
healthPath: /health/live
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
"apiVersion": "v1",
|
||||
"kind": "List",
|
||||
"items": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Service",
|
||||
"metadata": {
|
||||
"name": "hwlab-tasktree-api",
|
||||
"namespace": "hwlab-dev",
|
||||
"labels": { "app.kubernetes.io/name": "hwlab-tasktree-api", "hwlab.pikastech.local/service-id": "hwlab-tasktree-api" }
|
||||
},
|
||||
"spec": {
|
||||
"type": "ClusterIP",
|
||||
"selector": { "app.kubernetes.io/name": "hwlab-tasktree-api" },
|
||||
"ports": [{ "name": "http", "port": 6673, "targetPort": "http" }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Service",
|
||||
"metadata": {
|
||||
"name": "hwlab-tasktree-worker",
|
||||
"namespace": "hwlab-dev",
|
||||
"labels": { "app.kubernetes.io/name": "hwlab-tasktree-worker", "hwlab.pikastech.local/service-id": "hwlab-tasktree-worker" }
|
||||
},
|
||||
"spec": {
|
||||
"type": "ClusterIP",
|
||||
"selector": { "app.kubernetes.io/name": "hwlab-tasktree-worker" },
|
||||
"ports": [{ "name": "http", "port": 6674, "targetPort": "http" }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Service",
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
"apiVersion": "v1",
|
||||
"kind": "List",
|
||||
"items": [
|
||||
{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": {
|
||||
"name": "hwlab-tasktree-api",
|
||||
"namespace": "hwlab-dev",
|
||||
"labels": { "app.kubernetes.io/name": "hwlab-tasktree-api", "hwlab.pikastech.local/service-id": "hwlab-tasktree-api" }
|
||||
},
|
||||
"spec": {
|
||||
"replicas": 0,
|
||||
"selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-tasktree-api" } },
|
||||
"template": {
|
||||
"metadata": { "labels": { "app.kubernetes.io/name": "hwlab-tasktree-api", "hwlab.pikastech.local/service-id": "hwlab-tasktree-api" } },
|
||||
"spec": { "containers": [{
|
||||
"name": "hwlab-tasktree-api",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-tasktree-api:dev",
|
||||
"ports": [{ "name": "http", "containerPort": 6673 }],
|
||||
"env": [{ "name": "TASKTREE_API_PORT", "value": "6673" }],
|
||||
"readinessProbe": { "httpGet": { "path": "/health/ready", "port": "http" } },
|
||||
"livenessProbe": { "httpGet": { "path": "/health/live", "port": "http" } }
|
||||
}] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": {
|
||||
"name": "hwlab-tasktree-worker",
|
||||
"namespace": "hwlab-dev",
|
||||
"labels": { "app.kubernetes.io/name": "hwlab-tasktree-worker", "hwlab.pikastech.local/service-id": "hwlab-tasktree-worker" }
|
||||
},
|
||||
"spec": {
|
||||
"replicas": 0,
|
||||
"selector": { "matchLabels": { "app.kubernetes.io/name": "hwlab-tasktree-worker" } },
|
||||
"template": {
|
||||
"metadata": { "labels": { "app.kubernetes.io/name": "hwlab-tasktree-worker", "hwlab.pikastech.local/service-id": "hwlab-tasktree-worker" } },
|
||||
"spec": { "containers": [{
|
||||
"name": "hwlab-tasktree-worker",
|
||||
"image": "127.0.0.1:5000/hwlab/hwlab-tasktree-worker:dev",
|
||||
"ports": [{ "name": "http", "containerPort": 6674 }],
|
||||
"env": [{ "name": "TASKTREE_WORKER_HEALTH_PORT", "value": "6674" }],
|
||||
"readinessProbe": { "httpGet": { "path": "/health/ready", "port": "http" } },
|
||||
"livenessProbe": { "httpGet": { "path": "/health/live", "port": "http" } }
|
||||
}] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "PersistentVolumeClaim",
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
export HWLAB_SERVICE_ID=hwlab-tasktree-api
|
||||
export HWLAB_SERVICE_ENTRYPOINT=cmd/hwlab-tasktree-api/main.ts
|
||||
export TASKTREE_API_PORT="${TASKTREE_API_PORT:-6673}"
|
||||
export PORT="$TASKTREE_API_PORT"
|
||||
|
||||
exec "${HWLAB_BUN_COMMAND:-bun}" run cmd/hwlab-tasktree-api/main.ts
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
export HWLAB_SERVICE_ID=hwlab-tasktree-worker
|
||||
export HWLAB_SERVICE_ENTRYPOINT=cmd/hwlab-tasktree-worker/main.ts
|
||||
export TASKTREE_WORKER_HEALTH_PORT="${TASKTREE_WORKER_HEALTH_PORT:-6674}"
|
||||
export PORT="$TASKTREE_WORKER_HEALTH_PORT"
|
||||
|
||||
exec "${HWLAB_BUN_COMMAND:-bun}" run cmd/hwlab-tasktree-worker/main.ts
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
import { HWPOD_TOPOLOGY_CONTRACT_VERSION } from "./hwpod-topology-read-model.ts";
|
||||
import { handleCaseRunHttp } from "./server-caserun-http.ts";
|
||||
import { handleProjectManagementProxyHttp } from "./project-management-proxy.ts";
|
||||
import { handleTaskTreeProxyHttp } from "./tasktree-proxy.ts";
|
||||
import { handleDashboardSummaryHttp } from "./server-dashboard-http.ts";
|
||||
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
||||
|
||||
@@ -718,6 +719,10 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
await handleProjectManagementProxyHttp(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/v1/tasktree" || url.pathname.startsWith("/v1/tasktree/")) {
|
||||
await handleTaskTreeProxyHttp(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/users/me/profile" && request.method === "PATCH") {
|
||||
await handleUserBillingProfileHttp(request, response, options);
|
||||
@@ -1014,6 +1019,7 @@ function navIdForRestPath(pathname, method = "GET") {
|
||||
if (pathname.startsWith("/v1/admin/billing/")) return "admin.billing";
|
||||
if (pathname === "/v1/skills" || pathname.startsWith("/v1/skills/")) return "system.skills";
|
||||
if (pathname === "/v1/project-management" || pathname.startsWith("/v1/project-management/")) return "project.mdtodo";
|
||||
if (pathname === "/v1/tasktree" || pathname.startsWith("/v1/tasktree/")) return "project.tasktree";
|
||||
if (pathname === "/v1/api-keys" || pathname === "/v1/api-keys/default" || pathname.startsWith("/v1/api-keys/")) return "user.apiKeys";
|
||||
if (pathname === "/v1/users/me/profile" || pathname === "/v1/users/me/password") return "system.settings";
|
||||
if (pathname === "/v1/workbench/debug/fake-sse" || pathname.startsWith("/v1/workbench/debug/fake-sse/") || pathname === "/v1/workbench/debug/kafka-sse" || pathname.startsWith("/v1/workbench/debug/kafka-sse/")) return "workbench.debug";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { handleTaskTreeProxyHttp } from "./tasktree-proxy.ts";
|
||||
|
||||
test("TaskTree proxy requires auth and forwards the unchanged API path", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let upstreamUrl = "";
|
||||
globalThis.fetch = (async (input) => {
|
||||
upstreamUrl = String(input);
|
||||
return Response.json({ ok: true, operation: "group.list", data: { groups: [] } });
|
||||
}) as typeof fetch;
|
||||
const response = captureResponse();
|
||||
try {
|
||||
await handleTaskTreeProxyHttp(
|
||||
{ method: "GET", headers: {} },
|
||||
response,
|
||||
new URL("http://cloud.test/v1/tasktree/groups"),
|
||||
{
|
||||
env: { HWLAB_TASKTREE_URL: "http://hwlab-tasktree-api.hwlab-v03.svc.cluster.local:6673" },
|
||||
accessController: { async authenticate() { return { ok: true, actor: { id: "usr_test" } }; } }
|
||||
}
|
||||
);
|
||||
assert.equal(upstreamUrl, "http://hwlab-tasktree-api.hwlab-v03.svc.cluster.local:6673/v1/tasktree/groups");
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(JSON.parse(response.body), { ok: true, operation: "group.list", data: { groups: [] } });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
function captureResponse() {
|
||||
return {
|
||||
status: 0,
|
||||
body: "",
|
||||
writeHead(status: number) { this.status = status; },
|
||||
end(body: string) { this.body = body; }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readBody, sendJson } from "./server-http-utils.ts";
|
||||
|
||||
export async function handleTaskTreeProxyHttp(request, response, url, options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const baseUrl = String(env.HWLAB_TASKTREE_URL ?? "").trim();
|
||||
if (!baseUrl) return sendJson(response, 503, { ok: false, error: { code: "tasktree_service_unconfigured", message: "HWLAB_TASKTREE_URL is required" }, valuesRedacted: true });
|
||||
const auth = await options.accessController.authenticate(request, { required: true });
|
||||
if (!auth.ok) return sendJson(response, auth.status, auth);
|
||||
try {
|
||||
const upstreamUrl = new URL(`${url.pathname}${url.search}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
|
||||
const headers = new Headers({ accept: "application/json", "x-hwlab-bridge-service": "hwlab-cloud-api" });
|
||||
const contentType = request.headers["content-type"];
|
||||
if (contentType) headers.set("content-type", Array.isArray(contentType) ? contentType[0] : contentType);
|
||||
if (auth.actor?.id) headers.set("x-hwlab-actor-id", auth.actor.id);
|
||||
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await readBody(request, options.bodyLimitBytes);
|
||||
const upstream = await fetch(upstreamUrl, { method: request.method, headers, body, signal: AbortSignal.timeout(Number(env.HWLAB_TASKTREE_PROXY_TIMEOUT_MS || 30000)) });
|
||||
response.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") || "application/json; charset=utf-8", "cache-control": "no-store", "x-hwlab-proxy-service": "hwlab-tasktree-api" });
|
||||
response.end(await upstream.text() || "\n");
|
||||
} catch (error) {
|
||||
sendJson(response, 502, { ok: false, error: { code: "tasktree_proxy_failed", message: error?.message ?? String(error) }, valuesRedacted: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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" });
|
||||
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" });
|
||||
return { taskId: task.id, reportId: report.id };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
|
||||
export type TaskKind = "task" | "subtask";
|
||||
|
||||
export type TaskGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
groupId: string;
|
||||
parentId: string | null;
|
||||
kind: TaskKind;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
startAt: string | null;
|
||||
dueAt: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Milestone = {
|
||||
id: string;
|
||||
groupId: string;
|
||||
taskId: string | null;
|
||||
title: string;
|
||||
occursAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ExecutionReport = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type Timeline = {
|
||||
group: TaskGroup;
|
||||
tasks: TaskItem[];
|
||||
milestones: Milestone[];
|
||||
reports: ExecutionReport[];
|
||||
};
|
||||
|
||||
export type TaskTreeCommand =
|
||||
| { operation: "health" }
|
||||
| { operation: "group.list" }
|
||||
| { operation: "group.get"; groupId: string }
|
||||
| { operation: "group.create"; name: string; description?: string }
|
||||
| { operation: "group.delete"; groupId: string }
|
||||
| { operation: "task.create"; groupId: string; title: string; description?: string; startAt?: string; dueAt?: string; parentId?: string }
|
||||
| { operation: "task.update"; taskId: string; title?: string; description?: string; status?: TaskStatus; startAt?: string | null; dueAt?: string | null }
|
||||
| { operation: "task.delete"; taskId: string }
|
||||
| { operation: "milestone.create"; groupId: string; title: string; occursAt: string; taskId?: string }
|
||||
| { operation: "report.create"; taskId: string; title: string; body: string; status?: string }
|
||||
| { operation: "timeline.get"; groupId: string }
|
||||
| { operation: "workflow.start"; taskId: string };
|
||||
|
||||
export type TaskTreeResult = {
|
||||
ok: boolean;
|
||||
operation: TaskTreeCommand["operation"];
|
||||
data?: unknown;
|
||||
error?: { code: string; message: string; detail?: unknown };
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Client, Connection } from "@temporalio/client";
|
||||
|
||||
import type { TaskTreeCommand, TaskTreeResult } from "./contracts.ts";
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
|
||||
export type TaskTreeDispatcherOptions = {
|
||||
store: TaskTreeStore;
|
||||
temporalAddress?: string;
|
||||
temporalNamespace?: string;
|
||||
taskQueue?: string;
|
||||
};
|
||||
|
||||
export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) {
|
||||
const { store } = options;
|
||||
return async function dispatch(command: TaskTreeCommand): Promise<TaskTreeResult> {
|
||||
try {
|
||||
const operation = command.operation;
|
||||
let data: unknown;
|
||||
if (operation === "health") data = await store.health();
|
||||
else if (operation === "group.list") data = { groups: await store.listGroups() };
|
||||
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 === "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)}`);
|
||||
return { ok: true, operation, data };
|
||||
} catch (error: any) {
|
||||
return { ok: false, operation: command.operation, error: { code: error?.code ?? "tasktree_error", message: error?.message ?? String(error) } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function startTaskWorkflow(options: TaskTreeDispatcherOptions, taskId: string) {
|
||||
if (!options.temporalAddress) throw codedError("temporal_address_required", "Temporal address is required for workflow.start");
|
||||
const connection = await Connection.connect({ address: options.temporalAddress });
|
||||
try {
|
||||
const client = new Client({ connection, namespace: options.temporalNamespace ?? "unidesk" });
|
||||
const workflowId = `tasktree-${taskId}-${Date.now()}`;
|
||||
const handle = await client.workflow.start("taskExecutionWorkflow", { taskQueue: options.taskQueue ?? "hwlab-v03-tasktree", workflowId, args: [{ taskId }] });
|
||||
return { workflowId, runId: handle.firstExecutionRunId, taskId, result: await handle.result() };
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
function required<T>(value: T | null, code: string, message: string): T { if (value === null) throw codedError(code, message); return value; }
|
||||
function requiredText(value: unknown, field: string): string { const text = String(value ?? "").trim(); if (!text) throw codedError("invalid_input", `${field} is required`); return text; }
|
||||
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 }); }
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TaskTreeCommand } from "./contracts.ts";
|
||||
|
||||
export function createTaskTreeHttpApp(options: { dispatch: (command: TaskTreeCommand) => Promise<any>; close?: () => Promise<void> }) {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-tasktree-api", status: "live" });
|
||||
if (url.pathname === "/health/ready") return resultResponse(await options.dispatch({ operation: "health" }));
|
||||
if (url.pathname === "/v1/tasktree/groups" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.list" }));
|
||||
if (url.pathname === "/v1/tasktree/groups" && request.method === "POST") {
|
||||
const body = await bodyObject(request);
|
||||
return resultResponse(await options.dispatch({ operation: "group.create", name: text(body.name), description: text(body.description) }), 201);
|
||||
}
|
||||
const timeline = /^\/v1\/tasktree\/groups\/([^/]+)\/timeline$/u.exec(url.pathname);
|
||||
if (timeline && request.method === "GET") return resultResponse(await options.dispatch({ operation: "timeline.get", groupId: decodeURIComponent(timeline[1]) }));
|
||||
if (url.pathname === "/v1/tasktree/commands" && request.method === "POST") {
|
||||
return resultResponse(await options.dispatch(await bodyObject(request) as TaskTreeCommand));
|
||||
}
|
||||
return json(404, { ok: false, error: { code: "not_found", message: "TaskTree route was not found" } });
|
||||
},
|
||||
close: options.close ?? (async () => {})
|
||||
};
|
||||
}
|
||||
|
||||
function resultResponse(result: any, successStatus = 200) {
|
||||
const status = result?.ok === true ? successStatus : result?.error?.code?.includes("not_found") ? 404 : 400;
|
||||
return json(status, result);
|
||||
}
|
||||
async function bodyObject(request: Request): Promise<Record<string, any>> {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw Object.assign(new Error("JSON object body is required"), { code: "invalid_json" });
|
||||
return body as Record<string, any>;
|
||||
}
|
||||
function text(value: unknown) { return String(value ?? ""); }
|
||||
function json(status: number, body: unknown) { return Response.json(body, { status, headers: { "cache-control": "no-store" } }); }
|
||||
@@ -0,0 +1,12 @@
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
|
||||
export function taskTreeRuntime(env: Record<string, string | undefined> = process.env) {
|
||||
const databaseUrl = env.TASKTREE_DATABASE_URL || env.DATABASE_URL || "";
|
||||
const store = new TaskTreeStore(databaseUrl);
|
||||
return {
|
||||
store,
|
||||
temporalAddress: env.TASKTREE_TEMPORAL_ADDRESS || env.TEMPORAL_ADDRESS || "",
|
||||
temporalNamespace: env.TASKTREE_TEMPORAL_NAMESPACE || "unidesk",
|
||||
taskQueue: env.TASKTREE_TEMPORAL_TASK_QUEUE || "hwlab-v03-tasktree"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import pg from "pg";
|
||||
|
||||
import type { ExecutionReport, Milestone, TaskGroup, TaskItem, TaskStatus, Timeline } from "./contracts.ts";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const schema = [
|
||||
`CREATE TABLE IF NOT EXISTS tasktree_schema_migrations (migration_id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
|
||||
`CREATE TABLE IF NOT EXISTS tasktree_groups (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
|
||||
`CREATE TABLE IF NOT EXISTS tasktree_tasks (
|
||||
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
|
||||
parent_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('task','subtask')),
|
||||
title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','in_progress','completed','blocked')),
|
||||
start_at TIMESTAMPTZ, due_at TIMESTAMPTZ, sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (due_at IS NULL OR start_at IS NULL OR due_at >= start_at))`,
|
||||
`CREATE TABLE IF NOT EXISTS tasktree_milestones (
|
||||
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES tasktree_groups(id) ON DELETE CASCADE,
|
||||
task_id TEXT REFERENCES tasktree_tasks(id) ON DELETE CASCADE, title TEXT NOT NULL,
|
||||
occurs_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
|
||||
`CREATE TABLE IF NOT EXISTS tasktree_execution_reports (
|
||||
id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasktree_tasks(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'succeeded',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_group_order ON tasktree_tasks(group_id, sort_order, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tasktree_tasks_parent ON tasktree_tasks(parent_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_tasktree_reports_task ON tasktree_execution_reports(task_id, created_at DESC)`
|
||||
];
|
||||
|
||||
export class TaskTreeStore {
|
||||
private readonly pool: pg.Pool;
|
||||
private ready = false;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
if (!databaseUrl) throw new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required");
|
||||
this.pool = new Pool({ connectionString: databaseUrl, max: 4, connectionTimeoutMillis: 5000, ssl: { rejectUnauthorized: false } });
|
||||
}
|
||||
|
||||
async ensureSchema() {
|
||||
if (this.ready) return;
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
for (const statement of schema) await client.query(statement);
|
||||
await client.query("INSERT INTO tasktree_schema_migrations (migration_id) VALUES ($1) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1"]);
|
||||
await client.query("COMMIT");
|
||||
this.ready = true;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async health() {
|
||||
await this.ensureSchema();
|
||||
const result = await this.pool.query("SELECT current_database() AS database, now() AS now");
|
||||
return { storage: "native-postgresql", schemaReady: true, database: result.rows[0]?.database, checkedAt: result.rows[0]?.now };
|
||||
}
|
||||
|
||||
async listGroups(): Promise<TaskGroup[]> {
|
||||
await this.ensureSchema();
|
||||
const result = await this.pool.query("SELECT * FROM tasktree_groups ORDER BY updated_at DESC, name");
|
||||
return result.rows.map(groupRow);
|
||||
}
|
||||
|
||||
async getGroup(id: string): Promise<TaskGroup | null> {
|
||||
await this.ensureSchema();
|
||||
const result = await this.pool.query("SELECT * FROM tasktree_groups WHERE id=$1", [id]);
|
||||
return result.rows[0] ? groupRow(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async createGroup(name: string, description = ""): Promise<TaskGroup> {
|
||||
await this.ensureSchema();
|
||||
const result = await this.pool.query("INSERT INTO tasktree_groups (id,name,description) VALUES ($1,$2,$3) RETURNING *", [`tg_${randomUUID()}`, name, description]);
|
||||
return groupRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async deleteGroup(id: string): Promise<boolean> {
|
||||
await this.ensureSchema();
|
||||
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> {
|
||||
await this.ensureSchema();
|
||||
let kind = "task";
|
||||
if (input.parentId) {
|
||||
const parent = await this.pool.query("SELECT group_id,parent_id 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].parent_id) throw domainError("maximum_depth_exceeded", "TaskTree supports task and subtask only");
|
||||
kind = "subtask";
|
||||
}
|
||||
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> {
|
||||
await this.ensureSchema();
|
||||
const current = await this.pool.query("SELECT * FROM tasktree_tasks WHERE id=$1", [id]);
|
||||
if (!current.rows[0]) return null;
|
||||
const row = taskRow(current.rows[0]);
|
||||
const result = await this.pool.query(
|
||||
`UPDATE tasktree_tasks SET title=$2,description=$3,status=$4,start_at=$5,due_at=$6,updated_at=now() WHERE id=$1 RETURNING *`,
|
||||
[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]
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
async createMilestone(input: { groupId: string; title: string; occursAt: string; taskId?: string }): Promise<Milestone> {
|
||||
await this.ensureSchema();
|
||||
const result = await this.pool.query(
|
||||
"INSERT INTO tasktree_milestones (id,group_id,task_id,title,occurs_at) VALUES ($1,$2,$3,$4,$5) RETURNING *",
|
||||
[`tm_${randomUUID()}`, input.groupId, input.taskId ?? null, input.title, input.occursAt]
|
||||
);
|
||||
return milestoneRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async createReport(input: { taskId: string; title: string; body: string; status?: string }): Promise<ExecutionReport> {
|
||||
await this.ensureSchema();
|
||||
const result = 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, input.status ?? "succeeded"]
|
||||
);
|
||||
return reportRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async timeline(groupId: string): Promise<Timeline | null> {
|
||||
await this.ensureSchema();
|
||||
const group = await this.getGroup(groupId);
|
||||
if (!group) return null;
|
||||
const [tasks, milestones, reports] = await Promise.all([
|
||||
this.pool.query("SELECT * FROM tasktree_tasks WHERE group_id=$1 ORDER BY sort_order,created_at", [groupId]),
|
||||
this.pool.query("SELECT * FROM tasktree_milestones WHERE group_id=$1 ORDER BY occurs_at", [groupId]),
|
||||
this.pool.query("SELECT r.* FROM tasktree_execution_reports r JOIN tasktree_tasks t ON t.id=r.task_id WHERE t.group_id=$1 ORDER BY r.created_at DESC", [groupId])
|
||||
]);
|
||||
return { group, tasks: tasks.rows.map(taskRow), milestones: milestones.rows.map(milestoneRow), reports: reports.rows.map(reportRow) };
|
||||
}
|
||||
|
||||
async close() { await this.pool.end(); }
|
||||
}
|
||||
|
||||
function groupRow(row: any): TaskGroup { return { id: row.id, name: row.name, description: row.description, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
|
||||
function taskRow(row: any): TaskItem { return { id: row.id, groupId: row.group_id, parentId: row.parent_id, kind: row.kind, title: row.title, description: row.description, status: row.status, startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_at), sortOrder: row.sort_order, createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) }; }
|
||||
function milestoneRow(row: any): Milestone { return { id: row.id, groupId: row.group_id, taskId: row.task_id, title: row.title, occursAt: iso(row.occurs_at), createdAt: iso(row.created_at) }; }
|
||||
function reportRow(row: any): ExecutionReport { return { id: row.id, taskId: row.task_id, title: row.title, body: row.body, status: row.status, createdAt: iso(row.created_at) }; }
|
||||
function iso(value: unknown): string { return new Date(value as any).toISOString(); }
|
||||
function nullableIso(value: unknown): string | null { return value == null ? null : iso(value); }
|
||||
function domainError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createTaskTreeDispatcher } from "./dispatcher.ts";
|
||||
import { createTaskTreeHttpApp } from "./http.ts";
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
|
||||
test("HTTP command endpoint returns the same dispatcher DTO", async () => {
|
||||
const group = {
|
||||
id: "tg_contract",
|
||||
name: "Contract",
|
||||
description: "",
|
||||
createdAt: "2026-07-16T00:00:00.000Z",
|
||||
updatedAt: "2026-07-16T00:00:00.000Z"
|
||||
};
|
||||
const store = {
|
||||
async listGroups() { return [group]; }
|
||||
};
|
||||
const dispatch = createTaskTreeDispatcher({ store: store as TaskTreeStore });
|
||||
const expected = await dispatch({ operation: "group.list" });
|
||||
const app = createTaskTreeHttpApp({ dispatch });
|
||||
const response = await app.fetch(new Request("http://tasktree/v1/tasktree/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ operation: "group.list" })
|
||||
}));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), expected);
|
||||
});
|
||||
|
||||
test("native PostgreSQL store exposes timeline DTO and rejects a third task level", async () => {
|
||||
const databaseUrl = process.env.TASKTREE_DATABASE_URL;
|
||||
assert.ok(databaseUrl, "TASKTREE_DATABASE_URL is required for the TaskTree store integration test");
|
||||
const store = new TaskTreeStore(databaseUrl);
|
||||
const group = await store.createGroup(`TaskTree test ${Date.now()}`);
|
||||
try {
|
||||
const task = await store.createTask({
|
||||
groupId: group.id,
|
||||
title: "Root task",
|
||||
startAt: "2026-07-16T00:00:00.000Z",
|
||||
dueAt: "2026-07-18T00:00:00.000Z"
|
||||
});
|
||||
const subtask = await store.createTask({ groupId: group.id, parentId: task.id, title: "Subtask" });
|
||||
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.createTask({ groupId: group.id, parentId: subtask.id, title: "Unsupported third level" }),
|
||||
(error: any) => error?.code === "maximum_depth_exceeded"
|
||||
);
|
||||
const timeline = await store.timeline(group.id);
|
||||
assert.equal(timeline?.tasks.length, 2);
|
||||
assert.equal(timeline?.tasks[0]?.kind, "task");
|
||||
assert.equal(timeline?.tasks[1]?.kind, "subtask");
|
||||
assert.equal(timeline?.milestones.length, 1);
|
||||
assert.equal(timeline?.reports.length, 1);
|
||||
} finally {
|
||||
await store.deleteGroup(group.id);
|
||||
await store.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyActivities } from "@temporalio/workflow";
|
||||
|
||||
const { recordTaskExecution } = proxyActivities<{ recordTaskExecution(input: { taskId: string }): Promise<{ taskId: string; reportId: string }> }>({
|
||||
startToCloseTimeout: "30 seconds",
|
||||
retry: { maximumAttempts: 3 }
|
||||
});
|
||||
|
||||
export async function taskExecutionWorkflow(input: { taskId: string }) {
|
||||
const result = await recordTaskExecution(input);
|
||||
return { status: "completed", ...result };
|
||||
}
|
||||
Generated
+2032
File diff suppressed because it is too large
Load Diff
@@ -50,8 +50,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openai/codex": "^0.128.0",
|
||||
"@swc/wasm": "^1.15.43",
|
||||
"@temporalio/activity": "^1.20.3",
|
||||
"@temporalio/client": "^1.20.3",
|
||||
"@temporalio/worker": "^1.20.3",
|
||||
"@temporalio/workflow": "^1.20.3",
|
||||
"fzstd": "0.1.1",
|
||||
"kafkajs": "^2.2.4",
|
||||
"loader-utils": "^3.3.1",
|
||||
"pg": "^8.21.0",
|
||||
"playwright": "1.59.1",
|
||||
"yaml": "^2.8.3"
|
||||
|
||||
@@ -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 await main(argv);
|
||||
|
||||
@@ -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; }
|
||||
@@ -17,6 +17,7 @@ export { usageAPI } from "./usage";
|
||||
export { billingAPI } from "./billing";
|
||||
export { systemAPI, type SkillUploadFileInput } from "./system";
|
||||
export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type MdtodoTaskStatus, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
export { tasktreeAPI, type TaskTreeGroup, type TaskTreeMilestone, type TaskTreeReport, type TaskTreeTask, type TaskTreeTimeline } from "./tasktree";
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
import { accessAPI } from "./access";
|
||||
@@ -32,6 +33,7 @@ import { billingAPI } from "./billing";
|
||||
import { workbenchAPI } from "./workbench";
|
||||
import { systemAPI } from "./system";
|
||||
import { projectManagementAPI } from "./projectManagement";
|
||||
import { tasktreeAPI } from "./tasktree";
|
||||
import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types";
|
||||
|
||||
export const api = {
|
||||
@@ -47,6 +49,7 @@ export const api = {
|
||||
usage: usageAPI,
|
||||
billing: billingAPI,
|
||||
projectManagement: projectManagementAPI,
|
||||
tasktree: tasktreeAPI,
|
||||
system: systemAPI,
|
||||
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
|
||||
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fetchJson } from "./client";
|
||||
import type { ApiResult } from "@/types";
|
||||
|
||||
export type TaskTreeGroup = { id: string; name: string; description: string; createdAt: string; updatedAt: string };
|
||||
export type TaskTreeTask = { id: string; groupId: string; parentId: string | null; kind: "task" | "subtask"; title: string; description: string; status: string; startAt: string | null; dueAt: string | null; sortOrder: number; createdAt: string; updatedAt: string };
|
||||
export type TaskTreeMilestone = { id: string; groupId: string; taskId: string | null; title: string; occursAt: string; createdAt: string };
|
||||
export type TaskTreeReport = { id: string; taskId: string; title: string; body: string; status: string; createdAt: string };
|
||||
export type TaskTreeTimeline = { group: TaskTreeGroup; tasks: TaskTreeTask[]; milestones: TaskTreeMilestone[]; reports: TaskTreeReport[] };
|
||||
type Result<T> = { ok?: boolean; data?: T; error?: { code?: string; message?: string } };
|
||||
|
||||
export const tasktreeAPI = {
|
||||
groups: (): Promise<ApiResult<Result<{ groups: TaskTreeGroup[] }>>> => fetchJson("/v1/tasktree/groups", { timeoutMs: 12000, timeoutName: "TaskTree groups" }),
|
||||
timeline: (groupId: string): Promise<ApiResult<Result<TaskTreeTimeline>>> => fetchJson(`/v1/tasktree/groups/${encodeURIComponent(groupId)}/timeline`, { timeoutMs: 12000, timeoutName: "TaskTree timeline" })
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Cpu,
|
||||
CreditCard,
|
||||
FolderKanban,
|
||||
GanttChart,
|
||||
Gauge,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
@@ -109,6 +110,13 @@ const navSections = [
|
||||
icon: ListChecks,
|
||||
navId: "project.mdtodo",
|
||||
},
|
||||
{
|
||||
name: "TaskTree",
|
||||
label: "TaskTree",
|
||||
path: "/projects/tasktree",
|
||||
icon: GanttChart,
|
||||
navId: "project.tasktree",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ const WorkbenchDebugView = () => import("@/views/workbench/WorkbenchDebugView.vu
|
||||
const OpenCodeFrameView = () => import("@/views/opencode/OpenCodeFrameView.vue");
|
||||
const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
|
||||
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
|
||||
const TaskTreeView = () => import("@/views/projects/TaskTreeView.vue");
|
||||
const AgentRunsView = () => import("@/views/agents/AgentRunsView.vue");
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -25,6 +26,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } },
|
||||
{ path: "/projects", name: "Projects", component: ProjectsView, meta: { requiresAuth: true, navId: "project.overview", title: "项目", section: "project" } },
|
||||
{ path: "/projects/mdtodo", name: "ProjectMdtodo", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
|
||||
{ path: "/projects/tasktree", name: "TaskTree", component: TaskTreeView, meta: { requiresAuth: true, navId: "project.tasktree", title: "TaskTree", section: "project" } },
|
||||
{ path: "/projects/tasktree/:groupId", name: "TaskTreeGroup", component: TaskTreeView, meta: { requiresAuth: true, navId: "project.tasktree", navParent: "TaskTree", title: "TaskTree", section: "project" } },
|
||||
{ path: "/projects/mdtodo/sources/:sourceId/files/:fileRef", name: "ProjectMdtodoFile", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
|
||||
{ path: "/projects/mdtodo/sources/:sourceId/files/:fileRef/tasks/:taskId", name: "ProjectMdtodoTask", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
|
||||
{ path: "/projects/mdtodo/sources/:sourceId/files/:fileRef/tasks/:taskId/reports/:linkId", name: "ProjectMdtodoReport", component: ProjectMdtodoView, meta: { requiresAuth: true, navId: "project.mdtodo", title: "MDTODO", section: "project" } },
|
||||
|
||||
@@ -144,6 +144,7 @@ function firstAllowedNavPath(allowedIds: string[]): string {
|
||||
{ id: "workbench.debug", path: "/workbench/debug" },
|
||||
{ id: "opencode.root", path: "/opencode" },
|
||||
{ id: "project.mdtodo", path: "/projects/mdtodo" },
|
||||
{ id: "project.tasktree", path: "/projects/tasktree" },
|
||||
{ id: "user.dashboard", path: "/dashboard" },
|
||||
{ id: "project.overview", path: "/projects" },
|
||||
{ id: "user.billing", path: "/billing" },
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarDays, ChevronDown, Flag, RefreshCw } from "lucide-vue-next";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { tasktreeAPI, type TaskTreeGroup, type TaskTreeReport, type TaskTreeTask, type TaskTreeTimeline } from "@/api";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
import StatusStrip from "@/components/layout/StatusStrip.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const groups = ref<TaskTreeGroup[]>([]);
|
||||
const timeline = ref<TaskTreeTimeline | null>(null);
|
||||
const selectedTask = ref<TaskTreeTask | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const groupId = computed(() => String(route.params.groupId || groups.value[0]?.id || ""));
|
||||
const datedTasks = computed(() => timeline.value?.tasks.filter((task) => task.startAt || task.dueAt) ?? []);
|
||||
const range = computed(() => timelineRange(datedTasks.value));
|
||||
const days = computed(() => Array.from({ length: range.value.dayCount }, (_, index) => new Date(range.value.start.getTime() + index * 86400000)));
|
||||
const selectedReports = computed<TaskTreeReport[]>(() => selectedTask.value ? timeline.value?.reports.filter((report) => report.taskId === selectedTask.value?.id) ?? [] : []);
|
||||
const statusItems = computed(() => [
|
||||
{ id: "groups", label: "Taskgroups", value: groups.value.length, detail: "PostgreSQL authority" },
|
||||
{ id: "tasks", label: "Tasks", value: timeline.value?.tasks.filter((task) => task.kind === "task").length ?? 0, detail: "甘特主行" },
|
||||
{ id: "subtasks", label: "Subtasks", value: timeline.value?.tasks.filter((task) => task.kind === "subtask").length ?? 0, detail: "二级执行项" },
|
||||
{ id: "reports", label: "Reports", value: timeline.value?.reports.length ?? 0, detail: `${timeline.value?.milestones.length ?? 0} milestones` }
|
||||
]);
|
||||
const asyncState = computed(() => loading.value ? timeline.value ? "refreshing" : "initial-loading" : error.value ? timeline.value ? "partial" : "error" : timeline.value?.tasks.length ? "ready" : "empty");
|
||||
|
||||
onMounted(loadGroups);
|
||||
watch(groupId, (value) => { if (value) void loadTimeline(value); });
|
||||
|
||||
async function loadGroups() {
|
||||
loading.value = true; error.value = null;
|
||||
const response = await tasktreeAPI.groups(); loading.value = false;
|
||||
if (!response.ok || response.data?.ok === false) { error.value = response.data?.error?.message || response.error || "TaskTree taskgroups 加载失败"; return; }
|
||||
groups.value = response.data?.data?.groups ?? [];
|
||||
const requested = String(route.params.groupId || "");
|
||||
const selected = groups.value.some((group) => group.id === requested) ? requested : groups.value[0]?.id;
|
||||
if (selected && selected !== requested) await router.replace(`/projects/tasktree/${encodeURIComponent(selected)}`);
|
||||
else if (selected) await loadTimeline(selected);
|
||||
}
|
||||
async function loadTimeline(id = groupId.value) {
|
||||
if (!id) return; loading.value = true; error.value = null;
|
||||
const response = await tasktreeAPI.timeline(id); loading.value = false;
|
||||
if (!response.ok || response.data?.ok === false || !response.data?.data) { error.value = response.data?.error?.message || response.error || "TaskTree timeline 加载失败"; return; }
|
||||
timeline.value = response.data.data;
|
||||
}
|
||||
function selectGroup(event: Event) { void router.push(`/projects/tasktree/${encodeURIComponent((event.target as HTMLSelectElement).value)}`); }
|
||||
function timelineRange(tasks: TaskTreeTask[]) {
|
||||
const values = tasks.flatMap((task) => [task.startAt, task.dueAt]).filter(Boolean).map((value) => new Date(value as string).getTime());
|
||||
const today = startDay(new Date()); const min = values.length ? Math.min(...values) : today.getTime(); const max = values.length ? Math.max(...values) : min + 6 * 86400000;
|
||||
const start = new Date(startDay(new Date(min)).getTime() - 86400000);
|
||||
return { start, dayCount: Math.min(120, Math.max(7, Math.ceil((max - start.getTime()) / 86400000) + 2)) };
|
||||
}
|
||||
function startDay(date: Date) { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); }
|
||||
function barStyle(task: TaskTreeTask) {
|
||||
const start = new Date(task.startAt || task.dueAt || range.value.start).getTime(); const end = new Date(task.dueAt || task.startAt || range.value.start).getTime(); const unit = 100 / range.value.dayCount;
|
||||
const left = Math.max(0, (start - range.value.start.getTime()) / 86400000) * unit; const width = Math.max(unit * 0.65, ((end - start) / 86400000 + 1) * unit);
|
||||
return { left: `${left}%`, width: `${Math.min(100 - left, width)}%` };
|
||||
}
|
||||
function milestoneStyle(occursAt: string) { return barStyle({ startAt: occursAt, dueAt: occursAt } as TaskTreeTask); }
|
||||
function dayLabel(date: Date) { return `${date.getUTCMonth() + 1}/${date.getUTCDate()}`; }
|
||||
function dateLabel(value: string | null) { return value ? new Date(value).toLocaleString() : "未设置"; }
|
||||
function statusLabel(status: string) { return ({ pending: "待处理", in_progress: "进行中", completed: "已完成", blocked: "阻塞" } as Record<string, string>)[status] || status; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tasktree-page" data-testid="tasktree-page">
|
||||
<PageCommandBar title="TaskTree" eyebrow="项目时间线" description="任务、时间节点与执行报告">
|
||||
<template #actions><label class="tasktree-group-select"><span class="sr-only">Taskgroup</span><select :value="groupId" @change="selectGroup"><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select><ChevronDown :size="15" /></label><button class="icon-button" type="button" title="刷新" :disabled="loading" @click="loadTimeline()"><RefreshCw :size="17" /></button></template>
|
||||
</PageCommandBar>
|
||||
<StatusStrip :items="statusItems" />
|
||||
<AsyncBoundary :state="asyncState" title="TaskTree 暂无任务" :message="error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry="loadGroups">
|
||||
<section v-if="timeline" class="tasktree-workspace" aria-label="TaskTree 甘特图">
|
||||
<header class="tasktree-context"><div><strong>{{ timeline.group.name }}</strong><p>{{ timeline.group.description || '未填写 taskgroup 说明' }}</p></div><span><CalendarDays :size="16" /> {{ days.length }} 天</span></header>
|
||||
<div class="tasktree-gantt" :style="{ '--day-count': days.length }">
|
||||
<div class="tasktree-corner">任务</div><div class="tasktree-days"><span v-for="day in days" :key="day.toISOString()">{{ dayLabel(day) }}</span></div>
|
||||
<template v-for="task in timeline.tasks" :key="task.id">
|
||||
<button class="tasktree-label" :class="{ subtask: task.kind === 'subtask' }" type="button" @click="selectedTask = task"><span class="tasktree-status" :data-status="task.status"/><span>{{ task.title }}</span></button>
|
||||
<div class="tasktree-track" :style="{ backgroundSize: `${100 / days.length}% 100%` }"><button class="tasktree-bar" :class="[`status-${task.status}`, { subtask: task.kind === 'subtask' }]" :style="barStyle(task)" type="button" @click="selectedTask = task"><span>{{ statusLabel(task.status) }}</span></button><span v-for="milestone in timeline.milestones.filter((item) => item.taskId === task.id)" :key="milestone.id" class="tasktree-milestone" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)"><Flag :size="13" /></span></div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</AsyncBoundary>
|
||||
<BaseDialog :open="Boolean(selectedTask)" :title="selectedTask?.title || '任务详情'" :description="selectedTask ? `${selectedTask.kind === 'subtask' ? 'Subtask' : 'Task'} · ${statusLabel(selectedTask.status)}` : ''" wide @close="selectedTask = null">
|
||||
<div v-if="selectedTask" class="tasktree-detail"><dl><div><dt>开始</dt><dd>{{ dateLabel(selectedTask.startAt) }}</dd></div><div><dt>截止</dt><dd>{{ dateLabel(selectedTask.dueAt) }}</dd></div><div><dt>状态</dt><dd>{{ statusLabel(selectedTask.status) }}</dd></div></dl><section><h3>任务说明</h3><p>{{ selectedTask.description || '未填写任务说明。' }}</p></section><section><h3>执行报告</h3><div v-if="selectedReports.length" class="tasktree-reports"><article v-for="report in selectedReports" :key="report.id"><header><strong>{{ report.title }}</strong><time>{{ dateLabel(report.createdAt) }}</time></header><p>{{ report.body }}</p></article></div><p v-else class="tasktree-muted">尚无执行报告。</p></section></div>
|
||||
</BaseDialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasktree-page{display:grid;gap:12px;min-width:0}.tasktree-workspace{border:1px solid var(--border-color);background:var(--surface-primary);min-width:0;overflow:hidden}.tasktree-context{min-height:58px;padding:10px 14px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;justify-content:space-between;gap:16px}.tasktree-context p{margin:3px 0 0;color:var(--text-secondary);font-size:12px}.tasktree-context>span{display:flex;align-items:center;gap:6px;color:var(--text-secondary);font-size:12px}.tasktree-gantt{display:grid;grid-template-columns:minmax(210px,280px) minmax(720px,1fr);max-height:calc(100vh - 285px);overflow:auto}.tasktree-corner{position:sticky;left:0;top:0;z-index:4;padding:9px 14px;background:var(--surface-secondary);border-right:1px solid var(--border-color);border-bottom:1px solid var(--border-color);font-size:11px;font-weight:700}.tasktree-days{position:sticky;top:0;z-index:3;display:grid;grid-template-columns:repeat(var(--day-count),minmax(28px,1fr));background:var(--surface-secondary);border-bottom:1px solid var(--border-color)}.tasktree-days span{padding:9px 2px;border-right:1px solid var(--border-subtle);font-size:10px;text-align:center;color:var(--text-secondary)}.tasktree-label{position:sticky;left:0;z-index:2;height:46px;border:0;border-right:1px solid var(--border-color);border-bottom:1px solid var(--border-subtle);background:var(--surface-primary);padding:0 12px;display:flex;align-items:center;gap:8px;text-align:left;color:var(--text-primary);min-width:0}.tasktree-label span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tasktree-label.subtask{padding-left:34px;color:var(--text-secondary)}.tasktree-status{width:7px;height:7px;border-radius:50%;background:#83909c;flex:0 0 auto}.tasktree-status[data-status=completed]{background:#1f9d67}.tasktree-status[data-status=in_progress]{background:#2376c9}.tasktree-status[data-status=blocked]{background:#c73e48}.tasktree-track{position:relative;height:46px;border-bottom:1px solid var(--border-subtle);background-image:linear-gradient(to right,var(--border-subtle) 1px,transparent 1px)}.tasktree-bar{position:absolute;top:10px;height:26px;border:0;border-radius:4px;background:#2376c9;color:white;min-width:18px;padding:0 8px;text-align:left;overflow:hidden}.tasktree-bar span{font-size:10px;white-space:nowrap}.tasktree-bar.subtask{top:14px;height:18px;background:#5f7d99}.tasktree-bar.status-completed{background:#23845c}.tasktree-bar.status-blocked{background:#b84149}.tasktree-bar.status-pending{background:#687581}.tasktree-milestone{position:absolute;top:3px;width:20px;height:20px;color:#b36b00;transform:translateX(-10px);z-index:2}.tasktree-group-select{position:relative;display:flex;align-items:center}.tasktree-group-select select{appearance:none;min-width:180px;padding:7px 30px 7px 10px;border:1px solid var(--border-color);background:var(--surface-primary);color:var(--text-primary)}.tasktree-group-select svg{position:absolute;right:8px;pointer-events:none}.tasktree-detail{display:grid;gap:18px}.tasktree-detail dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0}.tasktree-detail dl div{padding:10px;border-left:2px solid #2376c9;background:var(--surface-secondary)}.tasktree-detail dt{font-size:11px;color:var(--text-secondary)}.tasktree-detail dd{margin:4px 0 0}.tasktree-detail h3{font-size:13px;margin:0 0 8px}.tasktree-detail p{margin:0;white-space:pre-wrap}.tasktree-reports{display:grid;gap:8px}.tasktree-reports article{border:1px solid var(--border-color);padding:10px}.tasktree-reports header{display:flex;justify-content:space-between;gap:10px}.tasktree-reports time,.tasktree-muted{font-size:11px;color:var(--text-secondary)}@media(max-width:720px){.tasktree-gantt{grid-template-columns:170px minmax(680px,1fr);max-height:calc(100vh - 250px)}.tasktree-detail dl{grid-template-columns:1fr}.tasktree-group-select select{min-width:130px;max-width:42vw}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user