Files
pikasTech-HWLAB/internal/project-management/server.ts
T
2026-06-25 17:59:19 +08:00

325 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
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 (url.pathname === "/v1/project-management/workbench-links" && request.method === "POST") {
await initialize();
return handleCreateWorkbenchLink(request, store);
}
if (request.method !== "GET" && request.method !== "HEAD") return jsonError(405, "method_not_allowed", "Project management API only accepts read requests plus Workbench link writes");
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: true,
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
};
}
async function readJsonObject(request) {
const text = await request.text();
if (text.length > 32768) return { ok: false, code: "body_too_large", message: "Project management link body is too large" };
try {
const value = text ? JSON.parse(text) : {};
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, code: "invalid_params", message: "body must be a JSON object" };
return { ok: true, value };
} catch (error) {
return { ok: false, code: "parse_error", message: "Invalid JSON body", reason: error?.message ?? String(error) };
}
}
async function handleCreateWorkbenchLink(request, store) {
const body = await readJsonObject(request);
if (!body.ok) return jsonError(400, body.code, body.message);
const normalized = normalizeWorkbenchLinkInput(body.value, actorFromRequest(request));
if (!normalized.ok) return jsonError(400, normalized.code, normalized.message);
const link = await store.upsertWorkbenchLink(normalized.link);
return json(201, envelope({ link }));
}
function normalizeWorkbenchLinkInput(input, actor) {
const projectId = safeToken(input.projectId, null);
const taskRef = safeOpaqueValue(input.taskRef);
const sessionId = safeOpaqueValue(input.sessionId);
if (!projectId) return { ok: false, code: "project_id_required", message: "ProjectWorkbenchLink requires projectId" };
if (!taskRef) return { ok: false, code: "task_ref_required", message: "ProjectWorkbenchLink requires taskRef" };
if (!sessionId) return { ok: false, code: "session_id_required", message: "ProjectWorkbenchLink requires sessionId" };
const traceId = safeOpaqueValue(input.traceId);
const role = safeToken(input.role, "launch");
const link = sanitizeLinkJson(input.link);
return {
ok: true,
link: {
linkId: safeToken(input.linkId, stableLinkId({ projectId, taskRef, sessionId })),
projectId,
taskRef,
sessionId,
traceId,
createdBy: safeOpaqueValue(input.createdBy) ?? actor?.id ?? null,
role,
link
}
};
}
function sanitizeLinkJson(value) {
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
const context = input.launchContext && typeof input.launchContext === "object" && !Array.isArray(input.launchContext) ? input.launchContext : {};
return {
launchContext: {
source: "project-management",
projectId: safeToken(context.projectId, null),
taskRef: safeOpaqueValue(context.taskRef),
sourceId: safeToken(context.sourceId, null),
fileRef: safeToken(context.fileRef, null),
taskId: safeToken(context.taskId, null),
title: shortText(context.title, 220),
status: safeToken(context.status, null),
route: "/projects/mdtodo",
valuesRedacted: true
},
workbenchUrl: safeWorkbenchUrl(input.workbenchUrl),
valuesRedacted: true
};
}
function stableLinkId({ projectId, taskRef, sessionId }) {
return `lnk_${createHash("sha256").update(`${projectId}\n${taskRef}\n${sessionId}`).digest("hex").slice(0, 24)}`;
}
function safeOpaqueValue(value) {
const text = String(value ?? "").trim();
return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null;
}
function safeWorkbenchUrl(value) {
const text = String(value ?? "").trim();
return /^\/workbench\/sessions\/[^/?#]+$/u.test(text) ? text : null;
}
function shortText(value, max) {
const text = String(value ?? "").replace(/\s+/gu, " ").trim();
return text ? text.slice(0, max) : 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;
}