Files
pikasTech-HWLAB/internal/project-management/server.ts
T
2026-06-25 21:54:22 +08:00

446 lines
19 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import { createMdtodoSourceAdapter, normalizeProjectSourceInput, sourceProjection } from "./source-adapter.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);
const sourceAdapter = options.sourceAdapter ?? createMdtodoSourceAdapter({ env, hwpodNodeOpsHandler: options.hwpodNodeOpsHandler, fetchImpl: options.fetchImpl });
const defaultSource = normalizeProjectSourceInput({
sourceId,
sourceKind: "local-file-tree",
displayName,
projectId,
rootRef: sourceRoot,
mdtodoRootRef: ".",
maxFiles
}, { projectId, displayName, rootRef: sourceRoot, maxFiles }).source;
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();
await store.ensureSchema();
await store.upsertSource(defaultSource);
const documents = await sourceAdapter.discover(defaultSource);
await store.replaceProjection(defaultSource, documents);
lastProjection = { ...sourceProjection(defaultSource, documents), 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 await handleCreateWorkbenchLink(request, store);
}
if (url.pathname === "/v1/project-management/mdtodo/sources" && request.method === "POST") {
await initialize();
return await handleUpsertMdtodoSource(request, store, { mode: "create" });
}
const sourceAction = matchSourceAction(url.pathname);
if (sourceAction && request.method === "PATCH" && !sourceAction.action) {
await initialize();
return await handleUpsertMdtodoSource(request, store, { mode: "update", sourceId: sourceAction.sourceId });
}
if (sourceAction?.action === "probe" && request.method === "POST") {
await initialize();
return await handleProbeMdtodoSource(store, sourceAdapter, sourceAction.sourceId, actorFromRequest(request));
}
if (sourceAction?.action === "reindex" && request.method === "POST") {
await initialize();
return await handleReindexMdtodoSource(store, sourceAdapter, sourceAction.sourceId, actorFromRequest(request));
}
const fileContent = matchFileContent(url.pathname);
if (fileContent && request.method === "PATCH") {
await initialize();
return await handleWriteMdtodoFile(request, url, store, sourceAdapter, fileContent.fileRef, actorFromRequest(request));
}
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 (fileContent) {
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
}
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, maxBytes = 32768) {
const text = await request.text();
if (text.length > maxBytes) return { ok: false, code: "body_too_large", message: "Project management request 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 }));
}
async function handleUpsertMdtodoSource(request, store, options = {}) {
const body = await readJsonObject(request);
if (!body.ok) return jsonError(400, body.code, body.message);
const existing = options.mode === "update" ? await getSource(store, options.sourceId) : null;
if (options.mode === "update" && !existing) return jsonError(404, "source_not_found", "MDTODO source was not found");
const normalized = normalizeProjectSourceInput({ ...(existing ?? {}), ...body.value, sourceId: options.sourceId ?? body.value.sourceId }, existing ?? {});
if (!normalized.ok) return jsonError(normalized.status ?? 400, normalized.code, normalized.message);
await store.upsertSource(normalized.source);
await recordAuditEvent(store, request, {
action: options.mode === "update" ? "mdtodo.source.update" : "mdtodo.source.create",
targetType: "mdtodo_source",
targetId: normalized.source.sourceId,
outcome: "accepted",
sourceKind: normalized.source.sourceKind
});
return json(options.mode === "update" ? 200 : 201, envelope({ source: normalized.source }));
}
async function handleProbeMdtodoSource(store, sourceAdapter, sourceId, actor) {
const source = await getSource(store, sourceId);
if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found");
const probe = await sourceAdapter.probe(source);
await recordAuditEvent(store, { actor }, { action: "mdtodo.source.probe", targetType: "mdtodo_source", targetId: source.sourceId, outcome: "succeeded", sourceKind: source.sourceKind });
return json(200, envelope({ probe }));
}
async function handleReindexMdtodoSource(store, sourceAdapter, sourceId, actor) {
const source = await getSource(store, sourceId);
if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found");
const documents = await sourceAdapter.discover(source);
await store.replaceProjection(source, documents);
const projection = sourceProjection(source, documents);
await recordAuditEvent(store, { actor }, { action: "mdtodo.source.reindex", targetType: "mdtodo_source", targetId: source.sourceId, outcome: "succeeded", documentCount: projection.documentCount, taskCount: projection.taskCount });
return json(200, envelope({ projection }));
}
async function handleReadMdtodoFile(url, store, sourceAdapter, fileRef) {
const sourceId = queryToken(url, "sourceId");
if (!sourceId) return jsonError(400, "source_id_required", "sourceId is required");
const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef);
if (error) return error;
const file = await sourceAdapter.readFile(source, document.relativePath);
return json(200, envelope({ file }));
}
async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef, actor) {
const body = await readJsonObject(request, 1024 * 1024 + 4096);
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
const sourceId = safeToken(body.value.sourceId, queryToken(url, "sourceId"));
if (!sourceId) return jsonError(400, "source_id_required", "sourceId is required");
if (typeof body.value.content !== "string") return jsonError(400, "content_required", "content must be a string");
const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision);
if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO writes");
const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef);
if (error) return error;
const file = await sourceAdapter.writeFile(source, document.relativePath, body.value.content, expectedFingerprint);
const documents = await sourceAdapter.discover(source);
await store.replaceProjection(source, documents);
const projection = sourceProjection(source, documents);
await recordAuditEvent(store, { actor }, { action: "mdtodo.file.write", targetType: "mdtodo_file", targetId: `${source.sourceId}:${fileRef}`, outcome: "succeeded", sourceKind: source.sourceKind, fileRef, revision: file.revision });
return json(200, envelope({ file, projection }));
}
async function resolveMdtodoDocument(store, sourceId, fileRef) {
const source = await getSource(store, sourceId);
if (!source) return { error: jsonError(404, "source_not_found", "MDTODO source was not found") };
const documents = await store.listDocuments({ sourceId });
const document = documents.find((item) => item.fileRef === fileRef);
if (!document) return { error: jsonError(404, "file_not_found", "MDTODO file was not found") };
return { source, document };
}
async function getSource(store, sourceId) {
if (!sourceId) return null;
if (typeof store.getSource === "function") return store.getSource(sourceId);
return (await store.listSources()).find((source) => source.sourceId === sourceId) ?? null;
}
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)}`;
}
async function recordAuditEvent(store, requestOrContext, event) {
if (typeof store.recordAuditEvent !== "function") return;
const actor = requestOrContext instanceof Request ? actorFromRequest(requestOrContext) : requestOrContext?.actor ?? null;
await store.recordAuditEvent({
eventId: `aud_${randomUUID()}`,
actorId: actor?.id ?? null,
action: event.action,
targetType: event.targetType,
targetId: event.targetId,
outcome: event.outcome,
event,
valuesRedacted: true
});
}
function matchSourceAction(pathname) {
const match = /^\/v1\/project-management\/mdtodo\/sources\/([A-Za-z0-9_.:-]{1,160})(?:\/(probe|reindex))?$/u.exec(pathname);
return match ? { sourceId: match[1], action: match[2] ?? null } : null;
}
function matchFileContent(pathname) {
const match = /^\/v1\/project-management\/mdtodo\/files\/([A-Za-z0-9_.:-]{1,160})\/content$/u.exec(pathname);
return match ? { fileRef: match[1] } : null;
}
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;
}