Merge pull request #2659 from pikasTech/feat/tasktree-production-release
Pipelines as Code CI / hwlab-nc01-production-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-production-ci-poll- Success
feat: 发布 TaskTree 到 production
This commit is contained in:
@@ -9,7 +9,7 @@ metadata:
|
||||
pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'release' && node == 'NC01'"
|
||||
pipelinesascode.tekton.dev/max-keep-runs: "8"
|
||||
unidesk.ai/owning-config-ref: "config/hwlab-node-lanes.yaml#lanes.production.targets.NC01"
|
||||
unidesk.ai/effective-config-sha256: sha256:8c54c1d70b44aecc890f729e5d849c1a3ca68d7069c8ee20774dcd5a1b7ff365
|
||||
unidesk.ai/effective-config-sha256: sha256:c01c32df9c12067100035d9416f7b462adde33781e6a568e400b5a33928aab33
|
||||
unidesk.ai/source-artifact-renderer: hwlab-runtime-lane
|
||||
unidesk.ai/source-artifact-mode: remote-pipeline-annotation
|
||||
pipelinesascode.tekton.dev/pipeline: ci/pipelines/hwlab-nc01-production-ci-image-publish.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -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
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
|
||||
@@ -23,6 +23,8 @@ spec:
|
||||
services:
|
||||
- hwlab-cloud-api
|
||||
- hwlab-project-management
|
||||
- hwlab-tasktree-api
|
||||
- hwlab-tasktree-worker
|
||||
- hwlab-cloud-web
|
||||
- hwlab-gateway
|
||||
- hwlab-edge-proxy
|
||||
@@ -138,6 +140,53 @@ spec:
|
||||
- package-lock.json
|
||||
- deploy/runtime/launcher/hwlab-env-reuse-launcher.ts
|
||||
- internal/dev-entrypoint/artifact-runtime.mjs
|
||||
hwlab-tasktree-api:
|
||||
runtimeReuse:
|
||||
enabled: true
|
||||
codeIdentity:
|
||||
paths:
|
||||
- cmd/hwlab-tasktree-api
|
||||
- internal/tasktree
|
||||
- tools/src/tasktree-cli.ts
|
||||
- deploy/runtime/boot/hwlab-tasktree-api.sh
|
||||
envIdentity:
|
||||
paths:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- deploy/runtime/launcher/hwlab-env-reuse-launcher.ts
|
||||
- internal/dev-entrypoint/artifact-runtime.mjs
|
||||
envReuse:
|
||||
enabled: true
|
||||
mode: env-reuse-git-mirror-checkout
|
||||
nodeDepsPath: /opt/hwlab-env/node_modules
|
||||
envIdentityFiles:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- deploy/runtime/launcher/hwlab-env-reuse-launcher.ts
|
||||
- internal/dev-entrypoint/artifact-runtime.mjs
|
||||
hwlab-tasktree-worker:
|
||||
runtimeReuse:
|
||||
enabled: true
|
||||
codeIdentity:
|
||||
paths:
|
||||
- cmd/hwlab-tasktree-worker
|
||||
- internal/tasktree
|
||||
- deploy/runtime/boot/hwlab-tasktree-worker.sh
|
||||
envIdentity:
|
||||
paths:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- deploy/runtime/launcher/hwlab-env-reuse-launcher.ts
|
||||
- internal/dev-entrypoint/artifact-runtime.mjs
|
||||
envReuse:
|
||||
enabled: true
|
||||
mode: env-reuse-git-mirror-checkout
|
||||
nodeDepsPath: /opt/hwlab-env/node_modules
|
||||
envIdentityFiles:
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- deploy/runtime/launcher/hwlab-env-reuse-launcher.ts
|
||||
- internal/dev-entrypoint/artifact-runtime.mjs
|
||||
hwlab-cloud-web:
|
||||
runtimeReuse:
|
||||
enabled: true
|
||||
|
||||
@@ -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,13 @@
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
|
||||
export function createTaskTreeActivities(store: TaskTreeStore) {
|
||||
return {
|
||||
async recordTaskExecution(input: { taskId: string }) {
|
||||
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.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 };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
|
||||
export type TaskKind = "task" | "subtask" | "subsubtask";
|
||||
|
||||
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 TaskGroupOverview = {
|
||||
group: TaskGroup;
|
||||
taskCount: number;
|
||||
subtaskCount: number;
|
||||
subsubtaskCount: number;
|
||||
reportCount: number;
|
||||
startAt: string | null;
|
||||
dueAt: string | null;
|
||||
};
|
||||
|
||||
export type TaskTreeCommand =
|
||||
| { operation: "health" }
|
||||
| { operation: "group.list" }
|
||||
| { operation: "group.overview" }
|
||||
| { 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: "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,150 @@
|
||||
import { Client, Connection } from "@temporalio/client";
|
||||
|
||||
import type { TaskItem, TaskTreeCommand, TaskTreeResult } from "./contracts.ts";
|
||||
import { parseMdtodoImport } from "./mdtodo-import.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.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: 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") {
|
||||
const startAt = optionalDate(command.startAt, "startAt");
|
||||
const dueAt = optionalDate(command.dueAt, "dueAt");
|
||||
validTimeRange(startAt, dueAt);
|
||||
data = await store.createTask({
|
||||
...command,
|
||||
title: validTitle(command.title),
|
||||
startAt,
|
||||
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");
|
||||
const startAt = optionalDate(command.startAt, "startAt");
|
||||
const dueAt = optionalDate(command.dueAt, "dueAt");
|
||||
validTimeRange(startAt, dueAt);
|
||||
data = { tasks: await store.createTasks({
|
||||
...command,
|
||||
titles,
|
||||
startAt,
|
||||
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)}`);
|
||||
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 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 }); }
|
||||
function validTimeRange(startAt?: string, dueAt?: string) {
|
||||
if (startAt !== undefined && dueAt !== undefined && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) {
|
||||
throw codedError("invalid_time_range", "startAt must be before or equal to dueAt");
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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/overview" && request.method === "GET") return resultResponse(await options.dispatch({ operation: "group.overview" }));
|
||||
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,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { parseMdtodoImport } from "./mdtodo-import.ts";
|
||||
|
||||
test("MDTODO import preserves three Rxx levels and attaches reports", () => {
|
||||
const plan = parseMdtodoImport({
|
||||
sourcePath: "/tmp/demo/MDTODO.md",
|
||||
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
|
||||
markdown: `# Demo\n\n## R1 Root [in_progress]\n\nSee [任务报告](./details/R1_Task_Report.md).\n\n### R1.1 Child [completed]\n\n#### R1.1.1 Deep child [blocked]\n`,
|
||||
reports: [{ href: "./details/R1_Task_Report.md", sourcePath: "/tmp/demo/details/R1_Task_Report.md", body: "# R1 report\n\nPassed.", modifiedAt: "2026-07-17T09:30:00.000Z" }]
|
||||
});
|
||||
|
||||
assert.equal(plan.groupName, "Demo");
|
||||
assert.deepEqual(plan.summary, { taskCount: 1, subtaskCount: 1, subsubtaskCount: 1, reportCount: 1, missingReportCount: 0, flattenedTaskCount: 0, warningCount: 0 });
|
||||
assert.equal(plan.tasks[0]?.status, "in_progress");
|
||||
assert.equal(plan.tasks[1]?.status, "completed");
|
||||
assert.equal(plan.tasks[2]?.status, "blocked");
|
||||
assert.equal(plan.tasks[2]?.parentSourceId, "R1.1");
|
||||
assert.equal(plan.tasks[0]?.reports[0]?.body, "# R1 report\n\nPassed.");
|
||||
assert.equal(plan.tasks[0]?.startAt, "2026-07-14T12:00:00.000Z");
|
||||
assert.equal(plan.tasks[0]?.dueAt, "2026-07-17T09:30:00.000Z");
|
||||
assert.equal(plan.tasks[1]?.startAt, "2026-07-14T12:00:00.000Z");
|
||||
assert.equal(plan.tasks[1]?.dueAt, "2026-07-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
test("MDTODO import flattens only levels deeper than subsubtask", () => {
|
||||
const plan = parseMdtodoImport({
|
||||
sourcePath: "/tmp/demo/MDTODO.md",
|
||||
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
|
||||
markdown: "# Demo\n\n## R2 Root\n\n### R2.9 Child\n\n#### R2.9.1 Grandchild\n\n##### R2.9.1.1 Deeper\n",
|
||||
reports: []
|
||||
});
|
||||
assert.equal(plan.tasks[2]?.parentSourceId, "R2.9");
|
||||
assert.equal(plan.tasks[3]?.parentSourceId, "R2.9");
|
||||
assert.equal(plan.summary.subsubtaskCount, 2);
|
||||
assert.equal(plan.summary.flattenedTaskCount, 1);
|
||||
});
|
||||
|
||||
test("MDTODO import reports missing linked report files", () => {
|
||||
const plan = parseMdtodoImport({
|
||||
sourcePath: "/tmp/demo/MDTODO.md",
|
||||
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
|
||||
markdown: "# Demo\n\n## R1 Root\n\n[Task report](./R1_Task_Report.md)\n",
|
||||
reports: []
|
||||
});
|
||||
assert.equal(plan.summary.missingReportCount, 1);
|
||||
assert.equal(plan.warnings[0]?.code, "report_file_missing");
|
||||
});
|
||||
|
||||
test("MDTODO import expands a parent range to contain report-dated children", () => {
|
||||
const plan = parseMdtodoImport({
|
||||
sourcePath: "/tmp/demo/MDTODO.md",
|
||||
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
|
||||
markdown: `# Demo\n\n## R1 Root\n\n### R1.1 Child\n\nSee [Task report](./R1.1_Task_Report.md).\n`,
|
||||
reports: [{ href: "./R1.1_Task_Report.md", sourcePath: "/tmp/demo/R1.1_Task_Report.md", body: "# Child report", modifiedAt: "2026-07-17T09:30:00.000Z" }]
|
||||
});
|
||||
assert.equal(plan.tasks[1]?.startAt, "2026-07-16T09:30:00.000Z");
|
||||
assert.equal(plan.tasks[1]?.dueAt, "2026-07-17T09:30:00.000Z");
|
||||
assert.equal(plan.tasks[0]?.startAt, "2026-07-14T12:00:00.000Z");
|
||||
assert.equal(plan.tasks[0]?.dueAt, "2026-07-17T09:30:00.000Z");
|
||||
});
|
||||
|
||||
test("MDTODO import never truncates title content at URL colons", () => {
|
||||
const plan = parseMdtodoImport({
|
||||
sourcePath: "/tmp/demo/MDTODO.md",
|
||||
sourceModifiedAt: "2026-07-15T12:00:00.000Z",
|
||||
markdown: "# Demo\n\n## R1\n\n完成 [UniDesk #2361](https://github.com/pikasTech/unidesk/issues/2361) 并保留后续文本。\n",
|
||||
reports: []
|
||||
});
|
||||
assert.equal(plan.tasks[0]?.title, "R1 完成 [UniDesk #2361](https://github.com/pikasTech/unidesk/issues/2361) 并保留后续文本。");
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { TaskStatus } from "./contracts.ts";
|
||||
|
||||
export type MdtodoReportSource = { href: string; sourcePath: string; body: string; modifiedAt: string };
|
||||
export type MdtodoImportPayload = {
|
||||
sourcePath: string;
|
||||
sourceModifiedAt: string;
|
||||
markdown: string;
|
||||
reports: MdtodoReportSource[];
|
||||
groupName?: string;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export type MdtodoImportTask = {
|
||||
sourceId: string;
|
||||
parentSourceId: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
startAt: string;
|
||||
dueAt: string;
|
||||
reports: Array<{ title: string; body: string; status: string; createdAt: string }>;
|
||||
};
|
||||
|
||||
export type MdtodoImportPlan = {
|
||||
sourcePath: string;
|
||||
groupName: string;
|
||||
groupDescription: string;
|
||||
parserMode: "rxx" | "legacy-checkbox";
|
||||
tasks: MdtodoImportTask[];
|
||||
summary: {
|
||||
taskCount: number;
|
||||
subtaskCount: number;
|
||||
subsubtaskCount: number;
|
||||
reportCount: number;
|
||||
missingReportCount: number;
|
||||
flattenedTaskCount: number;
|
||||
warningCount: number;
|
||||
};
|
||||
warnings: Array<{ code: string; message: string; sourceId?: string; href?: string }>;
|
||||
};
|
||||
|
||||
const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu;
|
||||
const checkboxPattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u;
|
||||
const markdownLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/gu;
|
||||
|
||||
export async function loadMdtodoImportPayload(filePath: string, options: { groupName?: string; dryRun?: boolean } = {}): Promise<MdtodoImportPayload> {
|
||||
const sourcePath = path.resolve(filePath);
|
||||
const [markdown, sourceInfo] = await Promise.all([readFile(sourcePath, "utf8"), stat(sourcePath)]);
|
||||
const reports: MdtodoReportSource[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const link of reportLinks(markdown)) {
|
||||
const reportPath = path.resolve(path.dirname(sourcePath), link.href);
|
||||
if (seen.has(reportPath)) continue;
|
||||
seen.add(reportPath);
|
||||
try {
|
||||
const [body, reportInfo] = await Promise.all([readFile(reportPath, "utf8"), stat(reportPath)]);
|
||||
reports.push({ href: link.href, sourcePath: reportPath, body, modifiedAt: reportInfo.mtime.toISOString() });
|
||||
} catch {
|
||||
// Missing report files are reported by the pure parser so dry-run and import agree.
|
||||
}
|
||||
}
|
||||
return { sourcePath, sourceModifiedAt: sourceInfo.mtime.toISOString(), markdown, reports, groupName: options.groupName, dryRun: options.dryRun };
|
||||
}
|
||||
|
||||
export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPlan {
|
||||
const lines = String(payload.markdown ?? "").split(/\r?\n/u);
|
||||
const rxxBlocks = collectRxxBlocks(lines);
|
||||
const parsed = rxxBlocks.length ? parseRxx(lines, rxxBlocks, payload) : parseCheckboxes(lines, payload);
|
||||
if (!parsed.tasks.length) throw codedError("mdtodo_tasks_not_found", "MDTODO file does not contain Rxx headings or checkbox tasks");
|
||||
const reportCount = parsed.tasks.reduce((count, task) => count + task.reports.length, 0);
|
||||
const missingReportCount = parsed.warnings.filter((warning) => warning.code === "report_file_missing").length;
|
||||
const flattenedTaskCount = parsed.warnings.filter((warning) => warning.code === "nested_task_flattened").length;
|
||||
const tasksBySourceId = new Map(parsed.tasks.map((task) => [task.sourceId, task]));
|
||||
return {
|
||||
sourcePath: payload.sourcePath,
|
||||
groupName: cleanTitle(payload.groupName || documentTitle(lines, payload.sourcePath)),
|
||||
groupDescription: `Imported from MDTODO: ${payload.sourcePath}`,
|
||||
parserMode: rxxBlocks.length ? "rxx" : "legacy-checkbox",
|
||||
tasks: parsed.tasks,
|
||||
summary: {
|
||||
taskCount: parsed.tasks.filter((task) => !task.parentSourceId).length,
|
||||
subtaskCount: parsed.tasks.filter((task) => taskHierarchyDepth(task, tasksBySourceId) === 1).length,
|
||||
subsubtaskCount: parsed.tasks.filter((task) => taskHierarchyDepth(task, tasksBySourceId) === 2).length,
|
||||
reportCount,
|
||||
missingReportCount,
|
||||
flattenedTaskCount,
|
||||
warningCount: parsed.warnings.length
|
||||
},
|
||||
warnings: parsed.warnings.slice(0, 100)
|
||||
};
|
||||
}
|
||||
|
||||
function parseRxx(lines: string[], blocks: RxxBlock[], payload: MdtodoImportPayload) {
|
||||
const tasks: MdtodoImportTask[] = [];
|
||||
const warnings: MdtodoImportPlan["warnings"] = [];
|
||||
const sourceIds = new Set(blocks.map((block) => block.sourceId));
|
||||
for (const block of blocks) {
|
||||
const depth = rxxDepth(block.sourceId);
|
||||
const supportedSourceId = depth > 2 ? block.sourceId.split(".").slice(0, 3).join(".") : block.sourceId;
|
||||
const parentSourceId = nearestRxxParent(supportedSourceId, sourceIds);
|
||||
if (depth > 2) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to the supported third level under ${parentSourceId ?? "the taskgroup"}` });
|
||||
if (depth > 0 && !parentSourceId) warnings.push({ code: "parent_task_missing", sourceId: block.sourceId, message: `${block.sourceId} has no importable parent task and was imported as a task` });
|
||||
const description = lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim();
|
||||
const reports = reportsForTask(description, payload, block.sourceId, warnings);
|
||||
tasks.push({
|
||||
sourceId: block.sourceId,
|
||||
parentSourceId,
|
||||
title: cleanTitle(`${block.sourceId} ${block.title || conciseBodyTitle(description)}`),
|
||||
description,
|
||||
status: block.status,
|
||||
...importedTaskRange(payload.sourceModifiedAt, reports),
|
||||
reports
|
||||
});
|
||||
}
|
||||
expandParentRanges(tasks);
|
||||
return { tasks, warnings };
|
||||
}
|
||||
|
||||
function parseCheckboxes(lines: string[], payload: MdtodoImportPayload) {
|
||||
const tasks: MdtodoImportTask[] = [];
|
||||
const warnings: MdtodoImportPlan["warnings"] = [];
|
||||
const parentsByDepth: string[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = checkboxPattern.exec(lines[index]);
|
||||
if (!match) continue;
|
||||
const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2));
|
||||
const sourceId = `L${index + 1}`;
|
||||
const supportedDepth = Math.min(depth, 2);
|
||||
const parentSourceId = supportedDepth > 0 ? parentsByDepth[supportedDepth - 1] ?? null : null;
|
||||
if (depth > 2) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the supported third checkbox level` });
|
||||
tasks.push({
|
||||
sourceId,
|
||||
parentSourceId,
|
||||
title: cleanTitle(match[3]),
|
||||
description: "",
|
||||
status: match[2].toLowerCase() === "x" ? "completed" : match[2] === "-" ? "blocked" : "pending",
|
||||
...importedTaskRange(payload.sourceModifiedAt, []),
|
||||
reports: []
|
||||
});
|
||||
parentsByDepth[supportedDepth] = sourceId;
|
||||
parentsByDepth.length = supportedDepth + 1;
|
||||
}
|
||||
expandParentRanges(tasks);
|
||||
return { tasks, warnings };
|
||||
}
|
||||
|
||||
function reportsForTask(body: string, payload: MdtodoImportPayload, sourceId: string, warnings: MdtodoImportPlan["warnings"]) {
|
||||
const reports: MdtodoImportTask["reports"] = [];
|
||||
for (const link of reportLinks(body)) {
|
||||
const resolved = path.resolve(path.dirname(payload.sourcePath), link.href);
|
||||
const report = payload.reports.find((candidate) => path.resolve(candidate.sourcePath) === resolved);
|
||||
if (!report) {
|
||||
warnings.push({ code: "report_file_missing", sourceId, href: link.href, message: `Report file could not be read: ${link.href}` });
|
||||
continue;
|
||||
}
|
||||
reports.push({ title: documentTitle(report.body.split(/\r?\n/u), report.sourcePath), body: report.body, status: "imported", createdAt: requiredIsoDate(report.modifiedAt, "report.modifiedAt") });
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
function importedTaskRange(sourceModifiedAt: string, reports: MdtodoImportTask["reports"]) {
|
||||
const sourceTime = new Date(requiredIsoDate(sourceModifiedAt, "sourceModifiedAt")).getTime();
|
||||
const reportTimes = reports.map((report) => new Date(report.createdAt).getTime());
|
||||
const dueTime = reportTimes.length ? Math.max(...reportTimes) : sourceTime;
|
||||
return { startAt: new Date(dueTime - 86400000).toISOString(), dueAt: new Date(dueTime).toISOString() };
|
||||
}
|
||||
|
||||
function expandParentRanges(tasks: MdtodoImportTask[]) {
|
||||
const tasksBySourceId = new Map(tasks.map((task) => [task.sourceId, task]));
|
||||
const deepestFirst = [...tasks].sort((left, right) => taskHierarchyDepth(right, tasksBySourceId) - taskHierarchyDepth(left, tasksBySourceId));
|
||||
for (const child of deepestFirst) {
|
||||
if (!child.parentSourceId) continue;
|
||||
const parent = tasksBySourceId.get(child.parentSourceId);
|
||||
if (!parent) continue;
|
||||
const startAt = Math.min(new Date(parent.startAt).getTime(), new Date(child.startAt).getTime());
|
||||
const dueAt = Math.max(new Date(parent.dueAt).getTime(), new Date(child.dueAt).getTime());
|
||||
parent.startAt = new Date(startAt).toISOString();
|
||||
parent.dueAt = new Date(dueAt).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function nearestRxxParent(sourceId: string, sourceIds: Set<string>) {
|
||||
const parts = sourceId.split(".");
|
||||
while (parts.length > 1) {
|
||||
parts.pop();
|
||||
const candidate = parts.join(".");
|
||||
if (sourceIds.has(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function taskHierarchyDepth(task: MdtodoImportTask, tasksBySourceId: Map<string, MdtodoImportTask>) {
|
||||
let depth = 0;
|
||||
let parentSourceId = task.parentSourceId;
|
||||
while (parentSourceId && depth < 3) {
|
||||
depth += 1;
|
||||
parentSourceId = tasksBySourceId.get(parentSourceId)?.parentSourceId ?? null;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function reportLinks(markdown: string) {
|
||||
const links: Array<{ label: string; href: string }> = [];
|
||||
for (const match of String(markdown ?? "").matchAll(markdownLinkPattern)) {
|
||||
const label = String(match[1] ?? "").trim();
|
||||
const href = String(match[2] ?? "").trim().replace(/^<|>$/gu, "").split(/\s+/u)[0] ?? "";
|
||||
if (!href || !/\.md(?:#.*)?$/iu.test(href) || /^(?:[a-z][a-z0-9+.-]*:|\/|[A-Za-z]:\/)/iu.test(href)) continue;
|
||||
if (!/(?:任务报告|task[_ -]?report|report)/iu.test(`${label} ${href}`)) continue;
|
||||
links.push({ label, href: href.split("#")[0] });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
type RxxBlock = { sourceId: string; title: string; status: TaskStatus; lineIndex: number; endLine: number };
|
||||
function collectRxxBlocks(lines: string[]): RxxBlock[] {
|
||||
const blocks: Omit<RxxBlock, "endLine">[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = rxxHeadingPattern.exec(lines[index]);
|
||||
if (!match) continue;
|
||||
const rest = String(match[3] ?? "").trim();
|
||||
blocks.push({ sourceId: match[2].replace(/^r/iu, "R"), title: stripStatus(rest), status: statusFrom(rest), lineIndex: index });
|
||||
}
|
||||
return blocks.map((block, index) => ({ ...block, endLine: blocks[index + 1]?.lineIndex ?? lines.length }));
|
||||
}
|
||||
|
||||
function statusFrom(value: string): TaskStatus {
|
||||
const markers = [...value.matchAll(/\[([^\]]+)\]/gu)].map((match) => String(match[1]).trim().toLowerCase().replace(/[\s-]+/gu, "_"));
|
||||
if (markers.some((marker) => marker === "blocked")) return "blocked";
|
||||
if (markers.some((marker) => ["completed", "finished", "done"].includes(marker))) return "completed";
|
||||
if (markers.some((marker) => ["in_progress", "processing"].includes(marker))) return "in_progress";
|
||||
return "pending";
|
||||
}
|
||||
function stripStatus(value: string) { return value.replace(/\s*\[(?:open|todo|blocked|in[_ -]?progress|processing|completed|finished|done)\]\s*/giu, " ").trim(); }
|
||||
function rxxDepth(sourceId: string) { return Math.max(0, sourceId.split(".").length - 1); }
|
||||
function documentTitle(lines: string[], sourcePath: string) { return cleanTitle(lines.find((line) => /^#{1,6}\s+\S/u.test(line))?.replace(/^#{1,6}\s+/u, "") || path.basename(sourcePath, path.extname(sourcePath))); }
|
||||
function firstBodyLine(body: string) { return body.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || "Untitled task"; }
|
||||
function conciseBodyTitle(body: string) {
|
||||
return firstBodyLine(body);
|
||||
}
|
||||
function cleanTitle(value: string) { return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task"; }
|
||||
function requiredIsoDate(value: unknown, field: string) { const date = new Date(String(value ?? "")); if (Number.isNaN(date.valueOf())) throw codedError("invalid_mdtodo_file_time", `${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,13 @@
|
||||
import { TaskTreeStore } from "./store.ts";
|
||||
|
||||
export function taskTreeRuntime(env: Record<string, string | undefined> = process.env) {
|
||||
const databaseUrl = env.TASKTREE_DATABASE_URL || env.DATABASE_URL || "";
|
||||
if (!databaseUrl) throw Object.assign(new Error("TASKTREE_DATABASE_URL or DATABASE_URL is required"), { code: "missing_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,384 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import pg from "pg";
|
||||
|
||||
import type { ExecutionReport, Milestone, TaskGroup, TaskGroupOverview, TaskItem, TaskStatus, Timeline } from "./contracts.ts";
|
||||
import type { MdtodoImportPlan } from "./mdtodo-import.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','subsubtask')),
|
||||
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)`
|
||||
,`ALTER TABLE tasktree_tasks DROP CONSTRAINT IF EXISTS tasktree_tasks_kind_check`
|
||||
,`ALTER TABLE tasktree_tasks ADD CONSTRAINT tasktree_tasks_kind_check CHECK (kind IN ('task','subtask','subsubtask'))`
|
||||
];
|
||||
|
||||
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),($2) ON CONFLICT DO NOTHING", ["tasktree-20260716-v1", "tasktree-20260717-v2-subsubtask"]);
|
||||
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 groupOverview(): Promise<TaskGroupOverview[]> {
|
||||
await this.ensureSchema();
|
||||
const result = await this.pool.query(`
|
||||
SELECT g.*,
|
||||
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='task')::int AS task_count,
|
||||
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subtask')::int AS subtask_count,
|
||||
COUNT(DISTINCT t.id) FILTER (WHERE t.kind='subsubtask')::int AS subsubtask_count,
|
||||
COUNT(DISTINCT r.id)::int AS report_count,
|
||||
MIN(t.start_at) AS start_at,
|
||||
MAX(t.due_at) AS due_at
|
||||
FROM tasktree_groups g
|
||||
LEFT JOIN tasktree_tasks t ON t.group_id=g.id
|
||||
LEFT JOIN tasktree_execution_reports r ON r.task_id=t.id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.updated_at DESC, g.name
|
||||
`);
|
||||
return result.rows.map(overviewRow);
|
||||
}
|
||||
|
||||
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 groupStats(groupId: string) {
|
||||
await this.ensureSchema();
|
||||
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();
|
||||
ensureTimeRange(input.startAt ?? null, input.dueAt ?? null);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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 startAt = patch.startAt === undefined ? row.startAt : patch.startAt;
|
||||
const dueAt = patch.dueAt === undefined ? row.dueAt : patch.dueAt;
|
||||
ensureTimeRange(startAt, dueAt);
|
||||
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,
|
||||
startAt, 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();
|
||||
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> {
|
||||
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");
|
||||
if (input.taskId) {
|
||||
const task = await client.query("SELECT group_id FROM tasktree_tasks WHERE id=$1", [input.taskId]);
|
||||
if (!task.rows[0]) throw domainError("task_not_found", "task was not found");
|
||||
if (task.rows[0].group_id !== input.groupId) throw domainError("task_group_mismatch", "task belongs to another taskgroup");
|
||||
}
|
||||
const result = await client.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]
|
||||
);
|
||||
await client.query("UPDATE tasktree_groups SET updated_at=now() WHERE id=$1", [input.groupId]);
|
||||
await client.query("COMMIT");
|
||||
return milestoneRow(result.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
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 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();
|
||||
const groupId = `tg_${randomUUID()}`;
|
||||
const taskIds = new Map<string, string>();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const groupResult = await client.query(
|
||||
"INSERT INTO tasktree_groups (id,name,description) VALUES ($1,$2,$3) RETURNING *",
|
||||
[groupId, plan.groupName, plan.groupDescription]
|
||||
);
|
||||
for (let index = 0; index < plan.tasks.length; index += 1) {
|
||||
const task = plan.tasks[index];
|
||||
const taskId = `tt_${randomUUID()}`;
|
||||
taskIds.set(task.sourceId, taskId);
|
||||
const parentId = task.parentSourceId ? taskIds.get(task.parentSourceId) ?? null : null;
|
||||
const parentTask = parentId ? plan.tasks.find((candidate) => candidate.sourceId === task.parentSourceId) : null;
|
||||
const kind = !parentId ? "task" : parentTask?.parentSourceId ? "subsubtask" : "subtask";
|
||||
await client.query(
|
||||
`INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,start_at,due_at,sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[taskId, groupId, parentId, kind, task.title, task.description, task.status, task.startAt, task.dueAt, index]
|
||||
);
|
||||
for (const report of task.reports) {
|
||||
await client.query(
|
||||
"INSERT INTO tasktree_execution_reports (id,task_id,title,body,status,created_at) VALUES ($1,$2,$3,$4,$5,$6)",
|
||||
[`tr_${randomUUID()}`, taskId, report.title, report.body, report.status, report.createdAt]
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return { dryRun: false, sourcePath: plan.sourcePath, group: groupRow(groupResult.rows[0]), parserMode: plan.parserMode, summary: plan.summary, warnings: plan.warnings };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
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 overviewRow(row: any): TaskGroupOverview { return { group: groupRow(row), taskCount: Number(row.task_count), subtaskCount: Number(row.subtask_count), subsubtaskCount: Number(row.subsubtask_count), reportCount: Number(row.report_count), startAt: nullableIso(row.start_at), dueAt: nullableIso(row.due_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 }); }
|
||||
function ensureTimeRange(startAt: string | null, dueAt: string | null) {
|
||||
if (startAt !== null && dueAt !== null && new Date(startAt).valueOf() > new Date(dueAt).valueOf()) {
|
||||
throw domainError("invalid_time_range", "startAt must be before or equal to dueAt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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("dispatcher keeps batch validation atomic and completion gated", async () => {
|
||||
let batchCalls = 0;
|
||||
let createCalls = 0;
|
||||
let updateCalls = 0;
|
||||
const store = {
|
||||
async createTask() { createCalls += 1; return null; },
|
||||
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 invalidRange = await dispatch({
|
||||
operation: "task.create",
|
||||
groupId: "tg_1",
|
||||
title: "Invalid range",
|
||||
startAt: "2026-07-19T00:00:00.000Z",
|
||||
dueAt: "2026-07-18T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(invalidRange.ok, false);
|
||||
assert.equal(invalidRange.error?.code, "invalid_time_range");
|
||||
assert.equal(createCalls, 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");
|
||||
|
||||
const workflow = await dispatch({ operation: "workflow.start", taskId: "tt_1" });
|
||||
assert.equal(workflow.ok, false);
|
||||
assert.equal(workflow.error?.code, "temporal_address_required");
|
||||
});
|
||||
|
||||
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");
|
||||
const store = new TaskTreeStore(databaseUrl);
|
||||
const group = await store.createGroup(`TaskTree test ${Date.now()}`);
|
||||
const otherGroup = await store.createGroup(`TaskTree cross-group 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" });
|
||||
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 assert.rejects(
|
||||
store.createMilestone({ groupId: otherGroup.id, taskId: task.id, title: "Wrong group", occursAt: "2026-07-17T00:00:00.000Z" }),
|
||||
(error: any) => error?.code === "task_group_mismatch"
|
||||
);
|
||||
await assert.rejects(
|
||||
store.createMilestone({ groupId: "tg_missing", title: "Missing group", occursAt: "2026-07-17T00:00:00.000Z" }),
|
||||
(error: any) => error?.code === "group_not_found"
|
||||
);
|
||||
await assert.rejects(
|
||||
store.createMilestone({ groupId: group.id, taskId: "tt_missing", title: "Missing task", occursAt: "2026-07-17T00:00:00.000Z" }),
|
||||
(error: any) => error?.code === "task_not_found"
|
||||
);
|
||||
await assert.rejects(
|
||||
store.createTask({
|
||||
groupId: group.id,
|
||||
title: "Invalid range",
|
||||
startAt: "2026-07-19T00:00:00.000Z",
|
||||
dueAt: "2026-07-18T00:00:00.000Z"
|
||||
}),
|
||||
(error: any) => error?.code === "invalid_time_range"
|
||||
);
|
||||
await assert.rejects(
|
||||
store.updateTask(task.id, { dueAt: "2026-07-15T00:00:00.000Z" }),
|
||||
(error: any) => error?.code === "invalid_time_range"
|
||||
);
|
||||
assert.equal((await store.getTask(task.id))?.dueAt, "2026-07-18T00:00:00.000Z");
|
||||
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, 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, 3);
|
||||
assert.equal(overview?.subsubtaskCount, 1);
|
||||
assert.equal(overview?.reportCount, 1);
|
||||
assert.equal(overview?.startAt, "2026-07-16T00:00:00.000Z");
|
||||
assert.equal(overview?.dueAt, "2026-07-18T00:00:00.000Z");
|
||||
} finally {
|
||||
await store.deleteGroup(otherGroup.id);
|
||||
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"
|
||||
|
||||
@@ -711,9 +711,9 @@ test("artifact reuse paths preserve catalog provenance instead of current planne
|
||||
assert.match(artifactPublish, /envReuseRecipe: servicePlan\.envReuseRecipe \?\? null/u);
|
||||
assert.match(artifactPublish, /componentInputHash: serviceResultValue\(env, service\.serviceId, "COMPONENT_INPUT_HASH"\) \?\? \(reusedServiceImage \? null : service\.componentInputHash \?\? null\)/u);
|
||||
|
||||
const gitopsRender = await readFile("scripts/gitops-render.mjs", "utf8");
|
||||
assert.match(gitopsRender, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u);
|
||||
assert.doesNotMatch(gitopsRender, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
|
||||
const perServicePublish = await readFile("scripts/src/gitops-render/templates/per-service-publish.sh", "utf8");
|
||||
assert.match(perServicePublish, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u);
|
||||
assert.doesNotMatch(perServicePublish, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
|
||||
});
|
||||
|
||||
test("artifact publish ignores stale external reports for envreuse services that do not need current build artifacts", async () => {
|
||||
@@ -1483,6 +1483,38 @@ test("v03 planner reads env reuse declarations and catalog from v03 lane config"
|
||||
}
|
||||
});
|
||||
|
||||
test("v03 TaskTree API and worker share one TypeScript environment artifact", async () => {
|
||||
const reuse = await readStructuredFile(process.cwd(), "gitops/reuse.ymal");
|
||||
const group = reuse.spec?.envArtifactGroups?.["hwlab-ts-runtime-env"];
|
||||
const services = reuse.spec?.services ?? {};
|
||||
const tasktreeServiceIds = ["hwlab-tasktree-api", "hwlab-tasktree-worker"];
|
||||
const expectedEnvIdentity = [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"deploy/runtime/launcher/hwlab-env-reuse-launcher.ts",
|
||||
"internal/dev-entrypoint/artifact-runtime.mjs"
|
||||
];
|
||||
|
||||
assert.ok(group, "shared TypeScript environment group");
|
||||
for (const serviceId of tasktreeServiceIds) {
|
||||
assert.equal(group.services?.includes(serviceId), true, `${serviceId} shared environment membership`);
|
||||
assert.deepEqual(services[serviceId]?.runtimeReuse?.envIdentity?.paths, expectedEnvIdentity, `${serviceId} runtime env identity`);
|
||||
assert.deepEqual(services[serviceId]?.envReuse?.envIdentityFiles, expectedEnvIdentity, `${serviceId} env reuse identity`);
|
||||
assert.equal(services[serviceId]?.envReuse?.mode, "env-reuse-git-mirror-checkout", `${serviceId} runtime mode`);
|
||||
}
|
||||
assert.deepEqual(services["hwlab-tasktree-api"].runtimeReuse.codeIdentity.paths, [
|
||||
"cmd/hwlab-tasktree-api",
|
||||
"internal/tasktree",
|
||||
"tools/src/tasktree-cli.ts",
|
||||
"deploy/runtime/boot/hwlab-tasktree-api.sh"
|
||||
]);
|
||||
assert.deepEqual(services["hwlab-tasktree-worker"].runtimeReuse.codeIdentity.paths, [
|
||||
"cmd/hwlab-tasktree-worker",
|
||||
"internal/tasktree",
|
||||
"deploy/runtime/boot/hwlab-tasktree-worker.sh"
|
||||
]);
|
||||
});
|
||||
|
||||
test("v03 planner builds one shared Go env artifact for compatible Go services", async () => {
|
||||
const repo = await createFixtureRepo({ catalog: false });
|
||||
await addGoEnvReuseFixture(repo);
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
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 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) {
|
||||
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,152 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
assert.ok(result.usage.includes("hwlab-cli tasktree group list|overview"));
|
||||
assert.ok(result.usage.includes("hwlab-cli tasktree group get|delete|stats --group ID"));
|
||||
assert.match(result.usage.join("\n"), /task update .*--start ISO\|null.*--due ISO\|null/u);
|
||||
assert.doesNotMatch(result.usage.join("\n"), /report create/u);
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
test("TaskTree rejects unknown, duplicate, hidden, and extra CLI arguments", async () => {
|
||||
const cases: Array<[string[], string]> = [
|
||||
[["group", "list", "unexpected"], "invalid_arguments"],
|
||||
[["group", "list", "--bogus", "value"], "invalid_option"],
|
||||
[["group", "create", "--name", "One", "--name", "Two"], "duplicate_option"],
|
||||
[["report", "create", "--task", "tt_1", "--title", "Hidden", "--body", "body"], "unsupported_command"],
|
||||
[["group", "list", "--api-url", "http://tasktree.test"], "invalid_option"],
|
||||
[["group", "list", "--stdin"], "invalid_option"]
|
||||
];
|
||||
for (const [argv, code] of cases) {
|
||||
await assert.rejects(runTaskTreeCli(argv), (error: any) => error?.code === code);
|
||||
}
|
||||
});
|
||||
|
||||
test("TaskTree accepts an explicit empty description value", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let received: unknown;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
received = JSON.parse(String(init?.body));
|
||||
return Response.json({ ok: true, operation: "group.create", data: {} });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await runTaskTreeCli(
|
||||
["group", "create", "--name", "Group", "--description", "", "--overapi"],
|
||||
{ HWLAB_TASKTREE_API_URL: "http://tasktree.test" }
|
||||
);
|
||||
assert.deepEqual(received, { operation: "group.create", name: "Group", description: "" });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("TaskTree returns stable transport and runtime configuration errors", async () => {
|
||||
await assert.rejects(
|
||||
runTaskTreeCli(["group", "list", "--overapi"]),
|
||||
(error: any) => error?.code === "missing_api_url"
|
||||
);
|
||||
await assert.rejects(
|
||||
runTaskTreeCli(["health"], {}),
|
||||
(error: any) => error?.code === "missing_database_url"
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = (async () => { throw new Error("connection refused"); }) as typeof fetch;
|
||||
await assert.rejects(
|
||||
runTaskTreeCli(["group", "list", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }),
|
||||
(error: any) => error?.code === "api_unreachable"
|
||||
);
|
||||
|
||||
for (const response of [
|
||||
new Response("not json", { status: 502 }),
|
||||
Response.json({ ok: true, operation: "task.get", data: {} }),
|
||||
Response.json({ ok: true, operation: "group.list", data: {} }, { status: 500 })
|
||||
]) {
|
||||
globalThis.fetch = (async () => response) as typeof fetch;
|
||||
await assert.rejects(
|
||||
runTaskTreeCli(["group", "list", "--overapi"], { HWLAB_TASKTREE_API_URL: "http://tasktree.test" }),
|
||||
(error: any) => error?.code === "invalid_api_response"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { TaskStatus, TaskTreeCommand } from "../../internal/tasktree/contracts.ts";
|
||||
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, stdin?: string) {
|
||||
const parsed = parse(argv);
|
||||
if (parsed.help || parsed.positionals.length === 0) return help();
|
||||
validateArguments(parsed);
|
||||
const command = await commandFrom(parsed, stdin);
|
||||
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|overview",
|
||||
"hwlab-cli tasktree group get|delete|stats --group ID",
|
||||
"hwlab-cli tasktree group create --name TEXT [--description TEXT]",
|
||||
"hwlab-cli tasktree group import-markdown --file PATH [--name TEXT] [--dry-run]",
|
||||
"hwlab-cli tasktree task list --group ID",
|
||||
"hwlab-cli tasktree task get --task ID",
|
||||
"hwlab-cli tasktree task create --group ID [--parent ID] (--title TEXT|--stdin) [--description TEXT] [--start ISO] [--due ISO]",
|
||||
"hwlab-cli tasktree task create-batch --group ID [--parent ID] [--description TEXT] [--start ISO] [--due ISO] TITLE...",
|
||||
"hwlab-cli tasktree task update --task ID [--title TEXT|--stdin] [--description TEXT] [--status STATUS] [--start ISO|null] [--due ISO|null]",
|
||||
"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 list|get --task ID|--report ID",
|
||||
"hwlab-cli tasktree report write --task ID --title TEXT (--body TEXT|--body-file PATH|--stdin) [--status STATUS]",
|
||||
"hwlab-cli tasktree timeline --group ID [--overapi]",
|
||||
"hwlab-cli tasktree workflow start --task ID"
|
||||
],
|
||||
transportContract: "--overapi only changes transport; commands and options are identical",
|
||||
transportOptions: "--overapi [--api-url URL]",
|
||||
hierarchy: "taskgroup -> task -> subtask -> subsubtask",
|
||||
completionContract: "a task requires at least one execution report before completion"
|
||||
};
|
||||
}
|
||||
|
||||
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") };
|
||||
if (group === "group" && action === "list") return { operation: "group.list" };
|
||||
if (group === "group" && action === "overview") return { operation: "group.overview" };
|
||||
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 === "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 === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") };
|
||||
throw codedError("unsupported_command", `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 codedError("missing_api_url", "HWLAB_TASKTREE_API_URL or --api-url is required with --overapi");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl.replace(/\/$/u, "")}/v1/tasktree/commands`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(command)
|
||||
});
|
||||
} catch {
|
||||
throw codedError("api_unreachable", "TaskTree API request failed");
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
if (
|
||||
!body
|
||||
|| typeof body !== "object"
|
||||
|| typeof (body as any).ok !== "boolean"
|
||||
|| (body as any).operation !== command.operation
|
||||
|| response.ok !== (body as any).ok
|
||||
) {
|
||||
throw codedError("invalid_api_response", `TaskTree API returned an invalid response (${response.status})`);
|
||||
}
|
||||
return { ...(body as object), transport: "api", httpStatus: response.status };
|
||||
}
|
||||
|
||||
type Parsed = { positionals: string[]; values: Record<string, string>; flags: Set<string>; overapi: boolean; help: boolean };
|
||||
function parse(argv: string[]): Parsed {
|
||||
const parsed: Parsed = { positionals: [], values: {}, flags: new Set(), overapi: false, help: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (token === "--overapi") {
|
||||
if (parsed.overapi) throw codedError("duplicate_option", "--overapi may only be provided once");
|
||||
parsed.overapi = true;
|
||||
}
|
||||
else if (token === "--dry-run" || token === "--stdin") {
|
||||
const flag = token.slice(2);
|
||||
if (parsed.flags.has(flag)) throw codedError("duplicate_option", `${token} may only be provided once`);
|
||||
parsed.flags.add(flag);
|
||||
}
|
||||
else if (token === "--help" || token === "-h") parsed.help = true;
|
||||
else if (token.startsWith("--")) {
|
||||
const key = token.slice(2);
|
||||
const value = argv[index + 1];
|
||||
if (parsed.values[key] !== undefined) throw codedError("duplicate_option", `${token} may only be provided once`);
|
||||
if (value === undefined || value.startsWith("--")) throw codedError("missing_option_value", `${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 codedError("missing_option", `--${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 }); }
|
||||
|
||||
type CommandSpec = {
|
||||
positionals: number | { min: number; max?: number };
|
||||
options?: string[];
|
||||
flags?: string[];
|
||||
};
|
||||
|
||||
const COMMAND_SPECS: Record<string, CommandSpec> = {
|
||||
health: { positionals: 1 },
|
||||
timeline: { positionals: 1, options: ["group"] },
|
||||
"group list": { positionals: 2 },
|
||||
"group overview": { positionals: 2 },
|
||||
"group get": { positionals: 2, options: ["group"] },
|
||||
"group create": { positionals: 2, options: ["name", "description"] },
|
||||
"group delete": { positionals: 2, options: ["group"] },
|
||||
"group stats": { positionals: 2, options: ["group"] },
|
||||
"group import-markdown": { positionals: 2, options: ["file", "name"], flags: ["dry-run"] },
|
||||
"task list": { positionals: 2, options: ["group"] },
|
||||
"task get": { positionals: 2, options: ["task"] },
|
||||
"task create": { positionals: 2, options: ["group", "parent", "title", "description", "start", "due"], flags: ["stdin"] },
|
||||
"task create-batch": { positionals: { min: 3 }, options: ["group", "parent", "description", "start", "due"] },
|
||||
"task update": { positionals: 2, options: ["task", "title", "description", "status", "start", "due"], flags: ["stdin"] },
|
||||
"task delete": { positionals: 2, options: ["task"] },
|
||||
"task remove": { positionals: 2, options: ["task"] },
|
||||
"task start": { positionals: 2, options: ["task"] },
|
||||
"task complete": { positionals: 2, options: ["task"] },
|
||||
"task done": { positionals: 2, options: ["task"] },
|
||||
"milestone create": { positionals: 2, options: ["group", "task", "title", "at"] },
|
||||
"report list": { positionals: 2, options: ["task"] },
|
||||
"report get": { positionals: 2, options: ["report"] },
|
||||
"report write": { positionals: 2, options: ["task", "title", "body", "body-file", "status"], flags: ["stdin"] },
|
||||
"workflow start": { positionals: 2, options: ["task"] }
|
||||
};
|
||||
|
||||
function validateArguments(parsed: Parsed) {
|
||||
const key = parsed.positionals.slice(0, 2).join(" ");
|
||||
const spec = COMMAND_SPECS[key] ?? COMMAND_SPECS[parsed.positionals[0]];
|
||||
if (!spec) throw codedError("unsupported_command", `unsupported TaskTree command: ${key}`);
|
||||
const positionals = typeof spec.positionals === "number"
|
||||
? { min: spec.positionals, max: spec.positionals }
|
||||
: spec.positionals;
|
||||
if (parsed.positionals.length < positionals.min || (positionals.max !== undefined && parsed.positionals.length > positionals.max)) {
|
||||
throw codedError("invalid_arguments", `unexpected positional arguments for ${key}`);
|
||||
}
|
||||
const allowedOptions = new Set(spec.options ?? []);
|
||||
if (parsed.overapi) allowedOptions.add("api-url");
|
||||
for (const option of Object.keys(parsed.values)) {
|
||||
if (!allowedOptions.has(option)) throw codedError("invalid_option", `--${option} is not valid for ${key}`);
|
||||
}
|
||||
if (parsed.values["api-url"] !== undefined && !parsed.overapi) {
|
||||
throw codedError("invalid_option", "--api-url requires --overapi");
|
||||
}
|
||||
const allowedFlags = new Set(spec.flags ?? []);
|
||||
for (const flag of parsed.flags) {
|
||||
if (!allowedFlags.has(flag)) throw codedError("invalid_option", `--${flag} is not valid for ${key}`);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
"scripts": {
|
||||
"deps": "node ../../scripts/worktree-deps.mjs --web",
|
||||
"dev": "bun run deps --quiet && vite",
|
||||
"tasktree:native:start": "bun run scripts/tasktree-native-service.ts start",
|
||||
"tasktree:native:stop": "bun run scripts/tasktree-native-service.ts stop",
|
||||
"tasktree:native:status": "bun run scripts/tasktree-native-service.ts status",
|
||||
"tasktree:native:logs": "bun run scripts/tasktree-native-service.ts logs",
|
||||
"check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts",
|
||||
"check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts",
|
||||
"check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const action = process.argv[2];
|
||||
const root = process.cwd();
|
||||
const stateDir = resolve(root, ".state/tasktree-native");
|
||||
const pidPath = resolve(stateDir, "supervisor.pid");
|
||||
const logPath = resolve(stateDir, "supervisor.log");
|
||||
const servicesLogPath = resolve(stateDir, "services.log");
|
||||
|
||||
function readPid(): number | null {
|
||||
try {
|
||||
const pid = Number(readFileSync(pidPath, "utf8").trim());
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForExit(pid: number): Promise<boolean> {
|
||||
const deadline = Date.now() + 15000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isRunning(pid)) return true;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
return !isRunning(pid);
|
||||
}
|
||||
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const pid = readPid();
|
||||
|
||||
if (action === "start") {
|
||||
if (isRunning(pid)) {
|
||||
console.log(JSON.stringify({ ok: true, action, status: "already-running", pid, logPath }));
|
||||
process.exit(0);
|
||||
}
|
||||
rmSync(pidPath, { force: true });
|
||||
const logFd = openSync(logPath, "a");
|
||||
const child = Bun.spawn([process.execPath, "scripts/tasktree-native-supervisor.ts"], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdin: "ignore",
|
||||
stdout: logFd,
|
||||
stderr: logFd,
|
||||
detached: true
|
||||
});
|
||||
child.unref();
|
||||
await Bun.write(pidPath, `${child.pid}\n`);
|
||||
console.log(JSON.stringify({ ok: true, action, status: "started", pid: child.pid, logPath }));
|
||||
} else if (action === "stop") {
|
||||
if (!isRunning(pid)) {
|
||||
rmSync(pidPath, { force: true });
|
||||
console.log(JSON.stringify({ ok: true, action, status: "stopped" }));
|
||||
} else {
|
||||
process.kill(pid!, "SIGTERM");
|
||||
if (!await waitForExit(pid!)) {
|
||||
console.error(JSON.stringify({ ok: false, action, status: "stop-timeout", pid }));
|
||||
process.exit(1);
|
||||
}
|
||||
rmSync(pidPath, { force: true });
|
||||
console.log(JSON.stringify({ ok: true, action, status: "stopped", pid }));
|
||||
}
|
||||
} else if (action === "status") {
|
||||
const running = isRunning(pid);
|
||||
console.log(JSON.stringify({ ok: running, action, status: running ? "running" : "stopped", pid: running ? pid : null, logPath, servicesLogPath }));
|
||||
process.exit(running ? 0 : 1);
|
||||
} else if (action === "logs") {
|
||||
for (const path of [logPath, servicesLogPath]) {
|
||||
try {
|
||||
console.log(readFileSync(path, "utf8").trimEnd().split("\n").slice(-80).join("\n"));
|
||||
} catch {
|
||||
// A log is optional until its process has started.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("usage: bun run scripts/tasktree-native-service.ts <start|stop|status|logs>");
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const webRoot = process.cwd();
|
||||
const repoRoot = resolve(webRoot, "../..");
|
||||
const stateDir = resolve(webRoot, ".state/tasktree-native");
|
||||
const databaseEnvFile = process.env.TASKTREE_NATIVE_DATABASE_ENV_FILE;
|
||||
const temporalAddress = process.env.TASKTREE_TEMPORAL_ADDRESS;
|
||||
|
||||
if (!databaseEnvFile) throw new Error("TASKTREE_NATIVE_DATABASE_ENV_FILE is required");
|
||||
if (!temporalAddress) throw new Error("TASKTREE_TEMPORAL_ADDRESS is required");
|
||||
|
||||
function readEnvValue(path: string, key: string): string {
|
||||
const line = readFileSync(path, "utf8")
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.startsWith(`${key}=`));
|
||||
if (!line) throw new Error(`${key} is missing from the configured env source`);
|
||||
const raw = line.slice(key.length + 1).trim();
|
||||
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) return raw.slice(1, -1);
|
||||
return raw;
|
||||
}
|
||||
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const logFd = openSync(resolve(stateDir, "services.log"), "a");
|
||||
const databaseUrl = readEnvValue(databaseEnvFile, "DATABASE_URL");
|
||||
const commonEnv = {
|
||||
...process.env,
|
||||
TASKTREE_DATABASE_URL: databaseUrl,
|
||||
TASKTREE_TEMPORAL_ADDRESS: temporalAddress,
|
||||
TASKTREE_TEMPORAL_NAMESPACE: process.env.TASKTREE_TEMPORAL_NAMESPACE ?? "unidesk",
|
||||
TASKTREE_TEMPORAL_TASK_QUEUE: process.env.TASKTREE_TEMPORAL_TASK_QUEUE ?? "hwlab-v03-tasktree"
|
||||
};
|
||||
|
||||
const children = [
|
||||
Bun.spawn([process.execPath, "cmd/hwlab-tasktree-worker/main.ts"], { cwd: repoRoot, env: commonEnv, stdin: "ignore", stdout: logFd, stderr: logFd }),
|
||||
Bun.spawn([process.execPath, "cmd/hwlab-tasktree-api/main.ts"], { cwd: repoRoot, env: { ...commonEnv, TASKTREE_API_HOST: "127.0.0.1", TASKTREE_API_PORT: "6673" }, stdin: "ignore", stdout: logFd, stderr: logFd }),
|
||||
Bun.spawn([process.execPath, "x", "vite", "--config", "scripts/tasktree-native-vite.config.ts"], { cwd: webRoot, env: { ...process.env, HWLAB_TASKTREE_NATIVE_API_URL: "http://127.0.0.1:6673" }, stdin: "ignore", stdout: logFd, stderr: logFd })
|
||||
];
|
||||
|
||||
process.stdout.write(`${JSON.stringify({ serviceId: "hwlab-tasktree-native", status: "started", pids: children.map((child) => child.pid), valuesPrinted: false })}\n`);
|
||||
|
||||
let stopping = false;
|
||||
async function stop(): Promise<void> {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
for (const child of children) child.kill("SIGTERM");
|
||||
await Promise.allSettled(children.map((child) => child.exited));
|
||||
closeSync(logFd);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
const exited = await Promise.race(children.map(async (child, index) => ({ index, code: await child.exited })));
|
||||
if (!stopping) {
|
||||
process.stderr.write(`${JSON.stringify({ serviceId: "hwlab-tasktree-native", status: "child-exited", ...exited })}\n`);
|
||||
await stop();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
const tasktreeApi = process.env.HWLAB_TASKTREE_NATIVE_API_URL ?? "http://127.0.0.1:6673";
|
||||
const webHost = process.env.TASKTREE_NATIVE_WEB_HOST ?? "127.0.0.1";
|
||||
const webPort = Number.parseInt(process.env.TASKTREE_NATIVE_WEB_PORT ?? "4173", 10);
|
||||
|
||||
const authPayload = {
|
||||
authenticated: true,
|
||||
mode: "server",
|
||||
authMethod: "native-web",
|
||||
identityAuthority: "hwlab-local",
|
||||
sessionKind: "browser",
|
||||
actor: { id: "usr_tasktree_native", username: "tasktree-native", role: "admin", status: "active" },
|
||||
capabilities: { web: "available", admin: "available" },
|
||||
access: { nav: { profileId: "tasktree-native", allowedIds: ["project.tasktree"], valuesRedacted: true } },
|
||||
expiresAt: null,
|
||||
valuesRedacted: true
|
||||
};
|
||||
|
||||
function nativeAuth(): Plugin {
|
||||
return {
|
||||
name: "hwlab-tasktree-native-auth",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
if (request.url === "/auth/logout") {
|
||||
response.writeHead(204, { "cache-control": "no-store" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.url !== "/auth/session" && request.url !== "/auth/bootstrap" && request.url !== "/auth/login") return next();
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
...(request.url === "/auth/login" ? { "set-cookie": "hwlab_session=tasktree-native; Path=/; HttpOnly; SameSite=Lax" } : {})
|
||||
});
|
||||
response.end(JSON.stringify(authPayload));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function nativeRuntimeConfig(): Plugin {
|
||||
return {
|
||||
name: "hwlab-tasktree-native-runtime-config",
|
||||
transformIndexHtml() {
|
||||
return [{
|
||||
tag: "script",
|
||||
injectTo: "head-prepend",
|
||||
children: `window.HWLAB_CLOUD_WEB_CONFIG = {
|
||||
displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" },
|
||||
workbench: {
|
||||
realtimeFeatures: { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true },
|
||||
debugCapabilities: { isolatedKafka: false, rawHwlabEventWindow: { enabled: false, maxEntries: 1, maxRetainedBytes: 1 } },
|
||||
traceTimeline: { autoExpandRunning: false, autoCollapseTerminal: false }
|
||||
}
|
||||
};`
|
||||
}];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root,
|
||||
plugins: [nativeRuntimeConfig(), nativeAuth(), vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("../src", import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: webHost,
|
||||
port: webPort,
|
||||
strictPort: true,
|
||||
hmr: { clientPort: webPort },
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 300,
|
||||
ignored: ["**/node_modules/**", "**/.git/**", "**/.state/**"]
|
||||
},
|
||||
proxy: {
|
||||
"/v1/tasktree": {
|
||||
target: tasktreeApi,
|
||||
changeOrigin: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 TaskTreeGroupOverview, 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,16 @@
|
||||
import { fetchJson } from "@/api/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" | "subsubtask"; 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[] };
|
||||
export type TaskTreeGroupOverview = { group: TaskTreeGroup; taskCount: number; subtaskCount: number; subsubtaskCount: number; reportCount: number; startAt: string | null; dueAt: string | null };
|
||||
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" }),
|
||||
overview: (): Promise<ApiResult<Result<{ groups: TaskTreeGroupOverview[] }>>> => fetchJson("/v1/tasktree/overview", { timeoutMs: 12000, timeoutName: "TaskTree overview" }),
|
||||
timeline: (groupId: string): Promise<ApiResult<Result<TaskTreeTimeline>>> => fetchJson(`/v1/tasktree/groups/${encodeURIComponent(groupId)}/timeline`, { timeoutMs: 12000, timeoutName: "TaskTree timeline" })
|
||||
};
|
||||
@@ -13,18 +13,20 @@ const props = withDefaults(defineProps<{
|
||||
closeOnBackdrop?: boolean;
|
||||
busy?: boolean;
|
||||
initialFocus?: string;
|
||||
surfaceClass?: string;
|
||||
}>(), {
|
||||
description: "",
|
||||
wide: false,
|
||||
fullscreen: false,
|
||||
closeOnBackdrop: true,
|
||||
busy: false,
|
||||
initialFocus: ""
|
||||
initialFocus: "",
|
||||
surfaceClass: ""
|
||||
});
|
||||
|
||||
defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverlaySurface :open="open" :title="title" :description="description" :wide="wide" :fullscreen="fullscreen" :close-on-backdrop="closeOnBackdrop" :busy="busy" :initial-focus="initialFocus" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
|
||||
<OverlaySurface :open="open" :title="title" :description="description" :wide="wide" :fullscreen="fullscreen" :close-on-backdrop="closeOnBackdrop" :busy="busy" :initial-focus="initialFocus" :surface-class="surfaceClass" @close="$emit('close')"><slot /><template v-if="$slots.footer" #footer><slot name="footer" /></template></OverlaySurface>
|
||||
</template>
|
||||
|
||||
@@ -71,7 +71,7 @@ function close(): void {
|
||||
>
|
||||
<header class="console-overlay-header">
|
||||
<div class="console-overlay-title-stack">
|
||||
<h2 :id="titleId">{{ title }}</h2>
|
||||
<h2 :id="titleId" :title="title">{{ title }}</h2>
|
||||
<p v-if="description" :id="descriptionId">{{ description }}</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ChevronLeft, ChevronRight, Maximize2, PanelRight, X } from "lucide-vue-next";
|
||||
import type { TaskTreeReport, TaskTreeTask } from "@/api/tasktree";
|
||||
import ContentViewer from "@/components/common/ContentViewer.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
task: TaskTreeTask;
|
||||
tasks: TaskTreeTask[];
|
||||
reports: TaskTreeReport[];
|
||||
mode: "dialog" | "docked";
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
select: [task: TaskTreeTask];
|
||||
close: [];
|
||||
mode: [mode: "dialog" | "docked"];
|
||||
}>();
|
||||
|
||||
const taskIndex = computed(() => props.tasks.findIndex((task) => task.id === props.task.id));
|
||||
const previousTask = computed(() => taskIndex.value > 0 ? props.tasks[taskIndex.value - 1] ?? null : null);
|
||||
const nextTask = computed(() => taskIndex.value >= 0 ? props.tasks[taskIndex.value + 1] ?? null : null);
|
||||
const parent = computed(() => props.task.parentId ? props.tasks.find((task) => task.id === props.task.parentId) ?? null : null);
|
||||
const siblings = computed(() => props.tasks.filter((task) => task.parentId === props.task.parentId));
|
||||
const children = computed(() => props.tasks.filter((task) => task.parentId === props.task.id));
|
||||
const taskReports = computed(() => props.reports.filter((report) => report.taskId === props.task.id));
|
||||
const combinedMarkdown = computed(() => {
|
||||
const description = props.task.description.trim() || "未填写任务说明。";
|
||||
const reports = taskReports.value.length
|
||||
? taskReports.value.map((report) => [
|
||||
`### ${plainReportTitle(report)}`,
|
||||
`_${dateLabel(report.createdAt)}_`,
|
||||
reportMarkdown(report)
|
||||
].join("\n\n")).join("\n\n---\n\n")
|
||||
: "尚无执行报告。";
|
||||
return ["## 任务说明", description, "## 执行报告", reports].join("\n\n");
|
||||
});
|
||||
|
||||
function plainTitle(task: TaskTreeTask) {
|
||||
return task.title.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim();
|
||||
}
|
||||
function plainReportTitle(report: TaskTreeReport) {
|
||||
return report.title.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim();
|
||||
}
|
||||
function kindLabel(kind: TaskTreeTask["kind"]) { return ({ task: "Task", subtask: "Subtask", subsubtask: "Subsubtask" })[kind]; }
|
||||
function dateLabel(value: string | null) { return value ? new Date(value).toLocaleString() : "未设置"; }
|
||||
function reportMarkdown(report: TaskTreeReport) {
|
||||
const lines = report.body.replace(/\r\n?/gu, "\n").split("\n");
|
||||
const firstContent = lines.findIndex((line) => line.trim().length > 0);
|
||||
if (firstContent < 0) return report.body;
|
||||
const heading = lines[firstContent].match(/^#{1,6}\s+(.+?)\s*#*\s*$/u);
|
||||
if (!heading || normalizeHeading(heading[1]) !== normalizeHeading(report.title)) return report.body;
|
||||
lines.splice(firstContent, 1);
|
||||
return lines.join("\n").trimStart();
|
||||
}
|
||||
function normalizeHeading(value: string) { return value.replace(/\s+/gu, "").toLocaleLowerCase(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tasktree-detail-panel" :data-mode="mode">
|
||||
<header v-if="mode === 'docked'" class="tasktree-docked-header">
|
||||
<div><small>{{ kindLabel(task.kind) }}</small><strong :title="plainTitle(task)">{{ plainTitle(task) }}</strong></div>
|
||||
<button type="button" title="切换到弹窗" aria-label="切换到弹窗" @click="emit('mode', 'dialog')"><Maximize2 :size="16" aria-hidden="true" /></button>
|
||||
<button type="button" title="关闭详情" aria-label="关闭详情" @click="emit('close')"><X :size="17" aria-hidden="true" /></button>
|
||||
</header>
|
||||
<div class="tasktree-detail-shell">
|
||||
<aside v-if="mode === 'dialog'" class="tasktree-detail-tree" aria-label="局部任务层级">
|
||||
<h3>局部任务</h3>
|
||||
<ol class="tasktree-local-tree">
|
||||
<li v-if="parent" class="level-0 relation-parent">
|
||||
<button type="button" @click="emit('select', parent)"><small>父节点 · {{ kindLabel(parent.kind) }}</small><strong>{{ plainTitle(parent) }}</strong></button>
|
||||
</li>
|
||||
<li v-for="sibling in siblings" :key="sibling.id" :class="[parent ? 'level-1' : 'level-0', { current: sibling.id === task.id }]">
|
||||
<button v-if="sibling.id !== task.id" type="button" @click="emit('select', sibling)"><small>兄弟任务 · {{ kindLabel(sibling.kind) }}</small><strong>{{ plainTitle(sibling) }}</strong></button>
|
||||
<button v-else class="current" type="button" aria-current="true"><small>当前任务 · {{ kindLabel(sibling.kind) }}</small><strong>{{ plainTitle(sibling) }}</strong></button>
|
||||
</li>
|
||||
<li v-for="child in children" :key="child.id" :class="parent ? 'level-2' : 'level-1'">
|
||||
<button type="button" @click="emit('select', child)"><small>子节点 · {{ kindLabel(child.kind) }}</small><strong>{{ plainTitle(child) }}</strong></button>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
<div class="tasktree-detail-main">
|
||||
<div class="tasktree-detail-content">
|
||||
<ContentViewer class="tasktree-detail-document" :content="combinedMarkdown" mode="markdown" title="任务说明与执行报告" :filename="`tasktree-${task.id}.md`" :line-numbers="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="tasktree-detail-statusbar">
|
||||
<div class="tasktree-detail-pager" aria-label="前后任务导航">
|
||||
<button type="button" :disabled="!previousTask" title="上一个任务" aria-label="上一个任务" @click="previousTask && emit('select', previousTask)"><ChevronLeft :size="15" aria-hidden="true" /></button>
|
||||
<output>{{ taskIndex + 1 }} / {{ tasks.length }}</output>
|
||||
<button type="button" :disabled="!nextTask" title="下一个任务" aria-label="下一个任务" @click="nextTask && emit('select', nextTask)"><ChevronRight :size="15" aria-hidden="true" /></button>
|
||||
</div>
|
||||
<span>开始 <strong>{{ dateLabel(task.startAt) }}</strong></span><span>截止 <strong>{{ dateLabel(task.dueAt) }}</strong></span><span>状态 <strong>{{ task.status }}</strong></span>
|
||||
<button v-if="mode === 'dialog'" class="tasktree-detail-dock-button" type="button" title="吸附到右侧" aria-label="吸附到右侧" @click="emit('mode', 'docked')"><PanelRight :size="15" aria-hidden="true" /></button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasktree-detail-panel { display: grid; grid-template-rows: minmax(0, 1fr) 32px; width: 100%; height: 100%; min-width: 0; min-height: 0; background: var(--surface-primary); }
|
||||
.tasktree-detail-panel[data-mode="docked"] { grid-template-rows: 48px minmax(0, 1fr) 32px; }
|
||||
.tasktree-docked-header { display: flex; min-width: 0; align-items: center; gap: 6px; padding: 7px 9px 7px 12px; border-bottom: 1px solid var(--border-color); }
|
||||
.tasktree-docked-header > div { display: grid; min-width: 0; flex: 1 1 auto; gap: 1px; }
|
||||
.tasktree-docked-header small { color: var(--text-secondary); font-size: 9px; }
|
||||
.tasktree-docked-header strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-docked-header button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; padding: 0; border: 1px solid var(--border-color); border-radius: 4px; background: var(--surface-primary); color: var(--text-secondary); cursor: pointer; }
|
||||
.tasktree-detail-shell { display: grid; grid-template-columns: minmax(160px, 24%) minmax(0, 1fr); min-height: 0; }
|
||||
.tasktree-detail-panel[data-mode="docked"] .tasktree-detail-shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.tasktree-detail-tree { min-width: 0; min-height: 0; padding: 14px 10px; overflow: auto; border-right: 1px solid var(--border-color); background: color-mix(in srgb, var(--surface-secondary) 72%, var(--surface-primary)); scrollbar-width: thin; }
|
||||
.tasktree-detail-tree h3 { margin: 0 6px 14px; font-size: 12px; }
|
||||
.tasktree-local-tree { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
||||
.tasktree-local-tree li { position: relative; min-width: 0; }
|
||||
.tasktree-local-tree li.level-1 { padding-left: 15px; }
|
||||
.tasktree-local-tree li.level-2 { padding-left: 30px; }
|
||||
.tasktree-local-tree li.level-1::before,
|
||||
.tasktree-local-tree li.level-2::before { position: absolute; top: 0; bottom: 0; left: 6px; width: 1px; background: var(--border-color); content: ""; }
|
||||
.tasktree-local-tree li.level-2::before { left: 21px; box-shadow: -15px 0 0 var(--border-subtle); }
|
||||
.tasktree-local-tree li.relation-parent { margin-bottom: 5px; padding-bottom: 6px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.tasktree-detail-tree button { display: grid; width: 100%; min-width: 0; gap: 2px; padding: 8px; overflow: hidden; border: 1px solid transparent; border-radius: 4px; background: transparent; color: var(--text-primary); text-align: left; cursor: pointer; }
|
||||
.tasktree-detail-tree button:hover { border-color: var(--border-color); background: var(--surface-primary); }
|
||||
.tasktree-detail-tree button.current { border-color: #8eb7d7; background: #eaf4fb; cursor: default; }
|
||||
.tasktree-detail-tree small { color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 9px; }
|
||||
.tasktree-detail-tree strong { overflow: hidden; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-detail-main { display: grid; grid-template-rows: minmax(0, 1fr); min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.tasktree-detail-content { display: grid; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr); padding: 10px; overflow: hidden; }
|
||||
.tasktree-detail-document { width: 100%; height: 100%; max-height: 100%; min-height: 0; overflow: hidden; }
|
||||
.tasktree-detail-document :deep(.content-viewer-toolbar) { display: grid; height: 34px; min-height: 34px; grid-template-columns: minmax(0, 1fr) minmax(90px, 170px) repeat(4, 26px); align-items: center; gap: 3px; box-sizing: border-box; padding: 3px 4px; }
|
||||
.tasktree-detail-document :deep(.content-viewer-toolbar strong) { min-width: 0; max-width: none; margin-right: 0; overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-detail-document :deep(.content-viewer-search) { width: 100%; height: 26px; min-width: 0; min-height: 26px; padding-block: 2px; }
|
||||
.tasktree-detail-document :deep(.content-viewer-toolbar .icon-button) { width: 26px; height: 26px; min-height: 26px; padding: 0; }
|
||||
.tasktree-detail-document :deep(.content-viewer-body) { max-height: 100%; min-height: 0; overflow: auto; overscroll-behavior: contain; }
|
||||
.tasktree-detail-content :deep(code),
|
||||
.tasktree-detail-content :deep(a) { overflow-wrap: anywhere; word-break: break-word; }
|
||||
.tasktree-detail-statusbar { display: flex; min-width: 0; align-items: center; gap: 0; padding: 0 6px; overflow-x: auto; border-top: 1px solid var(--border-color); background: var(--surface-secondary); color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 9px; white-space: nowrap; }
|
||||
.tasktree-detail-statusbar span + span::before { margin: 0 8px; color: var(--border-color); content: "|"; }
|
||||
.tasktree-detail-statusbar strong { color: var(--text-primary); }
|
||||
.tasktree-detail-pager { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 2px; margin-right: 9px; padding-right: 8px; border-right: 1px solid var(--border-color); }
|
||||
.tasktree-detail-pager button,
|
||||
.tasktree-detail-dock-button { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; padding: 0; border: 0; border-radius: 3px; background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||
.tasktree-detail-pager button:hover:not(:disabled),
|
||||
.tasktree-detail-dock-button:hover { background: var(--surface-primary); color: var(--text-primary); }
|
||||
.tasktree-detail-pager button:disabled { opacity: 0.35; cursor: default; }
|
||||
.tasktree-detail-pager output { min-width: 42px; text-align: center; }
|
||||
.tasktree-detail-dock-button { margin-left: auto; }
|
||||
@media (max-width: 720px) {
|
||||
.tasktree-detail-shell { grid-template-columns: 140px minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -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" },
|
||||
|
||||
@@ -580,6 +580,8 @@
|
||||
color: var(--console-graphite-950);
|
||||
font-size: 16px;
|
||||
}
|
||||
.console-overlay-surface.tasktree-detail-dialog .console-overlay-title-stack { overflow: hidden; }
|
||||
.console-overlay-surface.tasktree-detail-dialog .console-overlay-title-stack h2 { overflow: hidden; font-size: 20px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.console-overlay-title-stack p {
|
||||
color: var(--console-graphite-600);
|
||||
font-size: 12px;
|
||||
@@ -739,11 +741,36 @@
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.content-viewer-markdown > :first-child { margin-top: 0; }
|
||||
.content-viewer-markdown > :last-child { margin-bottom: 0; }
|
||||
.content-viewer-markdown h1,
|
||||
.content-viewer-markdown h2,
|
||||
.content-viewer-markdown h3,
|
||||
.content-viewer-markdown h4 {
|
||||
margin: 1.35em 0 0.55em;
|
||||
color: var(--console-graphite-950);
|
||||
font-weight: 760;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.content-viewer-markdown h1 { padding-bottom: 0.35em; border-bottom: 1px solid var(--console-border); font-size: 22px; }
|
||||
.content-viewer-markdown h2 { padding-bottom: 0.3em; border-bottom: 1px solid var(--console-border); font-size: 18px; }
|
||||
.content-viewer-markdown h3 { font-size: 15px; }
|
||||
.content-viewer-markdown h4 { font-size: 13px; }
|
||||
.content-viewer-markdown p { margin: 0.7em 0; }
|
||||
.content-viewer-markdown ul,
|
||||
.content-viewer-markdown ol { display: grid; gap: 0.35em; margin: 0.7em 0; padding-left: 1.6em; }
|
||||
.content-viewer-markdown blockquote { margin: 0.8em 0; padding: 0.2em 0 0.2em 1em; border-left: 3px solid var(--console-cyan-600); color: var(--console-graphite-700); }
|
||||
.content-viewer-markdown a { color: var(--console-cyan-700); font-weight: 700; text-decoration: underline; text-underline-offset: 2px; overflow-wrap: anywhere; }
|
||||
.content-viewer-markdown hr { margin: 1.2em 0; border: 0; border-top: 1px solid var(--console-border); }
|
||||
.content-viewer-markdown img { max-width: 100%; height: auto; }
|
||||
|
||||
.content-viewer-markdown pre,
|
||||
.content-viewer-markdown code {
|
||||
font-family: var(--console-font-mono);
|
||||
}
|
||||
|
||||
.content-viewer-markdown :not(pre) > code { border-radius: 3px; background: #e9eeeb; padding: 0.12em 0.35em; color: var(--console-graphite-950); font-size: 0.9em; }
|
||||
|
||||
.content-viewer-markdown pre {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ChevronDown, ChevronRight, ZoomIn, ZoomOut } from "lucide-vue-next";
|
||||
import { tasktreeAPI, type TaskTreeGroup, type TaskTreeGroupOverview, type TaskTreeTask, type TaskTreeTimeline } from "@/api/tasktree";
|
||||
import AsyncBoundary from "@/components/common/AsyncBoundary.vue";
|
||||
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import TaskTreeDetailPanel from "@/components/tasktree/TaskTreeDetailPanel.vue";
|
||||
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
|
||||
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const groups = ref<TaskTreeGroup[]>([]);
|
||||
const overview = ref<TaskTreeGroupOverview[]>([]);
|
||||
const timeline = ref<TaskTreeTimeline | null>(null);
|
||||
const selectedTask = ref<TaskTreeTask | null>(null);
|
||||
const detailMode = ref<"dialog" | "docked">(route.query.detail === "docked" ? "docked" : "dialog");
|
||||
const collapsedTaskIds = ref(new Set<string>());
|
||||
const timelineHeaderElement = ref<HTMLElement | null>(null);
|
||||
const taskLabelsElement = ref<HTMLElement | null>(null);
|
||||
const timelineBodyElement = ref<HTMLElement | null>(null);
|
||||
const labelChildScrollers = new Map<string, HTMLElement>();
|
||||
const timelineChildScrollers = new Map<string, HTMLElement>();
|
||||
const initializedChildScrollers = new Set<string>();
|
||||
const childScrollEdges = ref(new Map<string, { atTop: boolean; atBottom: boolean }>());
|
||||
const timelineScaleIndex = ref(1);
|
||||
const taskColumnWidth = ref(typeof window !== "undefined" ? Math.round(window.innerWidth * (window.innerWidth <= 720 ? 0.55 : 0.21)) : 400);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const groupId = computed(() => String(route.params.groupId || ""));
|
||||
const requestedTaskId = computed(() => String(route.query.task || ""));
|
||||
const requestedDetailMode = computed<"dialog" | "docked">(() => route.query.detail === "docked" ? "docked" : "dialog");
|
||||
const isGlobalView = computed(() => !groupId.value);
|
||||
const datedItems = computed(() => isGlobalView.value
|
||||
? overview.value.filter((item) => item.startAt || item.dueAt)
|
||||
: timeline.value?.tasks.filter((task) => task.startAt || task.dueAt) ?? []);
|
||||
const childrenByParent = computed(() => {
|
||||
const children = new Map<string, TaskTreeTask[]>();
|
||||
for (const task of timeline.value?.tasks ?? []) {
|
||||
if (!task.parentId) continue;
|
||||
children.set(task.parentId, [...(children.get(task.parentId) ?? []), task]);
|
||||
}
|
||||
return children;
|
||||
});
|
||||
const rootTasks = computed(() => (timeline.value?.tasks ?? []).filter((task) => !task.parentId));
|
||||
const timelineScales = [
|
||||
{ label: "周", stepMs: 2 * 86400000, tickWidth: 96, precision: "date" },
|
||||
{ label: "日", stepMs: 86400000, tickWidth: 144, precision: "date" },
|
||||
{ label: "时", stepMs: 6 * 3600000, tickWidth: 72, precision: "hour" },
|
||||
{ label: "分", stepMs: 3600000, tickWidth: 64, precision: "minute" },
|
||||
{ label: "秒", stepMs: 15 * 60000, tickWidth: 88, precision: "second" }
|
||||
] as const;
|
||||
const timelineScale = computed(() => timelineScales[timelineScaleIndex.value]);
|
||||
const range = computed(() => timelineRange(datedItems.value));
|
||||
const timelineTicks = computed(() => Array.from(
|
||||
{ length: Math.ceil(range.value.dayCount * 86400000 / timelineScale.value.stepMs) },
|
||||
(_, index) => new Date(range.value.start.getTime() + index * timelineScale.value.stepMs)
|
||||
));
|
||||
const timelineWidth = computed(() => timelineTicks.value.length * timelineScale.value.tickWidth);
|
||||
const hasActiveData = computed(() => isGlobalView.value ? overview.value.length > 0 : Boolean(timeline.value));
|
||||
const asyncState = computed(() => loading.value ? hasActiveData.value ? "refreshing" : "initial-loading" : error.value ? hasActiveData.value ? "partial" : "error" : hasActiveData.value ? "ready" : "empty");
|
||||
const overviewTotals = computed(() => overview.value.reduce((totals, item) => ({
|
||||
tasks: totals.tasks + item.taskCount,
|
||||
subtasks: totals.subtasks + item.subtaskCount,
|
||||
subsubtasks: totals.subsubtasks + item.subsubtaskCount,
|
||||
reports: totals.reports + item.reportCount
|
||||
}), { tasks: 0, subtasks: 0, subsubtasks: 0, reports: 0 }));
|
||||
|
||||
onMounted(() => void loadActiveView());
|
||||
watch(groupId, () => void loadActiveView());
|
||||
watch(requestedTaskId, syncSelectedTaskFromRoute);
|
||||
watch(requestedDetailMode, (mode) => { detailMode.value = mode; });
|
||||
|
||||
async function loadActiveView() {
|
||||
loading.value = true; error.value = null;
|
||||
const overviewResponse = await tasktreeAPI.overview();
|
||||
if (!overviewResponse.ok || overviewResponse.data?.ok === false) {
|
||||
loading.value = false;
|
||||
error.value = overviewResponse.data?.error?.message || overviewResponse.error || "TaskTree 全局视图加载失败";
|
||||
return;
|
||||
}
|
||||
overview.value = overviewResponse.data?.data?.groups ?? [];
|
||||
groups.value = overview.value.map((item) => item.group);
|
||||
if (!groupId.value) {
|
||||
timeline.value = null;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
const timelineResponse = await tasktreeAPI.timeline(groupId.value);
|
||||
loading.value = false;
|
||||
if (!timelineResponse.ok || timelineResponse.data?.ok === false || !timelineResponse.data?.data) {
|
||||
error.value = timelineResponse.data?.error?.message || timelineResponse.error || "TaskTree timeline 加载失败";
|
||||
return;
|
||||
}
|
||||
timeline.value = timelineResponse.data.data;
|
||||
initializedChildScrollers.clear();
|
||||
syncSelectedTaskFromRoute();
|
||||
}
|
||||
function selectGroup(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
void router.push(value ? `/projects/tasktree/${encodeURIComponent(value)}` : "/projects/tasktree");
|
||||
}
|
||||
function openGroup(id: string) { void router.push(`/projects/tasktree/${encodeURIComponent(id)}`); }
|
||||
function toggleTask(taskId: string) {
|
||||
const next = new Set(collapsedTaskIds.value);
|
||||
if (next.has(taskId)) next.delete(taskId);
|
||||
else next.add(taskId);
|
||||
collapsedTaskIds.value = next;
|
||||
if (!next.has(taskId)) {
|
||||
initializedChildScrollers.delete(`labels:${taskId}`);
|
||||
initializedChildScrollers.delete(`timeline:${taskId}`);
|
||||
}
|
||||
}
|
||||
function visibleDescendants(rootId: string) {
|
||||
const result: TaskTreeTask[] = [];
|
||||
const append = (parentId: string) => {
|
||||
if (collapsedTaskIds.value.has(parentId)) return;
|
||||
for (const child of childrenByParent.value.get(parentId) ?? []) {
|
||||
result.push(child);
|
||||
append(child.id);
|
||||
}
|
||||
};
|
||||
append(rootId);
|
||||
return result;
|
||||
}
|
||||
function setChildScroller(side: "labels" | "timeline", groupId: string, element: Element | null) {
|
||||
const map = side === "labels" ? labelChildScrollers : timelineChildScrollers;
|
||||
if (!element) {
|
||||
map.delete(groupId);
|
||||
const edges = new Map(childScrollEdges.value);
|
||||
edges.delete(`${side}:${groupId}`);
|
||||
childScrollEdges.value = edges;
|
||||
return;
|
||||
}
|
||||
const scroller = element as HTMLElement;
|
||||
map.set(groupId, scroller);
|
||||
const key = `${side}:${groupId}`;
|
||||
if (initializedChildScrollers.has(key)) {
|
||||
updateChildScrollEdges(side, groupId, scroller);
|
||||
return;
|
||||
}
|
||||
initializedChildScrollers.add(key);
|
||||
void nextTick(() => {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
updateChildScrollEdges(side, groupId, scroller);
|
||||
});
|
||||
}
|
||||
function syncChildScroller(groupId: string, source: "labels" | "timeline") {
|
||||
const from = (source === "labels" ? labelChildScrollers : timelineChildScrollers).get(groupId);
|
||||
const to = (source === "labels" ? timelineChildScrollers : labelChildScrollers).get(groupId);
|
||||
if (from && to && to.scrollTop !== from.scrollTop) to.scrollTop = from.scrollTop;
|
||||
if (from) updateChildScrollEdges(source, groupId, from);
|
||||
if (to) updateChildScrollEdges(source === "labels" ? "timeline" : "labels", groupId, to);
|
||||
}
|
||||
function childScrollerClass(side: "labels" | "timeline", groupId: string, count: number) {
|
||||
const edge = childScrollEdges.value.get(`${side}:${groupId}`);
|
||||
return {
|
||||
scrollable: count > 8,
|
||||
"has-hidden-above": count > 8 && edge !== undefined && !edge.atTop,
|
||||
"has-hidden-below": count > 8 && edge !== undefined && !edge.atBottom
|
||||
};
|
||||
}
|
||||
function updateChildScrollEdges(side: "labels" | "timeline", groupId: string, scroller: HTMLElement) {
|
||||
const next = {
|
||||
atTop: scroller.scrollTop <= 1,
|
||||
atBottom: scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight - 1
|
||||
};
|
||||
const key = `${side}:${groupId}`;
|
||||
const current = childScrollEdges.value.get(key);
|
||||
if (current?.atTop === next.atTop && current.atBottom === next.atBottom) return;
|
||||
const edges = new Map(childScrollEdges.value);
|
||||
edges.set(key, next);
|
||||
childScrollEdges.value = edges;
|
||||
}
|
||||
function handleChildWheel(event: WheelEvent, groupId: string, side: "labels" | "timeline") {
|
||||
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
||||
const scroller = (side === "labels" ? labelChildScrollers : timelineChildScrollers).get(groupId);
|
||||
if (!scroller) return;
|
||||
const delta = event.deltaY * (event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 40 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? scroller.clientHeight : 1);
|
||||
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
const available = Math.max(0, delta < 0 ? scroller.scrollTop : maxScrollTop - scroller.scrollTop);
|
||||
if (Math.abs(delta) <= available + 1) return;
|
||||
event.preventDefault();
|
||||
scroller.scrollTop = delta < 0 ? 0 : maxScrollTop;
|
||||
syncChildScroller(groupId, side);
|
||||
scrollOuterBy(side, delta < 0 ? delta + available : delta - available);
|
||||
}
|
||||
function scrollOuterBy(side: "labels" | "timeline", delta: number) {
|
||||
const source = side === "labels" ? taskLabelsElement.value : timelineBodyElement.value;
|
||||
const target = side === "labels" ? timelineBodyElement.value : taskLabelsElement.value;
|
||||
if (!source) return;
|
||||
const nextTop = Math.max(0, Math.min(source.scrollHeight - source.clientHeight, source.scrollTop + delta));
|
||||
source.scrollTop = nextTop;
|
||||
if (target) target.scrollTop = Math.max(0, Math.min(target.scrollHeight - target.clientHeight, nextTop));
|
||||
}
|
||||
function taskColumnLimit() { return Math.max(170, Math.min(760, window.innerWidth - 280)); }
|
||||
function resizeTaskColumn(width: number) { taskColumnWidth.value = Math.max(170, Math.min(taskColumnLimit(), Math.round(width))); }
|
||||
function startTaskColumnResize(event: PointerEvent) {
|
||||
const handle = event.currentTarget as HTMLElement;
|
||||
const startX = event.clientX;
|
||||
const startWidth = taskColumnWidth.value;
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
const move = (moveEvent: PointerEvent) => resizeTaskColumn(startWidth + moveEvent.clientX - startX);
|
||||
const stop = (stopEvent: PointerEvent) => {
|
||||
handle.releasePointerCapture(stopEvent.pointerId);
|
||||
handle.removeEventListener("pointermove", move);
|
||||
handle.removeEventListener("pointerup", stop);
|
||||
handle.removeEventListener("pointercancel", stop);
|
||||
};
|
||||
handle.addEventListener("pointermove", move);
|
||||
handle.addEventListener("pointerup", stop);
|
||||
handle.addEventListener("pointercancel", stop);
|
||||
}
|
||||
function resizeTaskColumnByKeyboard(event: KeyboardEvent) {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
resizeTaskColumn(taskColumnWidth.value + (event.key === "ArrowLeft" ? -16 : 16));
|
||||
}
|
||||
async function changeTimelineScale(direction: -1 | 1) {
|
||||
const body = timelineBodyElement.value;
|
||||
const centerRatio = body && body.scrollWidth > 0 ? (body.scrollLeft + body.clientWidth / 2) / body.scrollWidth : 0;
|
||||
timelineScaleIndex.value = Math.min(timelineScales.length - 1, Math.max(0, timelineScaleIndex.value + direction));
|
||||
await nextTick();
|
||||
if (body) {
|
||||
body.scrollLeft = Math.max(0, centerRatio * body.scrollWidth - body.clientWidth / 2);
|
||||
if (timelineHeaderElement.value) timelineHeaderElement.value.scrollLeft = body.scrollLeft;
|
||||
}
|
||||
}
|
||||
function syncTimelineBody() {
|
||||
const body = timelineBodyElement.value;
|
||||
if (!body) return;
|
||||
if (timelineHeaderElement.value && timelineHeaderElement.value.scrollLeft !== body.scrollLeft) timelineHeaderElement.value.scrollLeft = body.scrollLeft;
|
||||
if (taskLabelsElement.value && taskLabelsElement.value.scrollTop !== body.scrollTop) taskLabelsElement.value.scrollTop = body.scrollTop;
|
||||
}
|
||||
function syncTimelineHeader() {
|
||||
const header = timelineHeaderElement.value;
|
||||
const body = timelineBodyElement.value;
|
||||
if (header && body && body.scrollLeft !== header.scrollLeft) body.scrollLeft = header.scrollLeft;
|
||||
}
|
||||
function syncTaskLabels() {
|
||||
const labels = taskLabelsElement.value;
|
||||
const body = timelineBodyElement.value;
|
||||
if (labels && body && body.scrollTop !== labels.scrollTop) body.scrollTop = labels.scrollTop;
|
||||
}
|
||||
function timelineRange(items: Array<{ startAt: string | null; dueAt: string | null }>) {
|
||||
const values = items.flatMap((item) => [item.startAt, item.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(item: { startAt: string | null; dueAt: string | null }) {
|
||||
const start = new Date(item.startAt || item.dueAt || range.value.start).getTime(); const end = new Date(item.dueAt || item.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) {
|
||||
const time = new Date(occursAt).getTime();
|
||||
const left = Math.max(0, (time - range.value.start.getTime()) / 86400000) * (100 / range.value.dayCount);
|
||||
return { left: `${Math.min(100, left)}%` };
|
||||
}
|
||||
function tickLabel(date: Date) {
|
||||
const monthDay = `${date.getUTCMonth() + 1}/${date.getUTCDate()}`;
|
||||
if (timelineScale.value.precision === "date") return monthDay;
|
||||
const hour = String(date.getUTCHours()).padStart(2, "0");
|
||||
if (timelineScale.value.precision === "hour") return `${monthDay} ${hour}时`;
|
||||
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
if (timelineScale.value.precision === "minute") return `${monthDay} ${hour}:${minute}`;
|
||||
const second = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
return `${monthDay} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
function statusLabel(status: string) { return ({ pending: "待处理", in_progress: "进行中", completed: "已完成", blocked: "阻塞" } as Record<string, string>)[status] || status; }
|
||||
function taskKindLabel(kind: TaskTreeTask["kind"]) { return ({ task: "Task", subtask: "Subtask", subsubtask: "Subsubtask" })[kind]; }
|
||||
function plainTaskTitle(task: TaskTreeTask | null) {
|
||||
return String(task?.title || "任务详情").replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1").replace(/`([^`]+)`/gu, "$1").trim();
|
||||
}
|
||||
function selectTaskFromTitle(event: MouseEvent, task: TaskTreeTask) {
|
||||
if ((event.target as Element).closest("a")) return;
|
||||
selectTask(task);
|
||||
}
|
||||
function selectTask(task: TaskTreeTask | null) {
|
||||
if (!task) return;
|
||||
selectedTask.value = task;
|
||||
if (requestedTaskId.value !== task.id) void router.replace({ query: { ...route.query, task: task.id } });
|
||||
}
|
||||
function closeTask() {
|
||||
selectedTask.value = null;
|
||||
if (!requestedTaskId.value) return;
|
||||
const query = { ...route.query };
|
||||
delete query.task;
|
||||
delete query.detail;
|
||||
void router.replace({ query });
|
||||
}
|
||||
function setDetailMode(mode: "dialog" | "docked") {
|
||||
detailMode.value = mode;
|
||||
const query = { ...route.query };
|
||||
if (mode === "docked") query.detail = "docked";
|
||||
else delete query.detail;
|
||||
void router.replace({ query });
|
||||
}
|
||||
function syncSelectedTaskFromRoute() {
|
||||
if (!requestedTaskId.value) { selectedTask.value = null; return; }
|
||||
selectedTask.value = timeline.value?.tasks.find((task) => task.id === requestedTaskId.value) ?? null;
|
||||
}
|
||||
</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 value="">全部 TaskGroups</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label><button class="icon-button" type="button" title="刷新" aria-label="刷新" :disabled="loading" @click="loadActiveView"><span aria-hidden="true">↻</span></button></template>
|
||||
</PageCommandBar>
|
||||
<AsyncBoundary :state="asyncState" title="TaskTree 暂无任务" :message="error || '通过 CLI 创建 taskgroup 和任务后会显示在时间轴中。'" @retry="loadActiveView">
|
||||
<div v-if="isGlobalView || timeline" class="tasktree-view-layout" :class="{ docked: detailMode === 'docked' && selectedTask }">
|
||||
<section class="tasktree-workspace" aria-label="TaskTree 甘特图">
|
||||
<header class="tasktree-context">
|
||||
<div v-if="isGlobalView"><strong>全局 TaskGroups</strong><p>跨项目时间范围与工作量概览</p></div>
|
||||
<div v-else-if="timeline"><strong>{{ timeline.group.name }}</strong><p>{{ timeline.group.description || '未填写 taskgroup 说明' }}</p></div>
|
||||
<div class="tasktree-timeline-tools">
|
||||
<span>{{ range.dayCount }} 天</span>
|
||||
<div class="tasktree-zoom" role="group" aria-label="时间轴缩放">
|
||||
<button type="button" title="缩小时间尺度" aria-label="缩小时间尺度" :disabled="timelineScaleIndex === 0" @click="changeTimelineScale(-1)"><ZoomOut :size="15" aria-hidden="true" /></button>
|
||||
<output :title="`当前时间尺度:${timelineScale.label}`" aria-live="polite">{{ timelineScale.label }}</output>
|
||||
<button type="button" title="放大时间尺度" aria-label="放大时间尺度" :disabled="timelineScaleIndex === timelineScales.length - 1" @click="changeTimelineScale(1)"><ZoomIn :size="15" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tasktree-gantt" :class="{ 'global-view': isGlobalView }" :style="{ '--task-column-width': `${taskColumnWidth}px`, '--tick-count': timelineTicks.length, '--tick-width': `${timelineScale.tickWidth}px`, '--timeline-width': `${timelineWidth}px` }">
|
||||
<div class="tasktree-corner">{{ isGlobalView ? 'TaskGroup' : '任务' }}</div>
|
||||
<div
|
||||
class="tasktree-column-resizer"
|
||||
role="separator"
|
||||
aria-label="调整任务列表宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin="170"
|
||||
:aria-valuemax="taskColumnLimit()"
|
||||
:aria-valuenow="taskColumnWidth"
|
||||
tabindex="0"
|
||||
@pointerdown="startTaskColumnResize"
|
||||
@keydown="resizeTaskColumnByKeyboard"
|
||||
/>
|
||||
<div ref="timelineHeaderElement" class="tasktree-timeline-header" @scroll="syncTimelineHeader">
|
||||
<div class="tasktree-days">
|
||||
<span v-for="tick in timelineTicks" :key="tick.toISOString()">{{ tickLabel(tick) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="taskLabelsElement" class="tasktree-labels" @scroll="syncTaskLabels">
|
||||
<div v-if="isGlobalView" class="tasktree-labels-canvas tasktree-overview-labels">
|
||||
<div v-for="item in overview" :key="item.group.id" class="tasktree-label tasktree-group-label" role="link" tabindex="0" @click="openGroup(item.group.id)" @keydown.enter="openGroup(item.group.id)">
|
||||
<div class="tasktree-group-title">
|
||||
<MessageMarkdown class="tasktree-title-markdown" :source="item.group.name" />
|
||||
<ChevronRight :size="17" aria-hidden="true" />
|
||||
</div>
|
||||
<span>{{ item.taskCount }} tasks · {{ item.subtaskCount + item.subsubtaskCount }} children · {{ item.reportCount }} reports</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tasktree-labels-canvas tasktree-detail-labels">
|
||||
<section v-for="root in rootTasks" :key="root.id" class="tasktree-task-group">
|
||||
<div class="tasktree-label" :data-dated="Boolean(root.startAt || root.dueAt)">
|
||||
<button
|
||||
v-if="childrenByParent.has(root.id)"
|
||||
class="tasktree-disclosure"
|
||||
type="button"
|
||||
:title="collapsedTaskIds.has(root.id) ? '展开子任务' : '收起子任务'"
|
||||
:aria-label="collapsedTaskIds.has(root.id) ? `展开 ${root.title} 的子任务` : `收起 ${root.title} 的子任务`"
|
||||
:aria-expanded="!collapsedTaskIds.has(root.id)"
|
||||
@click="toggleTask(root.id)"
|
||||
>
|
||||
<ChevronRight v-if="collapsedTaskIds.has(root.id)" :size="15" aria-hidden="true" />
|
||||
<ChevronDown v-else :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
|
||||
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, root)" @keydown.enter="selectTask(root)">
|
||||
<span class="tasktree-status" :data-status="root.status" />
|
||||
<MessageMarkdown class="tasktree-title-markdown" :source="root.title" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="visibleDescendants(root.id).length"
|
||||
:ref="(element) => setChildScroller('labels', root.id, element as Element | null)"
|
||||
class="tasktree-child-viewport"
|
||||
:class="childScrollerClass('labels', root.id, visibleDescendants(root.id).length)"
|
||||
@scroll="syncChildScroller(root.id, 'labels')"
|
||||
@wheel="handleChildWheel($event, root.id, 'labels')"
|
||||
>
|
||||
<div v-for="task in visibleDescendants(root.id)" :key="task.id" class="tasktree-label" :class="task.kind" :data-dated="Boolean(task.startAt || task.dueAt)">
|
||||
<button
|
||||
v-if="childrenByParent.has(task.id)"
|
||||
class="tasktree-disclosure"
|
||||
type="button"
|
||||
:title="collapsedTaskIds.has(task.id) ? '展开下级任务' : '收起下级任务'"
|
||||
:aria-label="collapsedTaskIds.has(task.id) ? `展开 ${task.title} 的下级任务` : `收起 ${task.title} 的下级任务`"
|
||||
:aria-expanded="!collapsedTaskIds.has(task.id)"
|
||||
@click="toggleTask(task.id)"
|
||||
>
|
||||
<ChevronRight v-if="collapsedTaskIds.has(task.id)" :size="15" aria-hidden="true" />
|
||||
<ChevronDown v-else :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
<span v-else class="tasktree-disclosure-spacer" aria-hidden="true" />
|
||||
<div class="tasktree-title" role="button" tabindex="0" @click="selectTaskFromTitle($event, task)" @keydown.enter="selectTask(task)">
|
||||
<span class="tasktree-status" :data-status="task.status" />
|
||||
<MessageMarkdown class="tasktree-title-markdown" :source="task.title" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="timelineBodyElement" class="tasktree-timeline-body" @scroll="syncTimelineBody">
|
||||
<div v-if="isGlobalView" class="tasktree-timeline-canvas tasktree-overview-timeline">
|
||||
<div v-for="item in overview" :key="item.group.id" class="tasktree-track tasktree-group-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
|
||||
<button
|
||||
v-if="item.startAt || item.dueAt"
|
||||
class="tasktree-group-bar"
|
||||
:style="barStyle(item)"
|
||||
type="button"
|
||||
:aria-label="`打开 ${item.group.name}`"
|
||||
@click="openGroup(item.group.id)"
|
||||
>
|
||||
<span>{{ item.taskCount + item.subtaskCount + item.subsubtaskCount }} 项</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="timeline" class="tasktree-timeline-canvas tasktree-detail-timeline">
|
||||
<section v-for="root in rootTasks" :key="root.id" class="tasktree-task-group">
|
||||
<div class="tasktree-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
|
||||
<button v-if="root.startAt || root.dueAt" class="tasktree-bar" :class="`status-${root.status}`" :style="barStyle(root)" type="button" :aria-label="`${root.title},${statusLabel(root.status)}`" @click="selectTask(root)"><span>{{ statusLabel(root.status) }}</span></button>
|
||||
<span v-for="milestone in timeline.milestones.filter((item) => item.taskId === root.id)" :key="milestone.id" class="tasktree-milestone" role="img" :aria-label="`里程碑:${milestone.title}`" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="visibleDescendants(root.id).length"
|
||||
:ref="(element) => setChildScroller('timeline', root.id, element as Element | null)"
|
||||
class="tasktree-child-viewport"
|
||||
:class="childScrollerClass('timeline', root.id, visibleDescendants(root.id).length)"
|
||||
@scroll="syncChildScroller(root.id, 'timeline')"
|
||||
@wheel="handleChildWheel($event, root.id, 'timeline')"
|
||||
>
|
||||
<div v-for="task in visibleDescendants(root.id)" :key="task.id" class="tasktree-track" :style="{ backgroundSize: `${100 / timelineTicks.length}% 100%` }">
|
||||
<button v-if="task.startAt || task.dueAt" class="tasktree-bar" :class="[`status-${task.status}`, task.kind]" :style="barStyle(task)" type="button" :aria-label="`${task.title},${statusLabel(task.status)}`" @click="selectTask(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" role="img" :aria-label="`里程碑:${milestone.title}`" :title="milestone.title" :style="milestoneStyle(milestone.occursAt)" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="tasktree-statusbar" aria-label="TaskTree 状态">
|
||||
<span><strong>{{ overview.length }}</strong> Taskgroups</span>
|
||||
<span><strong>{{ isGlobalView ? overviewTotals.tasks : timeline?.tasks.filter((task) => task.kind === 'task').length }}</strong> Tasks</span>
|
||||
<span><strong>{{ isGlobalView ? overviewTotals.subtasks : timeline?.tasks.filter((task) => task.kind === 'subtask').length }}</strong> Subtasks</span>
|
||||
<span><strong>{{ isGlobalView ? overviewTotals.subsubtasks : timeline?.tasks.filter((task) => task.kind === 'subsubtask').length }}</strong> Subsubtasks</span>
|
||||
<span><strong>{{ isGlobalView ? overviewTotals.reports : timeline?.reports.length }}</strong> Reports</span>
|
||||
<span v-if="!isGlobalView"><strong>{{ timeline?.milestones.length }}</strong> Milestones</span>
|
||||
<span class="tasktree-statusbar-authority">PostgreSQL</span>
|
||||
</footer>
|
||||
</section>
|
||||
<aside v-if="detailMode === 'docked' && selectedTask && timeline" class="tasktree-docked-detail" aria-label="任务详情">
|
||||
<TaskTreeDetailPanel :task="selectedTask" :tasks="timeline.tasks" :reports="timeline.reports" mode="docked" @select="selectTask" @close="closeTask" @mode="setDetailMode" />
|
||||
</aside>
|
||||
</div>
|
||||
</AsyncBoundary>
|
||||
<BaseDialog :open="Boolean(selectedTask) && detailMode === 'dialog'" :title="plainTaskTitle(selectedTask)" :description="selectedTask ? `${taskKindLabel(selectedTask.kind)} · ${statusLabel(selectedTask.status)}` : ''" surface-class="tasktree-detail-dialog" wide @close="closeTask">
|
||||
<TaskTreeDetailPanel v-if="selectedTask && timeline" :task="selectedTask" :tasks="timeline.tasks" :reports="timeline.reports" mode="dialog" @select="selectTask" @close="closeTask" @mode="setDetailMode" />
|
||||
</BaseDialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasktree-page { display: grid; gap: 12px; min-width: 0; }
|
||||
.tasktree-view-layout { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; gap: 10px; }
|
||||
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr) clamp(520px, 34vw, 680px); }
|
||||
.tasktree-workspace { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; height: calc(100dvh - 190px); min-height: 360px; border: 1px solid var(--border-color); background: var(--surface-primary); min-width: 0; overflow: hidden; }
|
||||
.tasktree-docked-detail { height: calc(100dvh - 190px); min-width: 0; min-height: 360px; overflow: hidden; border: 1px solid var(--border-color); background: var(--surface-primary); }
|
||||
.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-timeline-tools { display: flex; align-items: center; gap: 10px; color: var(--text-secondary); font-size: 12px; }
|
||||
.tasktree-zoom { display: grid; grid-template-columns: 28px 42px 28px; height: 28px; align-items: stretch; border: 1px solid var(--border-color); background: var(--surface-primary); }
|
||||
.tasktree-zoom button { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||
.tasktree-zoom button:hover:not(:disabled) { background: var(--surface-secondary); color: var(--text-primary); }
|
||||
.tasktree-zoom output { display: grid; place-items: center; border-inline: 1px solid var(--border-color); color: var(--text-primary); font-family: var(--console-font-mono); font-size: 10px; }
|
||||
.tasktree-gantt { display: grid; grid-template-areas: "corner resize header" "labels resize timeline"; grid-template-columns: var(--task-column-width) 7px minmax(0, 1fr); grid-template-rows: 34px minmax(0, 1fr); min-height: 0; height: 100%; overflow: hidden; }
|
||||
.tasktree-corner { grid-area: corner; }
|
||||
.tasktree-timeline-header { grid-area: header; }
|
||||
.tasktree-labels { grid-area: labels; }
|
||||
.tasktree-timeline-body { grid-area: timeline; }
|
||||
.tasktree-column-resizer { position: relative; z-index: 3; grid-area: resize; width: 7px; min-height: 0; border: 0; border-inline: 1px solid var(--border-color); background: var(--surface-secondary); cursor: col-resize; touch-action: none; }
|
||||
.tasktree-column-resizer::after { position: absolute; inset: 0 2px; background: transparent; content: ""; transition: background-color 120ms ease; }
|
||||
.tasktree-column-resizer:hover::after,
|
||||
.tasktree-column-resizer:focus-visible::after,
|
||||
.tasktree-column-resizer:active::after { background: #2376c9; }
|
||||
.tasktree-corner { display: flex; align-items: center; padding: 0 14px; border-right: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); background: transparent; font-size: 11px; font-weight: 700; }
|
||||
.tasktree-timeline-header { min-width: 0; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid var(--border-color); background: transparent; scrollbar-width: none; overscroll-behavior-inline: contain; }
|
||||
.tasktree-timeline-header::-webkit-scrollbar { display: none; }
|
||||
.tasktree-days { width: max(100%, var(--timeline-width)); height: 100%; display: grid; grid-template-columns: repeat(var(--tick-count), minmax(var(--tick-width), 1fr)); }
|
||||
.tasktree-days span { display: flex; align-items: center; justify-content: center; min-width: 0; padding: 0 4px; overflow: hidden; border-right: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: 10px; white-space: nowrap; }
|
||||
.tasktree-labels { min-width: 0; min-height: 0; max-height: 100%; overflow-y: auto; overflow-x: hidden; border-right: 1px solid var(--border-color); scrollbar-width: thin; overscroll-behavior-block: contain; }
|
||||
.tasktree-labels-canvas { min-height: 100%; align-content: start; }
|
||||
.tasktree-overview-labels { display: grid; grid-auto-rows: 68px; }
|
||||
.tasktree-task-group { min-width: 0; }
|
||||
.tasktree-label { height: 46px; border-bottom: 1px solid var(--border-subtle); background: transparent; padding: 0 8px; display: flex; align-items: center; color: var(--text-primary); min-width: 0; }
|
||||
.tasktree-label:hover { background: rgb(255 255 255 / 32%); }
|
||||
.tasktree-label.subtask { padding-left: 30px; color: var(--text-secondary); }
|
||||
.tasktree-label.subsubtask { padding-left: 54px; color: var(--text-secondary); }
|
||||
.tasktree-group-label { height: 68px; padding: 9px 14px; display: grid; align-content: center; gap: 5px; cursor: pointer; }
|
||||
.tasktree-group-label > span { color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 10px; }
|
||||
.tasktree-group-title { display: flex; min-width: 0; align-items: center; gap: 8px; color: var(--text-primary); font-size: 15px; font-weight: 700; }
|
||||
.tasktree-group-title svg { flex: 0 0 auto; color: var(--text-secondary); }
|
||||
.tasktree-disclosure,
|
||||
.tasktree-disclosure-spacer { width: 24px; height: 28px; flex: 0 0 24px; }
|
||||
.tasktree-disclosure { display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||
.tasktree-disclosure:hover { color: var(--text-primary); background: var(--surface-secondary); }
|
||||
.tasktree-title { display: flex; flex: 1 1 auto; align-items: center; gap: 8px; min-width: 0; height: 100%; padding: 0 4px; color: inherit; text-align: left; cursor: pointer; }
|
||||
.tasktree-title:focus-visible { outline: 2px solid #2376c9; outline-offset: -2px; }
|
||||
.tasktree-title-markdown { min-width: 0; overflow: hidden; }
|
||||
.tasktree-title-markdown :deep(p) { min-width: 0; margin: 0; overflow: hidden; font: inherit; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-title-markdown :deep(a) { color: #1766ad; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.tasktree-title-markdown :deep(code) { padding: 1px 3px; border: 1px solid var(--border-subtle); background: var(--surface-secondary); color: inherit; font-size: 0.9em; }
|
||||
.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-timeline-body { min-width: 0; min-height: 0; max-height: 100%; overflow: auto; scrollbar-gutter: stable; overscroll-behavior: contain; touch-action: pan-x pan-y; }
|
||||
.tasktree-timeline-canvas { width: max(100%, var(--timeline-width)); min-height: 100%; align-content: start; }
|
||||
.tasktree-overview-timeline { display: grid; grid-auto-rows: 68px; }
|
||||
.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-group-track { height: 68px; }
|
||||
.tasktree-bar { position: absolute; top: 10px; height: 26px; min-width: 18px; padding: 0; border: 0; border-radius: 4px; background: #2376c9; color: white; overflow: visible; }
|
||||
.tasktree-bar span { position: absolute; left: 7px; top: 50%; transform: translateY(-50%); font-size: 10px; line-height: 1; white-space: nowrap; text-shadow: 0 1px 2px rgb(0 0 0 / 45%); pointer-events: none; }
|
||||
.tasktree-bar.subtask { top: 14px; height: 18px; background: #5f7d99; }
|
||||
.tasktree-bar.subsubtask { top: 16px; height: 14px; background: #8294a5; }
|
||||
.tasktree-group-bar { position: absolute; top: 16px; height: 36px; min-width: 28px; overflow: hidden; border: 1px solid #135b94; border-radius: 4px; background: #176ba8; color: white; box-shadow: inset 0 1px 0 rgb(255 255 255 / 22%), 0 2px 5px rgb(17 67 101 / 20%); cursor: pointer; }
|
||||
.tasktree-group-bar:hover { background: #125d95; }
|
||||
.tasktree-group-bar span { display: block; padding: 0 10px; overflow: hidden; font-size: 11px; font-weight: 700; line-height: 34px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tasktree-bar.status-completed { background: #23845c; }
|
||||
.tasktree-bar.status-blocked { background: #b84149; }
|
||||
.tasktree-bar.status-pending { background: #687581; }
|
||||
.tasktree-milestone { position: absolute; top: 15px; z-index: 2; width: 16px; height: 16px; transform: translateX(-50%); background: #d39114; clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); filter: drop-shadow(0 0 0.5px #664000) drop-shadow(0 1px 1px rgb(0 0 0 / 35%)); }
|
||||
.tasktree-task-group:has(> .tasktree-child-viewport.scrollable) > .tasktree-label,
|
||||
.tasktree-task-group:has(> .tasktree-child-viewport.scrollable) > .tasktree-track { position: relative; z-index: 3; border-bottom-color: #91a4b3; }
|
||||
.tasktree-task-group:has(> .tasktree-child-viewport.has-hidden-above) > .tasktree-label,
|
||||
.tasktree-task-group:has(> .tasktree-child-viewport.has-hidden-above) > .tasktree-track { box-shadow: 0 9px 15px -8px rgb(24 45 61 / 62%); }
|
||||
.tasktree-child-viewport { width: 100%; max-height: calc(46px * 8); overflow: hidden; background: color-mix(in srgb, var(--surface-secondary) 55%, var(--surface-primary)); overscroll-behavior-y: none; scrollbar-color: #7891a5 transparent; scrollbar-width: thin; }
|
||||
.tasktree-child-viewport.scrollable { overflow-y: auto; box-shadow: inset 0 1px 0 rgb(18 53 76 / 12%), inset 0 -1px 0 rgb(18 53 76 / 10%); }
|
||||
.tasktree-child-viewport.scrollable::before,
|
||||
.tasktree-child-viewport.scrollable::after { position: sticky; z-index: 5; display: block; height: 16px; margin-bottom: -16px; opacity: 0; pointer-events: none; content: ""; transition: opacity 100ms ease; }
|
||||
.tasktree-child-viewport.scrollable::before { top: 0; background: linear-gradient(to bottom, rgb(29 50 65 / 25%), rgb(29 50 65 / 8%) 45%, transparent); box-shadow: inset 0 7px 9px -8px rgb(12 31 44 / 75%); }
|
||||
.tasktree-child-viewport.scrollable::after { bottom: 0; margin-top: -16px; margin-bottom: 0; background: linear-gradient(to top, rgb(29 50 65 / 20%), transparent); box-shadow: inset 0 -7px 9px -8px rgb(12 31 44 / 65%); }
|
||||
.tasktree-child-viewport.has-hidden-above::before,
|
||||
.tasktree-child-viewport.has-hidden-below::after { opacity: 1; }
|
||||
.tasktree-child-viewport::-webkit-scrollbar { width: 9px; }
|
||||
.tasktree-child-viewport::-webkit-scrollbar-track { background: transparent; }
|
||||
.tasktree-child-viewport::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: #7891a5; background-clip: padding-box; }
|
||||
.tasktree-child-viewport::-webkit-scrollbar-thumb:hover { background: #526f86; background-clip: padding-box; }
|
||||
.tasktree-statusbar { min-width: 0; min-height: 28px; padding: 0 10px; display: flex; align-items: center; gap: 0; overflow-x: auto; border-top: 1px solid var(--border-color); background: var(--surface-secondary); color: var(--text-secondary); font-family: var(--console-font-mono); font-size: 10px; white-space: nowrap; scrollbar-width: thin; }
|
||||
.tasktree-statusbar span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.tasktree-statusbar span + span::before { margin: 0 9px; color: var(--border-color); content: "|"; }
|
||||
.tasktree-statusbar strong { color: var(--text-primary); font-weight: 700; }
|
||||
.tasktree-statusbar-authority { margin-left: auto; }
|
||||
.tasktree-statusbar-authority::after { width: 6px; height: 6px; margin-left: 6px; border-radius: 50%; background: #1f9d67; content: ""; }
|
||||
.tasktree-group-select { display: flex; align-items: center; }
|
||||
.tasktree-group-select select { min-width: 180px; padding: 7px 10px; border: 1px solid var(--border-color); background: var(--surface-primary); color: var(--text-primary); }
|
||||
:global(.console-overlay-surface.tasktree-detail-dialog) { height: 90dvh; min-height: 90dvh; max-height: 90dvh; }
|
||||
:global(.console-overlay-surface.tasktree-detail-dialog .console-overlay-body) { min-height: 0; overflow: hidden; padding: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tasktree-view-layout.docked { grid-template-columns: minmax(0, 1fr); }
|
||||
.tasktree-workspace { height: calc(100dvh - 225px); min-height: 400px; }
|
||||
.tasktree-docked-detail { height: 80dvh; min-height: 480px; }
|
||||
.tasktree-context { align-items: flex-start; }
|
||||
.tasktree-timeline-tools { flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||
.tasktree-statusbar-authority { margin-left: 0; }
|
||||
.tasktree-group-select select { min-width: 130px; max-width: 42vw; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user