From de8ebb51c35a053c1b077c65bcb7e4852e67ed97 Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 25 Jun 2026 16:34:59 +0800 Subject: [PATCH] feat: add project management microservice --- cmd/hwlab-project-management/main.ts | 53 +++ deploy/deploy.yaml | 28 ++ deploy/k8s/base/services.yaml | 25 ++ deploy/k8s/base/workloads.yaml | 60 +++ .../runtime/boot/hwlab-project-management.sh | 31 ++ internal/cloud/project-management-proxy.ts | 94 +++++ internal/cloud/server.ts | 12 + internal/project-management/mdtodo.test.ts | 17 + internal/project-management/mdtodo.ts | 167 ++++++++ internal/project-management/server.test.ts | 54 +++ internal/project-management/server.ts | 233 ++++++++++++ internal/project-management/store.ts | 358 ++++++++++++++++++ 12 files changed, 1132 insertions(+) create mode 100644 cmd/hwlab-project-management/main.ts create mode 100644 deploy/runtime/boot/hwlab-project-management.sh create mode 100644 internal/cloud/project-management-proxy.ts create mode 100644 internal/project-management/mdtodo.test.ts create mode 100644 internal/project-management/mdtodo.ts create mode 100644 internal/project-management/server.test.ts create mode 100644 internal/project-management/server.ts create mode 100644 internal/project-management/store.ts diff --git a/cmd/hwlab-project-management/main.ts b/cmd/hwlab-project-management/main.ts new file mode 100644 index 00000000..68eb5d9f --- /dev/null +++ b/cmd/hwlab-project-management/main.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env bun +import { createProjectManagementApp } from "../../internal/project-management/server.ts"; +import { createProjectManagementStore } from "../../internal/project-management/store.ts"; + +const host = process.env.HWLAB_PROJECT_MANAGEMENT_HOST || "0.0.0.0"; +const port = parsePort(process.env.HWLAB_PROJECT_MANAGEMENT_PORT, parsePort(process.env.PORT, 6672)); +const store = createProjectManagementStore({ env: process.env }); +const app = createProjectManagementApp({ env: process.env, store }); + +void app.initialize().catch((error) => { + console.warn(JSON.stringify({ + serviceId: "hwlab-project-management", + event: "project-management-startup-initialize-failed", + code: error?.code ?? "project_management_initialize_failed", + message: error?.message ?? String(error) + })); +}); + +const runtime = Bun.serve({ + hostname: host, + port, + fetch: (request) => app.fetch(request) +}); + +process.stdout.write(`${JSON.stringify({ + serviceId: "hwlab-project-management", + status: "listening", + host, + port: runtime.port, + routes: [ + "/health/live", + "/health/ready", + "/v1/project-management/navigation", + "/v1/project-management/projects", + "/v1/project-management/mdtodo/sources", + "/v1/project-management/mdtodo/files", + "/v1/project-management/mdtodo/tasks", + "/v1/project-management/workbench-links" + ] +})}\n`); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, async () => { + runtime.stop(true); + await app.close(); + process.exit(0); + }); +} + +function parsePort(value: unknown, fallback: number): number { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : fallback; +} diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 5d13dc21..1fe895c5 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -366,6 +366,7 @@ lanes: - hwlab-cloud-api - hwlab-workbench-runtime - hwlab-user-billing + - hwlab-project-management - hwlab-cloud-web - hwlab-gateway - hwlab-edge-proxy @@ -374,6 +375,7 @@ lanes: hwlab-cloud-api: deploy/runtime/boot/hwlab-cloud-api.sh 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-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 @@ -455,6 +457,20 @@ lanes: env: TZ: Asia/Shanghai observable: true + hwlab-project-management: + runtimeKind: bun-command + entrypoint: cmd/hwlab-project-management/main.ts + artifactKind: bun-command + healthPath: /health/ready + healthPort: 6672 + componentPaths: + - cmd/hwlab-project-management/ + - internal/project-management/ + - internal/db/runtime-store.ts + - deploy/runtime/boot/hwlab-project-management.sh + env: + TZ: Asia/Shanghai + observable: true hwlab-cloud-web: runtimeKind: cloud-web entrypoint: web/hwlab-cloud-web/index.html @@ -579,6 +595,7 @@ lanes: HWLAB_BOOTSTRAP_ADMIN_API_KEY: secretRef:hwlab-v03-master-server-admin-api-key/api-key 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_USER_BILLING_LOGIN_RETRY_ATTEMPTS: "2" HWLAB_USER_BILLING_LOGIN_RETRY_DELAY_MS: "250" HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1" @@ -626,6 +643,17 @@ lanes: HWLAB_USER_BILLING_REDIS_URL: redis://hwlab-user-billing-redis.hwlab-v03.svc.cluster.local:6379/0 OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces OTEL_SERVICE_NAME: hwlab-user-billing + - serviceId: hwlab-project-management + replicas: 1 + env: + HWLAB_PROJECT_MANAGEMENT_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url + HWLAB_PROJECT_MANAGEMENT_PORT: "6672" + HWLAB_PROJECT_MANAGEMENT_SOURCE_ROOT: /workspace/hwlab-boot/repo + HWLAB_PROJECT_MANAGEMENT_SOURCE_ID: hwlab-v03-mdtodo + HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME: HWLAB v0.3 MDTODO + HWLAB_PROJECT_MANAGEMENT_PROJECT_ID: project_hwlab_v03 + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces + OTEL_SERVICE_NAME: hwlab-project-management services: - serviceId: hwlab-cloud-api namespace: hwlab-dev diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 19d55301..dac826e9 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -27,6 +27,31 @@ ] } }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "hwlab-project-management", + "namespace": "hwlab-dev", + "labels": { + "app.kubernetes.io/name": "hwlab-project-management", + "hwlab.pikastech.local/service-id": "hwlab-project-management" + } + }, + "spec": { + "type": "ClusterIP", + "selector": { + "app.kubernetes.io/name": "hwlab-project-management" + }, + "ports": [ + { + "name": "http", + "port": 6672, + "targetPort": "http" + } + ] + } + }, { "apiVersion": "v1", "kind": "Service", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 8b71960e..ec474793 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -46,6 +46,66 @@ } } }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "hwlab-project-management", + "namespace": "hwlab-dev", + "labels": { + "app.kubernetes.io/name": "hwlab-project-management", + "hwlab.pikastech.local/service-id": "hwlab-project-management" + } + }, + "spec": { + "replicas": 0, + "selector": { + "matchLabels": { + "app.kubernetes.io/name": "hwlab-project-management" + } + }, + "template": { + "metadata": { + "labels": { + "app.kubernetes.io/name": "hwlab-project-management", + "hwlab.pikastech.local/service-id": "hwlab-project-management" + } + }, + "spec": { + "containers": [ + { + "name": "hwlab-project-management", + "image": "127.0.0.1:5000/hwlab/hwlab-project-management:af46386", + "ports": [ + { + "name": "http", + "containerPort": 6672 + } + ], + "env": [ + { + "name": "HWLAB_PROJECT_MANAGEMENT_PORT", + "value": "6672" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health/ready", + "port": "http" + } + }, + "livenessProbe": { + "httpGet": { + "path": "/health/live", + "port": "http" + } + } + } + ] + } + } + } + }, { "apiVersion": "apps/v1", "kind": "Deployment", diff --git a/deploy/runtime/boot/hwlab-project-management.sh b/deploy/runtime/boot/hwlab-project-management.sh new file mode 100644 index 00000000..6a0ddd25 --- /dev/null +++ b/deploy/runtime/boot/hwlab-project-management.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID="hwlab-project-management" +export HWLAB_ARTIFACT_KIND="bun-command" +export HWLAB_SERVICE_ENTRYPOINT="cmd/hwlab-project-management/main.ts" +export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" +export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" +export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" +export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}" +export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}" +export PORT="${PORT:-${HWLAB_PROJECT_MANAGEMENT_PORT:-${HWLAB_PORT:-6672}}}" +export HWLAB_PORT="$PORT" +export HWLAB_PROJECT_MANAGEMENT_PORT="$PORT" +export HWLAB_PROJECT_MANAGEMENT_SOURCE_ROOT="${HWLAB_PROJECT_MANAGEMENT_SOURCE_ROOT:-$PWD}" + +bun_bin="${HWLAB_BUN_COMMAND:-}" +if [ -z "$bun_bin" ]; then + if command -v bun >/dev/null 2>&1; then + bun_bin="$(command -v bun)" + elif [ -x /usr/local/bin/bun ]; then + bun_bin="/usr/local/bin/bun" + elif [ -x ./node_modules/.bin/bun ]; then + bun_bin="./node_modules/.bin/bun" + else + echo "hwlab-project-management boot failed: bun not found" >&2 + exit 127 + fi +fi + +exec "$bun_bin" run cmd/hwlab-project-management/main.ts diff --git a/internal/cloud/project-management-proxy.ts b/internal/cloud/project-management-proxy.ts new file mode 100644 index 00000000..f2ed717d --- /dev/null +++ b/internal/cloud/project-management-proxy.ts @@ -0,0 +1,94 @@ +import { readBody, sendJson } from "./server-http-utils.ts"; + +const serviceId = "hwlab-project-management"; + +export async function handleProjectManagementProxyHttp(request, response, url, options = {}) { + const env = options.env ?? process.env; + const baseUrl = String(env.HWLAB_PROJECT_MANAGEMENT_URL ?? "").trim(); + if (!baseUrl) { + sendJson(response, 503, { + ok: false, + error: { + code: "project_management_service_unconfigured", + message: "HWLAB_PROJECT_MANAGEMENT_URL is required to route project management API requests" + }, + valuesRedacted: true + }); + return; + } + + if (request.method !== "GET" && request.method !== "HEAD") { + sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "Project management P1 API is read-only" } }); + return; + } + + const auth = await options.accessController.authenticate(request, { required: true }); + if (!auth.ok) { + sendJson(response, auth.status, auth); + return; + } + + let upstreamUrl; + try { + upstreamUrl = new URL(`${url.pathname}${url.search}`, normalizedBaseUrl(baseUrl)); + } catch { + sendJson(response, 503, { + ok: false, + error: { code: "project_management_service_url_invalid", message: "HWLAB_PROJECT_MANAGEMENT_URL is invalid" }, + valuesRedacted: true + }); + return; + } + + const headers = bridgeHeaders(request, auth); + const timeoutMs = positiveInteger(env.HWLAB_PROJECT_MANAGEMENT_PROXY_TIMEOUT_MS, 30000); + try { + 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(timeoutMs) + }); + const text = await upstream.text(); + response.writeHead(upstream.status, { + "content-type": upstream.headers.get("content-type") || "application/json; charset=utf-8", + "cache-control": upstream.headers.get("cache-control") || "no-store", + "x-hwlab-proxy-service": serviceId + }); + response.end(text || "\n"); + } catch (error) { + sendJson(response, 502, { + ok: false, + error: { + code: "project_management_proxy_failed", + message: error?.message ?? "Project management proxy request failed" + }, + valuesRedacted: true + }); + } +} + +function bridgeHeaders(request, auth) { + const headers = new Headers(); + for (const name of ["accept", "x-request-id", "traceparent", "tracestate"]) { + const value = request.headers[name]; + if (Array.isArray(value)) headers.set(name, value[0]); + else if (value) headers.set(name, value); + } + headers.set("x-hwlab-bridge-service", "hwlab-cloud-api"); + if (auth.actor?.id) headers.set("x-hwlab-actor-id", auth.actor.id); + if (auth.actor?.role) headers.set("x-hwlab-actor-role", auth.actor.role); + const authMethod = auth.authMethod ?? (auth.apiKey ? "api-key" : auth.session ? "web-session" : "unknown"); + headers.set("x-hwlab-auth-method", authMethod); + return headers; +} + +function normalizedBaseUrl(value) { + return value.endsWith("/") ? value : `${value}/`; +} + +function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : fallback; +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index fc500aa7..7850d7b4 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -87,6 +87,7 @@ import { hwpodSpecWorkspaceProbePlan } from "./hwpod-spec-discovery.ts"; import { handleCaseRunHttp } from "./server-caserun-http.ts"; +import { handleProjectManagementProxyHttp } from "./project-management-proxy.ts"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; @@ -632,6 +633,12 @@ async function handleRestAdapter(request, response, url, options) { nodeRole: "thin-hwpod-node-executor", supportedOps: Array.from(HWPOD_NODE_OPS) }, + projectManagement: { + route: "/v1/project-management", + serviceId: "hwlab-project-management", + contractVersion: "project-management-v1", + sourceOfTruth: "mdtodo-markdown-files" + }, hwlabNode: { updateRoute: "/v1/hwlab-node/update", downloadRoute: "/v1/hwlab-node/download/hwlab-node.py", @@ -671,6 +678,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/project-management" || url.pathname.startsWith("/v1/project-management/")) { + await handleProjectManagementProxyHttp(request, response, url, options); + return; + } + if (url.pathname === "/v1/users/me/profile" && request.method === "PATCH") { await handleUserBillingProfileHttp(request, response, options); return; diff --git a/internal/project-management/mdtodo.test.ts b/internal/project-management/mdtodo.test.ts new file mode 100644 index 00000000..e11d9db8 --- /dev/null +++ b/internal/project-management/mdtodo.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; + +import { parseMdtodoDocument } from "./mdtodo.ts"; + +test("parseMdtodoDocument projects markdown tasks without raw body leakage", () => { + const document = parseMdtodoDocument(`# Demo\n\n- [ ] Root task\n - [x] Child done\n- [-] Blocked item\n`, { + sourceId: "demo", + relativePath: "project-management/MDTODO.md" + }); + + expect(document.taskCount).toBe(3); + expect(document.tasks[0].status).toBe("open"); + expect(document.tasks[1].status).toBe("done"); + expect(document.tasks[1].parentTaskRef).toBe(document.tasks[0].taskRef); + expect(document.tasks[2].status).toBe("blocked"); + expect(document.document.valuesRedacted).toBe(true); +}); diff --git a/internal/project-management/mdtodo.ts b/internal/project-management/mdtodo.ts new file mode 100644 index 00000000..3a595ebc --- /dev/null +++ b/internal/project-management/mdtodo.ts @@ -0,0 +1,167 @@ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const excludedDirectories = new Set([ + ".git", + ".worktree", + ".state", + "node_modules", + "dist", + "build", + "coverage", + "tmp", + "vendor" +]); + +const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u; + +export async function discoverMdtodoDocuments(options = {}) { + const root = path.resolve(String(options.root || process.cwd())); + const sourceId = safeToken(options.sourceId, "default-mdtodo"); + const projectId = safeToken(options.projectId, null); + const maxFiles = positiveInteger(options.maxFiles, 300); + const files = []; + await walkMarkdown(root, root, files, maxFiles); + const documents = []; + + for (const filePath of files) { + const markdown = await fs.readFile(filePath, "utf8"); + if (!isMdtodoCandidate(filePath, markdown)) continue; + const relativePath = normalizeRelativePath(path.relative(root, filePath)); + const parsed = parseMdtodoDocument(markdown, { sourceId, relativePath, projectId }); + if (parsed.tasks.length === 0 && !/mdtodo|todo/i.test(path.basename(filePath))) continue; + documents.push(parsed); + } + + documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return documents; +} + +export function parseMdtodoDocument(markdown, options = {}) { + const sourceId = safeToken(options.sourceId, "default-mdtodo"); + const relativePath = normalizeRelativePath(options.relativePath || "MDTODO.md"); + const fileRef = documentFileRef(relativePath); + const fingerprint = sha256(markdown); + const projectId = safeToken(options.projectId, projectIdForRelativePath(relativePath)); + const lines = String(markdown ?? "").split(/\r?\n/u); + const tasks = []; + const parentStack = []; + let ordinal = 0; + + for (let index = 0; index < lines.length; index += 1) { + const match = taskLinePattern.exec(lines[index]); + if (!match) continue; + ordinal += 1; + const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2)); + const checkbox = match[2]; + const title = cleanTaskTitle(match[3]); + const taskId = `t${index + 1}_${shortHash(`${relativePath}:${index + 1}:${title}`)}`; + const parentTaskRef = parentStack[depth - 1] ?? null; + const taskRef = `mdtodo:${sourceId}:${fileRef}:${taskId}`; + const task = { + taskRef, + taskId, + projectId, + sourceId, + fileRef, + relativePath, + title, + status: checkbox.toLowerCase() === "x" ? "done" : checkbox === "-" ? "blocked" : "open", + parentTaskRef, + depth, + lineNumber: index + 1, + ordinal, + linkCount: 0, + sourceFingerprint: fingerprint + }; + tasks.push(task); + parentStack[depth] = taskRef; + parentStack.length = depth + 1; + } + + return { + sourceId, + fileRef, + relativePath, + fingerprint, + projectId, + title: documentTitle(markdown, relativePath), + taskCount: tasks.length, + tasks, + document: { + source: "markdown-file", + relativePath, + firstHeading: documentTitle(markdown, relativePath), + valuesRedacted: true + } + }; +} + +export function documentFileRef(relativePath) { + return `file_${shortHash(normalizeRelativePath(relativePath), 16)}`; +} + +function isMdtodoCandidate(filePath, markdown) { + const basename = path.basename(filePath).toLowerCase(); + return basename.includes("todo") || basename.includes("mdtodo") || String(markdown ?? "").split(/\r?\n/u).some((line) => taskLinePattern.test(line)); +} + +async function walkMarkdown(root, current, files, maxFiles) { + if (files.length >= maxFiles) return; + let entries; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (files.length >= maxFiles) return; + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + if (excludedDirectories.has(entry.name)) continue; + await walkMarkdown(root, absolute, files, maxFiles); + continue; + } + if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) files.push(absolute); + } +} + +function documentTitle(markdown, relativePath) { + const heading = String(markdown ?? "").split(/\r?\n/u).find((line) => /^#{1,6}\s+\S/u.test(line)); + if (heading) return heading.replace(/^#{1,6}\s+/u, "").trim().slice(0, 180); + return path.basename(relativePath, path.extname(relativePath)) || "MDTODO"; +} + +function projectIdForRelativePath(relativePath) { + const segments = normalizeRelativePath(relativePath).split("/").filter(Boolean); + const candidate = segments.find((segment) => /^PJ\d{4}-\d+/iu.test(segment)) ?? segments[0] ?? "default"; + return `project_${safeToken(candidate.toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "_"), "default")}`; +} + +function cleanTaskTitle(value) { + return String(value ?? "").replace(/\s+\s*$/u, "").trim().slice(0, 500) || "Untitled task"; +} + +function normalizeRelativePath(value) { + return String(value ?? "").replace(/\\/gu, "/").replace(/^\/+|\/+$/gu, "") || "MDTODO.md"; +} + +function shortHash(value, length = 10) { + return sha256(value).slice(0, length); +} + +function sha256(value) { + return createHash("sha256").update(String(value ?? "")).digest("hex"); +} + +function safeToken(value, fallback) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback; +} + +function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : fallback; +} diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts new file mode 100644 index 00000000..abe88496 --- /dev/null +++ b/internal/project-management/server.test.ts @@ -0,0 +1,54 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { expect, test } from "bun:test"; + +import { createProjectManagementApp } from "./server.ts"; +import { createProjectManagementStore } from "./store.ts"; + +test("project management app exposes read-only MDTODO API matrix", async () => { + const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-")); + try { + await writeFile(join(root, "MDTODO.md"), "# Demo\n\n- [ ] Launch project nav\n- [x] Wire read model\n", "utf8"); + const app = createProjectManagementApp({ + env: {}, + store: createProjectManagementStore({ kind: "memory" }), + sourceRoot: root, + sourceId: "test-mdtodo", + projectId: "project_test" + }); + + const ready = await app.fetch(new Request("http://service/health/ready")); + expect(ready.status).toBe(200); + + const navigation = await json(app, "/v1/project-management/navigation"); + expect(navigation.navigation.id).toBe("project-management"); + + const sources = await json(app, "/v1/project-management/mdtodo/sources"); + expect(sources.sources[0].sourceId).toBe("test-mdtodo"); + + const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=test-mdtodo"); + expect(files.files[0].taskCount).toBe(2); + + const tasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo"); + expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "done"]); + expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true); + + const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test"); + expect(links.links).toEqual([]); + + const rejected = await app.fetch(new Request("http://service/v1/project-management/mdtodo/tasks", { method: "POST" })); + expect(rejected.status).toBe(405); + + await app.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function json(app, path) { + const response = await app.fetch(new Request(`http://service${path}`, { headers: { "x-hwlab-actor-id": "usr_test" } })); + expect(response.status).toBe(200); + return response.json(); +} diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts new file mode 100644 index 00000000..9695cb05 --- /dev/null +++ b/internal/project-management/server.ts @@ -0,0 +1,233 @@ +import path from "node:path"; + +import { discoverMdtodoDocuments } from "./mdtodo.ts"; + +const contractVersion = "project-management-v1"; +const serviceId = "hwlab-project-management"; + +export function createProjectManagementApp(options = {}) { + const env = options.env ?? process.env; + const store = options.store; + const logger = options.logger ?? console; + const sourceRoot = path.resolve(String(options.sourceRoot ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_ROOT ?? process.cwd())); + const sourceId = safeToken(options.sourceId ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_ID, "default-mdtodo"); + const projectId = safeToken(options.projectId ?? env.HWLAB_PROJECT_MANAGEMENT_PROJECT_ID, "project_hwlab_v03"); + const displayName = String(options.displayName ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME ?? "HWLAB MDTODO").trim() || "HWLAB MDTODO"; + const maxFiles = positiveInteger(options.maxFiles ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_MAX_FILES, 300); + let initialized = false; + let lastProjection = null; + let lastError = null; + let initializing = null; + + async function initialize() { + if (initialized) return lastProjection; + if (initializing) return initializing; + initializing = (async () => { + const startedAt = new Date().toISOString(); + const source = { + sourceId, + kind: "mdtodo-file-tree", + displayName, + projectId, + rootRef: sourceRoot, + status: "active", + contractVersion, + valuesRedacted: true + }; + await store.ensureSchema(); + await store.upsertSource(source); + const documents = await discoverMdtodoDocuments({ root: sourceRoot, sourceId, projectId, maxFiles }); + await store.replaceProjection(source, documents); + lastProjection = { + sourceId, + rootRef: sourceRoot, + documentCount: documents.length, + taskCount: documents.reduce((sum, document) => sum + document.taskCount, 0), + projectedAt: new Date().toISOString(), + startedAt + }; + initialized = true; + lastError = null; + logger.info?.(JSON.stringify({ event: "project-management-projection-ready", serviceId, ...lastProjection })); + return lastProjection; + })(); + try { + return await initializing; + } catch (error) { + lastError = error; + initialized = false; + throw error; + } finally { + initializing = null; + } + } + + async function fetchHandler(request) { + const url = new URL(request.url); + try { + if (url.pathname === "/health/live" || url.pathname === "/live") { + return json(200, healthPayload("live", { initialized, lastProjection, lastError })); + } + if (url.pathname === "/health/ready" || url.pathname === "/health") { + try { + const projection = await initialize(); + return json(200, healthPayload("ready", { initialized: true, lastProjection: projection, lastError: null })); + } catch (error) { + return json(503, healthPayload("ready", { initialized: false, lastProjection, lastError: error })); + } + } + + if (!url.pathname.startsWith("/v1/project-management")) return jsonError(404, "not_found", "Project management route is not implemented"); + if (request.method !== "GET" && request.method !== "HEAD") return jsonError(405, "method_not_allowed", "Project management P1 API is read-only"); + + await initialize(); + if (url.pathname === "/v1/project-management" || url.pathname === "/v1/project-management/navigation") { + return json(200, envelope({ navigation: navigationPayload(actorFromRequest(request)), projection: lastProjection })); + } + if (url.pathname === "/v1/project-management/projects") { + const sources = await store.listSources(); + return json(200, envelope({ projects: projectsFromSources(sources) })); + } + if (url.pathname === "/v1/project-management/mdtodo/sources") { + return json(200, envelope({ sources: await store.listSources() })); + } + if (url.pathname === "/v1/project-management/mdtodo/files") { + return json(200, envelope({ files: await store.listDocuments({ sourceId: queryToken(url, "sourceId") }) })); + } + if (url.pathname === "/v1/project-management/mdtodo/tasks") { + return json(200, envelope({ + tasks: await store.listTasks({ + sourceId: queryToken(url, "sourceId"), + fileRef: queryToken(url, "fileRef"), + projectId: queryToken(url, "projectId") + }) + })); + } + if (url.pathname === "/v1/project-management/workbench-links") { + return json(200, envelope({ + links: await store.listWorkbenchLinks({ + taskRef: queryOpaque(url, "taskRef"), + projectId: queryToken(url, "projectId"), + sessionId: queryOpaque(url, "sessionId") + }) + })); + } + return jsonError(404, "not_found", "Project management route is not implemented"); + } catch (error) { + logger.error?.(JSON.stringify({ event: "project-management-request-failed", serviceId, path: url.pathname, code: error?.code ?? "project_management_error", message: error?.message ?? String(error) })); + return jsonError(error?.statusCode ?? 500, error?.code ?? "project_management_error", error?.message ?? "Project management request failed"); + } + } + + return { + initialize, + fetch: fetchHandler, + async close() { + if (typeof store?.close === "function") await store.close(); + } + }; +} + +function healthPayload(mode, state) { + const blockers = state.lastError ? [{ code: state.lastError.code ?? "project_management_not_ready", message: state.lastError.message ?? String(state.lastError) }] : []; + return { + contractVersion, + serviceId, + status: blockers.length ? "blocked" : "ok", + ready: mode === "live" ? true : blockers.length === 0 && state.initialized === true, + mode, + projection: state.lastProjection, + blockers, + valuesRedacted: true + }; +} + +function envelope(payload) { + return { + contractVersion, + serviceId, + ok: true, + ...payload, + valuesRedacted: true + }; +} + +function navigationPayload(actor) { + return { + id: "project-management", + label: "Project Management", + actor, + items: [ + { id: "projects", label: "Projects", apiRoute: "/v1/project-management/projects" }, + { id: "mdtodo", label: "MDTODO", apiRoute: "/v1/project-management/mdtodo/tasks" } + ], + capabilities: { + mdtodoProjection: true, + workbenchLaunch: false, + workbenchLinks: true, + sourceOfTruth: "markdown-files" + } + }; +} + +function projectsFromSources(sources) { + const projects = new Map(); + for (const source of sources) { + const projectId = source.projectId || "project_hwlab_v03"; + if (!projects.has(projectId)) { + projects.set(projectId, { + projectId, + name: source.displayName || projectId, + sourceIds: [], + sourceOfTruth: "markdown-files", + status: source.status || "active" + }); + } + projects.get(projectId).sourceIds.push(source.sourceId); + } + return [...projects.values()]; +} + +function actorFromRequest(request) { + const actorId = request.headers.get("x-hwlab-actor-id") || null; + if (!actorId) return null; + return { + id: actorId, + role: request.headers.get("x-hwlab-actor-role") || null, + authMethod: request.headers.get("x-hwlab-auth-method") || null + }; +} + +function json(status, payload) { + return new Response(`${JSON.stringify(payload)}\n`, { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store" + } + }); +} + +function jsonError(status, code, message) { + return json(status, { ok: false, contractVersion, serviceId, error: { code, message, status }, valuesRedacted: true }); +} + +function queryToken(url, name) { + const value = url.searchParams.get(name); + return /^[A-Za-z0-9_.:-]{1,160}$/u.test(String(value ?? "")) ? value : null; +} + +function queryOpaque(url, name) { + const value = url.searchParams.get(name); + return /^[A-Za-z0-9_.:/#-]{1,320}$/u.test(String(value ?? "")) ? value : null; +} + +function safeToken(value, fallback) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback; +} + +function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : fallback; +} diff --git a/internal/project-management/store.ts b/internal/project-management/store.ts new file mode 100644 index 00000000..b7356940 --- /dev/null +++ b/internal/project-management/store.ts @@ -0,0 +1,358 @@ +import pg from "pg"; + +import { buildPostgresPoolConfig } from "../db/runtime-store.ts"; + +const { Pool } = pg; + +const schemaStatements = Object.freeze([ + `CREATE TABLE IF NOT EXISTS project_management_schema_migrations ( + migration_id TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE TABLE IF NOT EXISTS project_sources ( + source_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + display_name TEXT NOT NULL, + project_id TEXT NOT NULL, + root_ref TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + source_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE TABLE IF NOT EXISTS mdtodo_documents ( + source_id TEXT NOT NULL REFERENCES project_sources(source_id) ON DELETE CASCADE, + file_ref TEXT NOT NULL, + project_id TEXT NOT NULL, + relative_path TEXT NOT NULL, + title TEXT NOT NULL, + fingerprint TEXT NOT NULL, + task_count INTEGER NOT NULL DEFAULT 0, + parse_error TEXT, + revision INTEGER NOT NULL DEFAULT 1, + document_json JSONB NOT NULL DEFAULT '{}'::jsonb, + last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (source_id, file_ref) + )`, + `CREATE TABLE IF NOT EXISTS mdtodo_task_projection ( + task_ref TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + source_id TEXT NOT NULL, + file_ref TEXT NOT NULL, + task_id TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + parent_task_ref TEXT, + depth INTEGER NOT NULL DEFAULT 0, + line_number INTEGER NOT NULL DEFAULT 0, + ordinal INTEGER NOT NULL DEFAULT 0, + link_count INTEGER NOT NULL DEFAULT 0, + source_fingerprint TEXT NOT NULL, + task_json JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE TABLE IF NOT EXISTS project_workbench_links ( + link_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + task_ref TEXT, + session_id TEXT, + trace_id TEXT, + created_by TEXT, + role TEXT NOT NULL DEFAULT 'reference', + link_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE TABLE IF NOT EXISTS project_management_audit_events ( + event_id TEXT PRIMARY KEY, + actor_id TEXT, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + outcome TEXT NOT NULL, + event_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE TABLE IF NOT EXISTS project_management_outbox ( + event_id TEXT PRIMARY KEY, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + event_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + event_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + published_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_mdtodo_documents_project ON mdtodo_documents(project_id, relative_path)`, + `CREATE INDEX IF NOT EXISTS idx_mdtodo_tasks_project_file ON mdtodo_task_projection(project_id, source_id, file_ref, ordinal)`, + `CREATE INDEX IF NOT EXISTS idx_mdtodo_tasks_parent ON mdtodo_task_projection(parent_task_ref)`, + `CREATE INDEX IF NOT EXISTS idx_project_workbench_links_task ON project_workbench_links(task_ref, updated_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_project_outbox_status ON project_management_outbox(status, created_at)` +]); + +export function createProjectManagementStore(options = {}) { + const env = options.env ?? process.env; + const kind = String(options.kind ?? env.HWLAB_PROJECT_MANAGEMENT_STORE ?? "postgres").trim().toLowerCase(); + if (kind === "memory") return new MemoryProjectManagementStore(options); + + const dbUrl = options.dbUrl ?? env.HWLAB_PROJECT_MANAGEMENT_DB_URL; + if (!dbUrl) return new UnconfiguredProjectManagementStore("HWLAB_PROJECT_MANAGEMENT_DB_URL"); + return new PostgresProjectManagementStore({ + ...options, + dbUrl, + sslMode: options.sslMode ?? env.HWLAB_PROJECT_MANAGEMENT_DB_SSL_MODE, + timeoutMs: options.timeoutMs ?? env.HWLAB_PROJECT_MANAGEMENT_DB_CONNECT_TIMEOUT_MS, + poolMax: options.poolMax ?? env.HWLAB_PROJECT_MANAGEMENT_DB_POOL_MAX, + queryTimeoutMs: options.queryTimeoutMs ?? env.HWLAB_PROJECT_MANAGEMENT_DB_QUERY_TIMEOUT_MS + }); +} + +export class PostgresProjectManagementStore { + constructor(options = {}) { + this.pool = options.pool ?? new Pool(buildPostgresPoolConfig({ + dbUrl: options.dbUrl, + sslMode: options.sslMode, + timeoutMs: options.timeoutMs, + poolMax: options.poolMax, + queryTimeoutMs: options.queryTimeoutMs + })); + this.schemaReady = false; + } + + async ensureSchema() { + if (this.schemaReady) return; + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + for (const statement of schemaStatements) await client.query(statement); + await client.query( + "INSERT INTO project_management_schema_migrations (migration_id) VALUES ($1) ON CONFLICT (migration_id) DO NOTHING", + ["project-management-20260625-p1"] + ); + await client.query("COMMIT"); + this.schemaReady = true; + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } + } + + async upsertSource(source) { + await this.ensureSchema(); + await this.pool.query( + `INSERT INTO project_sources (source_id, kind, display_name, project_id, root_ref, status, source_json, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, now()) + ON CONFLICT (source_id) DO UPDATE SET + kind = EXCLUDED.kind, + display_name = EXCLUDED.display_name, + project_id = EXCLUDED.project_id, + root_ref = EXCLUDED.root_ref, + status = EXCLUDED.status, + source_json = EXCLUDED.source_json, + updated_at = now()`, + [source.sourceId, source.kind, source.displayName, source.projectId, source.rootRef, source.status, JSON.stringify(source)] + ); + } + + async replaceProjection(source, documents) { + await this.ensureSchema(); + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query("DELETE FROM mdtodo_task_projection WHERE source_id = $1", [source.sourceId]); + await client.query("DELETE FROM mdtodo_documents WHERE source_id = $1", [source.sourceId]); + for (const document of documents) { + await client.query( + `INSERT INTO mdtodo_documents (source_id, file_ref, project_id, relative_path, title, fingerprint, task_count, document_json, last_indexed_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())`, + [source.sourceId, document.fileRef, document.projectId, document.relativePath, document.title, document.fingerprint, document.taskCount, JSON.stringify(document.document)] + ); + for (const task of document.tasks) { + await client.query( + `INSERT INTO mdtodo_task_projection + (task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now())`, + [task.taskRef, task.projectId, task.sourceId, task.fileRef, task.taskId, task.title, task.status, task.parentTaskRef, task.depth, task.lineNumber, task.ordinal, task.linkCount, task.sourceFingerprint, JSON.stringify(task)] + ); + } + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } + } + + async listSources() { + await this.ensureSchema(); + const result = await this.pool.query( + `SELECT source_id, kind, display_name, project_id, root_ref, status, source_json, updated_at + FROM project_sources ORDER BY source_id` + ); + return result.rows.map((row) => ({ + sourceId: row.source_id, + kind: row.kind, + displayName: row.display_name, + projectId: row.project_id, + rootRef: row.root_ref, + status: row.status, + updatedAt: row.updated_at, + ...(row.source_json ?? {}) + })); + } + + async listDocuments(filters = {}) { + await this.ensureSchema(); + const params = []; + const clauses = []; + if (filters.sourceId) { + params.push(filters.sourceId); + clauses.push(`source_id = $${params.length}`); + } + const result = await this.pool.query( + `SELECT source_id, file_ref, project_id, relative_path, title, fingerprint, task_count, parse_error, revision, document_json, last_indexed_at, updated_at + FROM mdtodo_documents ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY project_id, relative_path`, + params + ); + return result.rows.map((row) => ({ + sourceId: row.source_id, + fileRef: row.file_ref, + projectId: row.project_id, + relativePath: row.relative_path, + title: row.title, + fingerprint: row.fingerprint, + taskCount: row.task_count, + parseError: row.parse_error, + revision: row.revision, + lastIndexedAt: row.last_indexed_at, + updatedAt: row.updated_at, + document: row.document_json ?? {} + })); + } + + async listTasks(filters = {}) { + await this.ensureSchema(); + const params = []; + const clauses = []; + for (const [column, value] of [["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId]]) { + if (!value) continue; + params.push(value); + clauses.push(`${column} = $${params.length}`); + } + const result = await this.pool.query( + `SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at + FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY project_id, source_id, file_ref, ordinal`, + params + ); + return result.rows.map((row) => ({ + taskRef: row.task_ref, + projectId: row.project_id, + sourceId: row.source_id, + fileRef: row.file_ref, + taskId: row.task_id, + title: row.title, + status: row.status, + parentTaskRef: row.parent_task_ref, + depth: row.depth, + lineNumber: row.line_number, + ordinal: row.ordinal, + linkCount: row.link_count, + sourceFingerprint: row.source_fingerprint, + updatedAt: row.updated_at, + task: row.task_json ?? {} + })); + } + + async listWorkbenchLinks(filters = {}) { + await this.ensureSchema(); + const params = []; + const clauses = []; + for (const [column, value] of [["task_ref", filters.taskRef], ["project_id", filters.projectId], ["session_id", filters.sessionId]]) { + if (!value) continue; + params.push(value); + clauses.push(`${column} = $${params.length}`); + } + const result = await this.pool.query( + `SELECT link_id, project_id, task_ref, session_id, trace_id, created_by, role, link_json, created_at, updated_at + FROM project_workbench_links ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} + ORDER BY updated_at DESC, link_id LIMIT 200`, + params + ); + return result.rows.map((row) => ({ + linkId: row.link_id, + projectId: row.project_id, + taskRef: row.task_ref, + sessionId: row.session_id, + traceId: row.trace_id, + createdBy: row.created_by, + role: row.role, + createdAt: row.created_at, + updatedAt: row.updated_at, + link: row.link_json ?? {} + })); + } + + async close() { + await this.pool.end(); + } +} + +class MemoryProjectManagementStore { + constructor() { + this.sources = new Map(); + this.documents = []; + this.tasks = []; + this.links = []; + } + + async ensureSchema() {} + async close() {} + + async upsertSource(source) { + this.sources.set(source.sourceId, source); + } + + async replaceProjection(source, documents) { + this.documents = this.documents.filter((document) => document.sourceId !== source.sourceId); + this.tasks = this.tasks.filter((task) => task.sourceId !== source.sourceId); + this.documents.push(...documents.map((document) => ({ ...document, lastIndexedAt: new Date().toISOString(), updatedAt: new Date().toISOString() }))); + this.tasks.push(...documents.flatMap((document) => document.tasks.map((task) => ({ ...task, updatedAt: new Date().toISOString(), task })))); + } + + async listSources() { return [...this.sources.values()]; } + async listDocuments(filters = {}) { return this.documents.filter((item) => !filters.sourceId || item.sourceId === filters.sourceId); } + async listTasks(filters = {}) { + return this.tasks.filter((item) => (!filters.sourceId || item.sourceId === filters.sourceId) && (!filters.fileRef || item.fileRef === filters.fileRef) && (!filters.projectId || item.projectId === filters.projectId)); + } + async listWorkbenchLinks(filters = {}) { + return this.links.filter((item) => (!filters.taskRef || item.taskRef === filters.taskRef) && (!filters.projectId || item.projectId === filters.projectId) && (!filters.sessionId || item.sessionId === filters.sessionId)); + } +} + +class UnconfiguredProjectManagementStore { + constructor(envName) { + this.envName = envName; + } + + async ensureSchema() { + const error = new Error(`${this.envName} is required for hwlab-project-management`); + error.code = "project_management_db_unconfigured"; + error.statusCode = 503; + throw error; + } + + async close() {} + async upsertSource() { await this.ensureSchema(); } + async replaceProjection() { await this.ensureSchema(); } + async listSources() { await this.ensureSchema(); } + async listDocuments() { await this.ensureSchema(); } + async listTasks() { await this.ensureSchema(); } + async listWorkbenchLinks() { await this.ensureSchema(); } +}