import { createHash, randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { mutateMdtodoDocument, readMdtodoTaskDetail } from "./mdtodo.ts"; 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 taskMaxLimit = positiveInteger(options.taskMaxLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT, 500); const taskDefaultLimit = Math.min(taskMaxLimit, positiveInteger(options.taskDefaultLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT, 200)); 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(); const bootstrapSources = await projectManagementBootstrapSources({ env, projectId, displayName, sourceRoot, maxFiles }); const sources = dedupeProjectSources([defaultSource, ...bootstrapSources]); const projections = []; for (const source of sources) { await store.upsertSource(source); try { const documents = await sourceAdapter.discover(source); await store.replaceProjection(source, documents); projections.push({ ...sourceProjection(source, documents), status: "ready" }); } catch (error) { if (source.sourceId === defaultSource.sourceId) throw error; const blocker = { code: error?.code ?? "project_source_projection_failed", message: error?.message ?? String(error) }; projections.push({ ...sourceProjection(source, []), status: "blocked", blocker }); logger.warn?.(JSON.stringify({ event: "project-management-bootstrap-source-projection-failed", serviceId, sourceId: source.sourceId, sourceKind: source.sourceKind, ...blocker })); } } lastProjection = summarizeProjectManagementProjection(projections, 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)); } const taskAction = matchTaskAction(url.pathname); if (url.pathname === "/v1/project-management/mdtodo/tasks" && request.method === "POST") { await initialize(); return await handleCreateMdtodoTask(request, store, sourceAdapter, actorFromRequest(request)); } if (taskAction && request.method === "PATCH") { await initialize(); return await handlePatchMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, actorFromRequest(request)); } if (taskAction && request.method === "DELETE") { await initialize(); return await handleDeleteMdtodoTask(request, store, sourceAdapter, taskAction.taskRef, 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/launch-context") { return await handleReadMdtodoLaunchContext(url, store); } if (url.pathname === "/v1/project-management/mdtodo/task-detail") { return await handleReadMdtodoTaskDetail(url, store, sourceAdapter); } if (url.pathname === "/v1/project-management/mdtodo/report-preview") { return await handleReadMdtodoReportPreview(url, store, sourceAdapter); } if (url.pathname === "/v1/project-management/mdtodo/tasks") { const taskFilters = { taskRef: queryOpaque(url, "taskRef"), sourceId: queryToken(url, "sourceId"), fileRef: queryToken(url, "fileRef"), projectId: queryToken(url, "projectId"), parentTaskRef: queryOpaque(url, "parentTaskRef"), status: queryToken(url, "status"), search: querySearch(url, "search") }; const page = taskListPage(url, { defaultLimit: taskDefaultLimit, maxLimit: taskMaxLimit }); const [total, tasks] = await Promise.all([ store.countTasks(taskFilters), store.listTasks({ ...taskFilters, limit: page.limit, offset: page.offset }) ]); return json(200, envelope({ tasks, page: { ...page, total, hasMore: page.offset + tasks.length < total } })); } 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(); } }; } async function projectManagementBootstrapSources({ env, projectId, displayName, sourceRoot, maxFiles }) { const inputs = await readProjectManagementBootstrapSourceInputs(env); return inputs.map((input, index) => { const normalized = normalizeProjectSourceInput(input, { projectId, displayName, rootRef: sourceRoot, maxFiles }); if (!normalized.ok) { const error = Object.assign(new Error(`bootstrap source[${index}] invalid: ${normalized.message}`), { code: normalized.code, statusCode: normalized.status }); throw error; } return normalized.source; }); } async function readProjectManagementBootstrapSourceInputs(env) { const pathValue = String(env.HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH ?? "").trim(); const inlineValue = String(env.HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_JSON ?? "").trim(); if (!pathValue && !inlineValue) return []; const text = pathValue ? await readFile(pathValue, "utf8") : inlineValue; const parsed = JSON.parse(text); if (Array.isArray(parsed)) return parsed; if (Array.isArray(parsed?.sources)) return parsed.sources; const error = Object.assign(new Error("Project management bootstrap sources must be a JSON array or an object with a sources array"), { code: "invalid_project_management_bootstrap_sources", statusCode: 400 }); throw error; } function dedupeProjectSources(sources) { const byId = new Map(); for (const source of sources) byId.set(source.sourceId, source); return [...byId.values()]; } function summarizeProjectManagementProjection(projections, startedAt) { if (projections.length === 1) return { ...projections[0], startedAt }; return { sourceId: projections[0]?.sourceId ?? "project-management", sourceCount: projections.length, sources: projections, documentCount: projections.reduce((sum, item) => sum + Number(item.documentCount ?? 0), 0), taskCount: projections.reduce((sum, item) => sum + Number(item.taskCount ?? 0), 0), projectedAt: new Date().toISOString(), startedAt, valuesRedacted: true }; } 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 handleReadMdtodoTaskDetail(url, store, sourceAdapter) { const taskRef = queryOpaque(url, "taskRef"); if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required"); const detail = await loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback: true }); if (detail.error) return detail.error; if (detail.availability?.state === "unavailable") { return json(200, envelope({ status: "partial", task: detail.task, detail: null, file: fileSummary(detail.document), detailAuthority: detail.availability })); } return json(200, envelope({ status: "ready", task: { ...detail.task, bodyPreview: detail.detail.bodyPreview, linkCount: detail.detail.links.length }, detail: detail.detail, file: fileSummary(detail.file), detailAuthority: taskDetailAuthority("ready", detail.source) })); } async function handleReadMdtodoLaunchContext(url, store) { const taskRef = queryOpaque(url, "taskRef"); if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required"); const task = await getTaskByRef(store, taskRef); if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found"); const source = await getSource(store, task.sourceId); if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found"); const executionContext = buildMdtodoExecutionContext({ task, source }); if (!executionContext.ok) return jsonError(executionContext.status, executionContext.code, executionContext.message); const launchContext = buildMdtodoLaunchContext({ task, source, executionContext: executionContext.context }); return json(200, envelope({ task, source: sourceSummary(source), executionContext: executionContext.context, launchContext })); } async function handleReadMdtodoReportPreview(url, store, sourceAdapter) { const taskRef = queryOpaque(url, "taskRef"); const linkId = queryToken(url, "linkId"); if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required"); if (!linkId) return jsonError(400, "link_id_required", "linkId is required"); let detail; try { detail = await loadTaskDetail({ store, sourceAdapter, taskRef }); } catch (error) { if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error); throw error; } if (detail.error) return detail.error; const link = detail.detail.links.find((item) => item.linkId === linkId); if (!link) return jsonError(404, "report_link_not_found", "MDTODO report link was not found"); if (link.kind !== "markdown-report" || !link.relativePath) return jsonError(400, "unsupported_report_link", "Only safe relative Markdown report links can be previewed"); let report; try { report = await sourceAdapter.readLinkedMarkdown(detail.source, detail.document.relativePath, link.href); } catch (error) { if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error); throw error; } return json(200, envelope({ task: detail.task, link, report: { ...report, linkId: link.linkId, label: link.label, href: link.href, valuesRedacted: false }, file: fileSummary(detail.file) })); } async function loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback = false }) { const task = await getTaskByRef(store, taskRef); if (!task) return { error: jsonError(404, "task_not_found", "MDTODO task was not found") }; const { source, document, error } = await resolveMdtodoDocument(store, task.sourceId, task.fileRef); if (error) return { error }; let file; try { file = await sourceAdapter.readFile(source, document.relativePath); } catch (readError) { if (allowProjectedFallback && source.sourceKind === "hwpod-workspace" && isHwpodAvailabilityError(readError)) { return { source, document, file: null, task, detail: null, availability: taskDetailAuthority("unavailable", source, readError) }; } throw readError; } const detail = readMdtodoTaskDetail(file.content, { sourceId: source.sourceId, fileRef: document.fileRef, relativePath: document.relativePath, projectId: document.projectId, taskRef: task.taskRef, rxxId: task.taskId }); if (!detail) return { error: jsonError(404, "task_detail_not_found", "MDTODO task detail was not found in source Markdown") }; return { source, document, file, task, detail }; } function isHwpodAvailabilityError(error) { const statusCode = Number(error?.statusCode); return error?.sourceKind === "hwpod-workspace" && [502, 503, 504].includes(statusCode); } function hwpodAvailabilityError(error) { return jsonError( Number(error?.statusCode), safeToken(error?.code, "hwpod_source_unavailable"), "HWPOD 源当前不可读;报告预览需等待 Node 恢复。" ); } function taskDetailAuthority(state, source, error = null) { const unavailable = state === "unavailable"; return { state, authority: unavailable ? "project-management-projection" : "source-markdown", sourceKind: source?.sourceKind ?? null, code: unavailable ? safeToken(error?.code, "hwpod_source_unavailable") : null, message: unavailable ? "HWPOD 源当前不可读;页面继续展示最近一次项目投影,正文编辑和报告预览需等待 Node 恢复。" : null, retryable: unavailable, valuesRedacted: true }; } 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 handlePatchMdtodoTask(request, store, sourceAdapter, taskRef, actor) { const body = await readJsonObject(request, 256 * 1024); if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message); const task = await getTaskByRef(store, taskRef); if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found"); const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision); if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes"); const operations = []; if (typeof body.value.title === "string") operations.push({ type: "updateTitle", rxxId: task.taskId, title: body.value.title }); if (typeof body.value.status === "string") operations.push({ type: "updateStatus", rxxId: task.taskId, status: body.value.status }); if (typeof body.value.body === "string") operations.push({ type: "updateBody", rxxId: task.taskId, body: body.value.body }); if (operations.length === 0) return jsonError(400, "mutation_required", "At least one MDTODO task field is required"); return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations, auditAction: "mdtodo.task.update", targetTaskRef: taskRef, actor, status: 200 }); } async function handleCreateMdtodoTask(request, store, sourceAdapter, actor) { const body = await readJsonObject(request, 256 * 1024); if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message); const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision); if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes"); const title = shortText(body.value.title, 500); if (!title) return jsonError(400, "title_required", "title is required for MDTODO task creation"); const parentTask = body.value.parentTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.parentTaskRef)) : null; const afterTask = body.value.afterTaskRef ? await getTaskByRef(store, safeOpaqueValue(body.value.afterTaskRef)) : null; if (body.value.parentTaskRef && !parentTask) return jsonError(404, "parent_task_not_found", "Parent MDTODO task was not found"); if (body.value.afterTaskRef && !afterTask) return jsonError(404, "after_task_not_found", "Continuation MDTODO task was not found"); const sourceId = parentTask?.sourceId ?? afterTask?.sourceId ?? safeToken(body.value.sourceId, null); const fileRef = parentTask?.fileRef ?? afterTask?.fileRef ?? safeToken(body.value.fileRef, null); if (!sourceId || !fileRef) return jsonError(400, "task_target_required", "sourceId and fileRef are required for root MDTODO task creation"); const operation = { type: "addTask", title, status: body.value.status ?? "open", body: typeof body.value.body === "string" ? body.value.body : "", parentRxxId: parentTask?.taskId ?? null, afterRxxId: afterTask?.taskId ?? null }; return mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations: [operation], auditAction: "mdtodo.task.create", targetTaskRef: parentTask?.taskRef ?? afterTask?.taskRef ?? `${sourceId}:${fileRef}`, actor, status: 201 }); } async function handleDeleteMdtodoTask(request, store, sourceAdapter, taskRef, actor) { const body = await readJsonObject(request, 32768); if (!body.ok) return jsonError(400, body.code, body.message); const task = await getTaskByRef(store, taskRef); if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found"); const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision ?? body.value.expectedRevision); if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO task writes"); return mutateTaskDocument({ store, sourceAdapter, sourceId: task.sourceId, fileRef: task.fileRef, expectedFingerprint, operations: [{ type: "deleteTask", rxxId: task.taskId }], auditAction: "mdtodo.task.delete", targetTaskRef: taskRef, actor, status: 200 }); } async function mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, expectedFingerprint, operations, auditAction, targetTaskRef, actor, status }) { const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef); if (error) return error; const current = await sourceAdapter.readFile(source, document.relativePath); let markdown = current.content; let mutation = null; for (const operation of operations) { mutation = mutateMdtodoDocument(markdown, { ...operation, expectedFingerprint: mutation ? null : expectedFingerprint }, { sourceId: source.sourceId, relativePath: document.relativePath, projectId: document.projectId }); if (!mutation.ok) return jsonError(mutation.conflict ? 409 : 400, mutation.code ?? "mdtodo_mutation_failed", mutation.message ?? "MDTODO task mutation failed"); markdown = mutation.markdown; } const file = await sourceAdapter.writeFile(source, document.relativePath, markdown, expectedFingerprint); const documents = await sourceAdapter.discover(source); await store.replaceProjection(source, documents); const projection = sourceProjection(source, documents); const tasks = await store.listTasks({ sourceId: source.sourceId, fileRef }); const changedTaskRef = mutation?.changedTaskId ? `mdtodo:${source.sourceId}:${fileRef}:${mutation.changedTaskId}` : null; const task = changedTaskRef ? tasks.find((item) => item.taskRef === changedTaskRef) ?? null : null; await recordAuditEvent(store, { actor }, { action: auditAction, targetType: "mdtodo_task", targetId: targetTaskRef, outcome: "succeeded", sourceKind: source.sourceKind, fileRef, revision: file.revision, changedTaskId: mutation?.changedTaskId ?? null, affectedTaskIds: mutation?.affectedTaskIds ?? [] }); return json(status, envelope({ task, affectedTaskIds: mutation?.affectedTaskIds ?? [], changedTaskId: mutation?.changedTaskId ?? null, file: fileSummary(file), projection })); } async function getTaskByRef(store, taskRef) { const safeTaskRef = safeOpaqueValue(taskRef); if (!safeTaskRef) return null; if (typeof store.getTask === "function") return store.getTask(safeTaskRef); return (await store.listTasks({ taskRef: safeTaskRef, limit: 1 }))[0] ?? null; } function fileSummary(file) { return { sourceId: file.sourceId, fileRef: file.fileRef, relativePath: file.relativePath, fingerprint: file.fingerprint, revision: file.revision, parserMode: file.parserMode, taskCount: file.taskCount, valuesRedacted: true }; } 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 : {}; const execution = sanitizeExecutionContext(context.executionContext ?? context.execution); return { launchContext: { source: "project-management", projectId: safeToken(context.projectId, null), taskRef: safeOpaqueValue(context.taskRef), sourceId: safeToken(context.sourceId, null), sourceKind: safeToken(context.sourceKind, null), fileRef: safeToken(context.fileRef, null), relativePath: safeRelativePath(context.relativePath), taskId: safeToken(context.taskId, null), title: shortText(context.title, 220), status: safeToken(context.status, null), hwpodId: execution?.hwpodId ?? safeOpaqueValue(context.hwpodId), nodeId: execution?.nodeId ?? safeOpaqueValue(context.nodeId), mdtodoRootRef: execution?.mdtodoRootRef ?? safeRelativePath(context.mdtodoRootRef), workspaceRootRef: execution?.workspaceRootRef ?? safeWorkspaceRef(context.workspaceRootRef), workspaceRootHash: execution?.workspaceRootHash ?? safeOpaqueValue(context.workspaceRootHash), workspaceRootLabel: execution?.workspaceRootLabel ?? shortText(context.workspaceRootLabel, 180), hwpodWorkspaceArgs: execution?.hwpodWorkspaceArgs ?? shortText(context.hwpodWorkspaceArgs, 500), contextFingerprint: execution?.contextFingerprint ?? safeOpaqueValue(context.contextFingerprint), capabilities: sanitizeCapabilities(context.capabilities), executionContext: execution, route: "/projects/mdtodo", valuesRedacted: true }, workbenchUrl: safeWorkbenchUrl(input.workbenchUrl), valuesRedacted: true }; } function buildMdtodoLaunchContext({ task, source, executionContext }) { return { source: "project-management", projectId: task.projectId, taskRef: task.taskRef, sourceId: task.sourceId, sourceKind: source.sourceKind ?? source.kind ?? null, fileRef: task.fileRef, relativePath: safeRelativePath(task.relativePath), taskId: task.taskId, title: shortText(task.title, 220), status: safeToken(task.status, null), hwpodId: executionContext.hwpodId, nodeId: executionContext.nodeId, workspaceRootRef: executionContext.workspaceRootRef, workspaceRootHash: executionContext.workspaceRootHash, workspaceRootLabel: executionContext.workspaceRootLabel, mdtodoRootRef: executionContext.mdtodoRootRef, hwpodWorkspaceArgs: executionContext.hwpodWorkspaceArgs, contextFingerprint: executionContext.contextFingerprint, capabilities: executionContext.capabilities, executionContext, route: "/projects/mdtodo", valuesRedacted: true }; } function buildMdtodoExecutionContext({ task, source }) { const sourceKind = safeToken(source.sourceKind ?? source.kind, null); const projectId = safeToken(task.projectId ?? source.projectId, null); const taskRef = safeOpaqueValue(task.taskRef); const sourceId = safeToken(task.sourceId ?? source.sourceId, null); const fileRef = safeToken(task.fileRef, null); if (!projectId || !taskRef || !sourceId || !fileRef) return { ok: false, status: 409, code: "mdtodo_launch_context_incomplete", message: "MDTODO launch context is missing task identity." }; const base = { sourceId, sourceKind, projectId, taskRef, fileRef, relativePath: safeRelativePath(task.relativePath), taskId: safeToken(task.taskId, null), valuesRedacted: true }; if (sourceKind !== "hwpod-workspace") { const context = { ...base, capabilities: sanitizeCapabilities(source.capabilities), contextFingerprint: contextFingerprint(base) }; return { ok: true, context }; } const hwpodId = safeOpaqueValue(source.hwpodId); const nodeId = safeOpaqueValue(source.nodeId); const workspaceRootRef = safeWorkspaceRef(source.workspaceRootRef); const mdtodoRootRef = safeRelativePath(source.mdtodoRootRef) ?? "."; if (!hwpodId || !nodeId || !workspaceRootRef) { return { ok: false, status: 409, code: "hwpod_launch_context_missing", message: "HWPOD MDTODO source is missing hwpodId, nodeId or workspaceRootRef." }; } const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(workspaceRootRef)}`; const contextBase = { ...base, hwpodId, nodeId, workspaceRootRef, workspaceRootHash: hashText(workspaceRootRef), workspaceRootLabel: workspaceRootLabel(workspaceRootRef), mdtodoRootRef, hwpodWorkspaceArgs, capabilities: sanitizeCapabilities(source.capabilities), valuesRedacted: true }; return { ok: true, context: { ...contextBase, contextFingerprint: contextFingerprint(contextBase) } }; } function sourceSummary(source) { return { sourceId: source.sourceId, sourceKind: source.sourceKind ?? source.kind ?? null, projectId: source.projectId ?? null, hwpodId: safeOpaqueValue(source.hwpodId), nodeId: safeOpaqueValue(source.nodeId), mdtodoRootRef: safeRelativePath(source.mdtodoRootRef), workspaceRootHash: source.workspaceRootRef ? hashText(source.workspaceRootRef) : null, workspaceRootLabel: source.workspaceRootRef ? workspaceRootLabel(source.workspaceRootRef) : null, valuesRedacted: true }; } function sanitizeExecutionContext(value) { const input = value && typeof value === "object" && !Array.isArray(value) ? value : null; if (!input) return null; const sourceId = safeToken(input.sourceId, null); const taskRef = safeOpaqueValue(input.taskRef); return { sourceId, sourceKind: safeToken(input.sourceKind, null), projectId: safeToken(input.projectId, null), taskRef, fileRef: safeToken(input.fileRef, null), relativePath: safeRelativePath(input.relativePath), taskId: safeToken(input.taskId, null), hwpodId: safeOpaqueValue(input.hwpodId), nodeId: safeOpaqueValue(input.nodeId), workspaceRootRef: safeWorkspaceRef(input.workspaceRootRef), workspaceRootHash: safeOpaqueValue(input.workspaceRootHash), workspaceRootLabel: shortText(input.workspaceRootLabel, 180), mdtodoRootRef: safeRelativePath(input.mdtodoRootRef), hwpodWorkspaceArgs: shortText(input.hwpodWorkspaceArgs, 500), contextFingerprint: safeOpaqueValue(input.contextFingerprint), capabilities: sanitizeCapabilities(input.capabilities), valuesRedacted: true }; } function sanitizeCapabilities(value) { const input = value && typeof value === "object" && !Array.isArray(value) ? value : {}; const out = {}; for (const [key, item] of Object.entries(input)) { if (!/^[A-Za-z0-9_.:-]{1,80}$/u.test(key)) continue; if (["string", "number", "boolean"].includes(typeof item) || item === null) out[key] = item; } return out; } function contextFingerprint(value) { return `ctx_${hashText(JSON.stringify(value)).slice(0, 24)}`; } function hashText(value) { return createHash("sha256").update(String(value ?? "")).digest("hex"); } function promptShellArg(value) { return `'${String(value ?? "").replace(/'/gu, `'\\''`)}'`; } function workspaceRootLabel(value) { const normalized = String(value ?? "").replace(/\\/gu, "/").replace(/\/+$/u, ""); return normalized.split("/").filter(Boolean).pop() || normalized || null; } 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 matchTaskAction(pathname) { const match = /^\/v1\/project-management\/mdtodo\/tasks\/(.+)$/u.exec(pathname); return match ? { taskRef: decodeURIComponent(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 safeRelativePath(value) { const text = String(value ?? "").replace(/\\/gu, "/").trim().replace(/^\/+|\/+$/gu, ""); if (!text) return null; if (text === ".") return "."; if (text.includes("..") || /(^|\/)\./u.test(text)) return null; return /^[\p{L}\p{N}_./ -]{1,360}$/u.test(text) ? text : null; } function safeWorkspaceRef(value) { const text = String(value ?? "").trim(); if (!text || text.includes("\0") || text.length > 500) return null; return /^[\p{L}\p{N}_:./\\ -]+$/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 querySearch(url, name) { const value = url.searchParams.get(name); if (value === null) return null; const text = shortText(value, 120); return text && /^[\p{L}\p{N}\s_.:/#-]{1,120}$/u.test(text) ? text : 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; } function nonNegativeInteger(value, fallback) { const number = Number(value); return Number.isInteger(number) && number >= 0 ? number : fallback; } function taskListPage(url, options) { const limit = Math.min(options.maxLimit, positiveInteger(url.searchParams.get("limit"), options.defaultLimit)); return { offset: nonNegativeInteger(url.searchParams.get("offset"), 0), limit, maxLimit: options.maxLimit }; }