234 lines
8.2 KiB
TypeScript
234 lines
8.2 KiB
TypeScript
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;
|
|
}
|