Files

963 lines
44 KiB
TypeScript

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
};
}