325 lines
12 KiB
TypeScript
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;
|
|
}
|