Merge pull request #2140 from pikasTech/feat/2134-project-management-service
feat: add project management microservice
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
Reference in New Issue
Block a user