From 562b148946aee0e059a02aed4070cbc27bde8e8a Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 25 Jun 2026 21:54:22 +0800 Subject: [PATCH] feat: add mdtodo source adapter api Refs #2159 --- internal/project-management/server.test.ts | 104 +++++- internal/project-management/server.ts | 173 ++++++++-- internal/project-management/source-adapter.ts | 306 ++++++++++++++++++ internal/project-management/store.ts | 36 +++ 4 files changed, 592 insertions(+), 27 deletions(-) create mode 100644 internal/project-management/source-adapter.ts diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index f37a05bc..cf41d6de 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -1,9 +1,10 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { expect, test } from "bun:test"; +import { executeHwpodNodeOpsPlan } from "../../tools/src/hwpod-node-lib.ts"; import { createProjectManagementApp } from "./server.ts"; import { createProjectManagementStore } from "./store.ts"; @@ -63,8 +64,109 @@ test("project management app exposes MDTODO read model and Workbench link writes } }); +test("project management app configures HWPOD source and writes MDTODO content", async () => { + const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-")); + const defaultRoot = join(root, "default"); + const hwpodRoot = join(root, "hwpod-workspace"); + const mdtodoDir = join(hwpodRoot, "docs", "MDTODO"); + const samplePath = join(mdtodoDir, "hwlab-v03-mdtodo-web-sample.md"); + const store = createProjectManagementStore({ kind: "memory" }); + try { + await mkdir(defaultRoot, { recursive: true }); + await mkdir(mdtodoDir, { recursive: true }); + await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8"); + await writeFile(samplePath, "# Sample\n\n## R1 Root\n\n### R1.1 Child [in_progress]\n", "utf8"); + const app = createProjectManagementApp({ + env: {}, + store, + sourceRoot: defaultRoot, + sourceId: "default-mdtodo", + projectId: "project_test", + hwpodNodeOpsHandler: (plan) => executeHwpodNodeOpsPlan(plan) + }); + + const createResponse = await app.fetch(jsonRequest("/v1/project-management/mdtodo/sources", { + method: "POST", + body: { + sourceId: "hwpod-mdtodo", + sourceKind: "hwpod-workspace", + displayName: "D601 F103 MDTODO", + projectId: "project_test", + hwpodId: "d601-f103-v2", + nodeId: "node-d601-f103-v2", + workspaceRootRef: hwpodRoot, + mdtodoRootRef: "docs/MDTODO/" + } + })); + expect(createResponse.status).toBe(201); + const created = await createResponse.json(); + expect(created.source.sourceKind).toBe("hwpod-workspace"); + + const probe = await post(app, "/v1/project-management/mdtodo/sources/hwpod-mdtodo/probe", {}); + expect(probe.probe.status).toBe("ok"); + + const reindex = await post(app, "/v1/project-management/mdtodo/sources/hwpod-mdtodo/reindex", {}); + expect(reindex.projection.documentCount).toBe(1); + expect(reindex.projection.taskCount).toBe(2); + + const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=hwpod-mdtodo"); + expect(files.files[0].relativePath).toBe("hwlab-v03-mdtodo-web-sample.md"); + const fileRef = files.files[0].fileRef; + + const content = await json(app, `/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`); + expect(content.file.content).toContain("## R1 Root"); + + const nextContent = "# Sample\n\n## R1 Edited root\n\n### R1.1 Child [completed]\n"; + const write = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, { + method: "PATCH", + body: { sourceId: "hwpod-mdtodo", expectedFingerprint: content.file.fingerprint, content: nextContent } + })); + expect(write.status).toBe(200); + const written = await write.json(); + expect(written.file.fingerprint).not.toBe(content.file.fingerprint); + expect(await readFile(samplePath, "utf8")).toBe(nextContent); + + const tasks = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=hwpod-mdtodo"); + expect(tasks.tasks.map((task) => [task.taskId, task.title, task.status])).toEqual([ + ["R1", "Edited root", "open"], + ["R1.1", "Child", "done"] + ]); + + const conflict = await app.fetch(jsonRequest(`/v1/project-management/mdtodo/files/${fileRef}/content?sourceId=hwpod-mdtodo`, { + method: "PATCH", + body: { sourceId: "hwpod-mdtodo", expectedFingerprint: content.file.fingerprint, content: nextContent } + })); + expect(conflict.status).toBe(409); + + const rejected = await app.fetch(jsonRequest("/v1/project-management/mdtodo/sources", { + method: "POST", + body: { sourceKind: "hwpod-workspace", hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", workspaceRootRef: hwpodRoot, mdtodoRootRef: "../secret" } + })); + expect(rejected.status).toBe(400); + expect((await store.listAuditEvents()).some((event) => event.action === "mdtodo.file.write")).toBe(true); + + await app.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + async function json(app, path) { const response = await app.fetch(new Request(`http://service${path}`, { headers: { "x-hwlab-actor-id": "usr_test" } })); expect(response.status).toBe(200); return response.json(); } + +async function post(app, path, body) { + const response = await app.fetch(jsonRequest(path, { method: "POST", body })); + expect(response.status).toBe(200); + return response.json(); +} + +function jsonRequest(path, options = {}) { + return new Request(`http://service${path}`, { + method: options.method ?? "GET", + headers: { "content-type": "application/json", "x-hwlab-actor-id": "usr_test" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body) + }); +} diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index 80e0cc82..4af64f7b 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -1,7 +1,7 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import path from "node:path"; -import { discoverMdtodoDocuments } from "./mdtodo.ts"; +import { createMdtodoSourceAdapter, normalizeProjectSourceInput, sourceProjection } from "./source-adapter.ts"; const contractVersion = "project-management-v1"; const serviceId = "hwlab-project-management"; @@ -15,6 +15,16 @@ export function createProjectManagementApp(options = {}) { const projectId = safeToken(options.projectId ?? env.HWLAB_PROJECT_MANAGEMENT_PROJECT_ID, "project_hwlab_v03"); const displayName = String(options.displayName ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME ?? "HWLAB MDTODO").trim() || "HWLAB MDTODO"; const maxFiles = positiveInteger(options.maxFiles ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_MAX_FILES, 300); + const sourceAdapter = options.sourceAdapter ?? createMdtodoSourceAdapter({ env, hwpodNodeOpsHandler: options.hwpodNodeOpsHandler, fetchImpl: options.fetchImpl }); + const defaultSource = normalizeProjectSourceInput({ + sourceId, + sourceKind: "local-file-tree", + displayName, + projectId, + rootRef: sourceRoot, + mdtodoRootRef: ".", + maxFiles + }, { projectId, displayName, rootRef: sourceRoot, maxFiles }).source; let initialized = false; let lastProjection = null; let lastError = null; @@ -25,28 +35,11 @@ export function createProjectManagementApp(options = {}) { 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 - }; + await store.upsertSource(defaultSource); + const documents = await sourceAdapter.discover(defaultSource); + await store.replaceProjection(defaultSource, documents); + lastProjection = { ...sourceProjection(defaultSource, documents), startedAt }; initialized = true; lastError = null; logger.info?.(JSON.stringify({ event: "project-management-projection-ready", serviceId, ...lastProjection })); @@ -81,7 +74,29 @@ export function createProjectManagementApp(options = {}) { 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); + return await handleCreateWorkbenchLink(request, store); + } + if (url.pathname === "/v1/project-management/mdtodo/sources" && request.method === "POST") { + await initialize(); + return await handleUpsertMdtodoSource(request, store, { mode: "create" }); + } + const sourceAction = matchSourceAction(url.pathname); + if (sourceAction && request.method === "PATCH" && !sourceAction.action) { + await initialize(); + return await handleUpsertMdtodoSource(request, store, { mode: "update", sourceId: sourceAction.sourceId }); + } + if (sourceAction?.action === "probe" && request.method === "POST") { + await initialize(); + return await handleProbeMdtodoSource(store, sourceAdapter, sourceAction.sourceId, actorFromRequest(request)); + } + if (sourceAction?.action === "reindex" && request.method === "POST") { + await initialize(); + return await handleReindexMdtodoSource(store, sourceAdapter, sourceAction.sourceId, actorFromRequest(request)); + } + const fileContent = matchFileContent(url.pathname); + if (fileContent && request.method === "PATCH") { + await initialize(); + return await handleWriteMdtodoFile(request, url, store, sourceAdapter, fileContent.fileRef, actorFromRequest(request)); } if (request.method !== "GET" && request.method !== "HEAD") return jsonError(405, "method_not_allowed", "Project management API only accepts read requests plus Workbench link writes"); @@ -99,6 +114,9 @@ export function createProjectManagementApp(options = {}) { if (url.pathname === "/v1/project-management/mdtodo/files") { return json(200, envelope({ files: await store.listDocuments({ sourceId: queryToken(url, "sourceId") }) })); } + if (fileContent) { + return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef); + } if (url.pathname === "/v1/project-management/mdtodo/tasks") { return json(200, envelope({ tasks: await store.listTasks({ @@ -203,9 +221,9 @@ function actorFromRequest(request) { }; } -async function readJsonObject(request) { +async function readJsonObject(request, maxBytes = 32768) { const text = await request.text(); - if (text.length > 32768) return { ok: false, code: "body_too_large", message: "Project management link body is too large" }; + 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" }; @@ -224,6 +242,84 @@ async function handleCreateWorkbenchLink(request, store) { return json(201, envelope({ link })); } +async function handleUpsertMdtodoSource(request, store, options = {}) { + const body = await readJsonObject(request); + if (!body.ok) return jsonError(400, body.code, body.message); + const existing = options.mode === "update" ? await getSource(store, options.sourceId) : null; + if (options.mode === "update" && !existing) return jsonError(404, "source_not_found", "MDTODO source was not found"); + const normalized = normalizeProjectSourceInput({ ...(existing ?? {}), ...body.value, sourceId: options.sourceId ?? body.value.sourceId }, existing ?? {}); + if (!normalized.ok) return jsonError(normalized.status ?? 400, normalized.code, normalized.message); + await store.upsertSource(normalized.source); + await recordAuditEvent(store, request, { + action: options.mode === "update" ? "mdtodo.source.update" : "mdtodo.source.create", + targetType: "mdtodo_source", + targetId: normalized.source.sourceId, + outcome: "accepted", + sourceKind: normalized.source.sourceKind + }); + return json(options.mode === "update" ? 200 : 201, envelope({ source: normalized.source })); +} + +async function handleProbeMdtodoSource(store, sourceAdapter, sourceId, actor) { + const source = await getSource(store, sourceId); + if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found"); + const probe = await sourceAdapter.probe(source); + await recordAuditEvent(store, { actor }, { action: "mdtodo.source.probe", targetType: "mdtodo_source", targetId: source.sourceId, outcome: "succeeded", sourceKind: source.sourceKind }); + return json(200, envelope({ probe })); +} + +async function handleReindexMdtodoSource(store, sourceAdapter, sourceId, actor) { + const source = await getSource(store, sourceId); + if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found"); + const documents = await sourceAdapter.discover(source); + await store.replaceProjection(source, documents); + const projection = sourceProjection(source, documents); + await recordAuditEvent(store, { actor }, { action: "mdtodo.source.reindex", targetType: "mdtodo_source", targetId: source.sourceId, outcome: "succeeded", documentCount: projection.documentCount, taskCount: projection.taskCount }); + return json(200, envelope({ projection })); +} + +async function handleReadMdtodoFile(url, store, sourceAdapter, fileRef) { + const sourceId = queryToken(url, "sourceId"); + if (!sourceId) return jsonError(400, "source_id_required", "sourceId is required"); + const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef); + if (error) return error; + const file = await sourceAdapter.readFile(source, document.relativePath); + return json(200, envelope({ file })); +} + +async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef, actor) { + const body = await readJsonObject(request, 1024 * 1024 + 4096); + if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message); + const sourceId = safeToken(body.value.sourceId, queryToken(url, "sourceId")); + if (!sourceId) return jsonError(400, "source_id_required", "sourceId is required"); + if (typeof body.value.content !== "string") return jsonError(400, "content_required", "content must be a string"); + const expectedFingerprint = safeOpaqueValue(body.value.expectedFingerprint ?? body.value.revision); + if (!expectedFingerprint) return jsonError(400, "expected_fingerprint_required", "expectedFingerprint is required for MDTODO writes"); + const { source, document, error } = await resolveMdtodoDocument(store, sourceId, fileRef); + if (error) return error; + const file = await sourceAdapter.writeFile(source, document.relativePath, body.value.content, expectedFingerprint); + const documents = await sourceAdapter.discover(source); + await store.replaceProjection(source, documents); + const projection = sourceProjection(source, documents); + await recordAuditEvent(store, { actor }, { action: "mdtodo.file.write", targetType: "mdtodo_file", targetId: `${source.sourceId}:${fileRef}`, outcome: "succeeded", sourceKind: source.sourceKind, fileRef, revision: file.revision }); + return json(200, envelope({ file, projection })); +} + +async function resolveMdtodoDocument(store, sourceId, fileRef) { + const source = await getSource(store, sourceId); + if (!source) return { error: jsonError(404, "source_not_found", "MDTODO source was not found") }; + const documents = await store.listDocuments({ sourceId }); + const document = documents.find((item) => item.fileRef === fileRef); + if (!document) return { error: jsonError(404, "file_not_found", "MDTODO file was not found") }; + return { source, document }; +} + +async function getSource(store, sourceId) { + if (!sourceId) return null; + if (typeof store.getSource === "function") return store.getSource(sourceId); + return (await store.listSources()).find((source) => source.sourceId === sourceId) ?? null; +} + function normalizeWorkbenchLinkInput(input, actor) { const projectId = safeToken(input.projectId, null); const taskRef = safeOpaqueValue(input.taskRef); @@ -274,6 +370,31 @@ function stableLinkId({ projectId, taskRef, sessionId }) { return `lnk_${createHash("sha256").update(`${projectId}\n${taskRef}\n${sessionId}`).digest("hex").slice(0, 24)}`; } +async function recordAuditEvent(store, requestOrContext, event) { + if (typeof store.recordAuditEvent !== "function") return; + const actor = requestOrContext instanceof Request ? actorFromRequest(requestOrContext) : requestOrContext?.actor ?? null; + await store.recordAuditEvent({ + eventId: `aud_${randomUUID()}`, + actorId: actor?.id ?? null, + action: event.action, + targetType: event.targetType, + targetId: event.targetId, + outcome: event.outcome, + event, + valuesRedacted: true + }); +} + +function matchSourceAction(pathname) { + const match = /^\/v1\/project-management\/mdtodo\/sources\/([A-Za-z0-9_.:-]{1,160})(?:\/(probe|reindex))?$/u.exec(pathname); + return match ? { sourceId: match[1], action: match[2] ?? null } : null; +} + +function matchFileContent(pathname) { + const match = /^\/v1\/project-management\/mdtodo\/files\/([A-Za-z0-9_.:-]{1,160})\/content$/u.exec(pathname); + return match ? { fileRef: match[1] } : null; +} + function safeOpaqueValue(value) { const text = String(value ?? "").trim(); return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null; diff --git a/internal/project-management/source-adapter.ts b/internal/project-management/source-adapter.ts new file mode 100644 index 00000000..5e497e93 --- /dev/null +++ b/internal/project-management/source-adapter.ts @@ -0,0 +1,306 @@ +/** + * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source. + * Owns MDTODO Source normalization and bounded file access for local and HWPOD workspace sources. + */ +import { createHash, randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; +import { discoverMdtodoDocuments, parseMdtodoDocument } from "./mdtodo.ts"; + +const maxContentBytes = 1024 * 1024; +const hiddenOrSecretPattern = /(^\.|secret|secrets|token|credential|credentials|\.env$)/iu; + +export function createMdtodoSourceAdapter(options = {}) { + const env = options.env ?? process.env; + const hwpodNodeOpsHandler = options.hwpodNodeOpsHandler ?? createHwpodNodeOpsHttpClient({ env, fetchImpl: options.fetchImpl ?? globalThis.fetch }); + + return { + async probe(source) { + if (source.sourceKind === "hwpod-workspace") { + const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: normalizeDirectoryRef(source.mdtodoRootRef ?? ".") }); + return { ok: true, status: "ok", sourceId: source.sourceId, sourceKind: source.sourceKind, entries: Array.isArray(output.entries) ? output.entries.length : 0, valuesRedacted: true }; + } + const root = localRootForSource(source); + const entries = await fs.readdir(root, { withFileTypes: true }); + return { ok: true, status: "ok", sourceId: source.sourceId, sourceKind: source.sourceKind, entries: entries.length, valuesRedacted: true }; + }, + + async discover(source) { + if (source.sourceKind === "hwpod-workspace") return discoverHwpodDocuments(hwpodNodeOpsHandler, source); + return discoverMdtodoDocuments({ root: localRootForSource(source), sourceId: source.sourceId, projectId: source.projectId, maxFiles: source.maxFiles }); + }, + + async readFile(source, relativePath) { + const safePath = normalizeMdtodoRelativePath(relativePath); + const content = source.sourceKind === "hwpod-workspace" + ? (await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.cat", { path: hwpodWorkspacePath(source, safePath), maxBytes: maxContentBytes })).content + : await fs.readFile(localFilePathForSource(source, safePath), "utf8"); + return fileContentPayload(source, safePath, String(content ?? "")); + }, + + async writeFile(source, relativePath, content, expectedFingerprint) { + const safePath = normalizeMdtodoRelativePath(relativePath); + const nextContent = String(content ?? ""); + if (Buffer.byteLength(nextContent, "utf8") > maxContentBytes) throw sourceError("mdtodo_content_too_large", "MDTODO content is too large", 413); + const before = await this.readFile(source, safePath); + if (expectedFingerprint && before.fingerprint !== expectedFingerprint) { + throw sourceError("revision_conflict", "MDTODO document fingerprint does not match expectedFingerprint", 409, { expectedFingerprint, actualFingerprint: before.fingerprint }); + } + if (source.sourceKind === "hwpod-workspace") { + await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.write", { path: hwpodWorkspacePath(source, safePath), content: nextContent, expectedSha: expectedFingerprint ?? before.fingerprint }); + } else { + const target = localFilePathForSource(source, safePath); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, nextContent, "utf8"); + } + return fileContentPayload(source, safePath, nextContent); + } + }; +} + +export function normalizeProjectSourceInput(input, defaults = {}) { + try { + const sourceKind = normalizeSourceKind(input.sourceKind ?? input.kind ?? defaults.sourceKind ?? "local-file-tree"); + const projectId = safeToken(input.projectId, defaults.projectId ?? "project_hwlab_v03"); + const displayName = shortText(input.displayName ?? input.name ?? defaults.displayName ?? "HWLAB MDTODO", 180) ?? "HWLAB MDTODO"; + const status = safeToken(input.status, "active"); + const maxFiles = positiveInteger(input.maxFiles ?? defaults.maxFiles, 300); + const mdtodoRootRef = normalizeDirectoryRef(input.mdtodoRootRef ?? defaults.mdtodoRootRef ?? "."); + const hwpodId = sourceKind === "hwpod-workspace" ? requiredOpaque(input.hwpodId ?? defaults.hwpodId, "hwpodId") : null; + const nodeId = sourceKind === "hwpod-workspace" ? requiredOpaque(input.nodeId ?? defaults.nodeId, "nodeId") : null; + const workspaceRootRef = sourceKind === "hwpod-workspace" ? requiredWorkspaceRoot(input.workspaceRootRef ?? defaults.workspaceRootRef) : null; + const rootRef = sourceKind === "hwpod-workspace" + ? `${hwpodId}:${mdtodoRootRef}` + : path.resolve(String(input.rootRef ?? defaults.rootRef ?? process.cwd()), mdtodoRootRef === "." ? "" : mdtodoRootRef); + const sourceId = safeToken(input.sourceId, stableSourceId({ sourceKind, projectId, rootRef, hwpodId, nodeId, mdtodoRootRef })); + const capabilities = normalizeCapabilities(input.capabilities, sourceKind); + return { + ok: true, + source: { + sourceId, + kind: sourceKind === "hwpod-workspace" ? "hwpod-workspace" : "mdtodo-file-tree", + sourceKind, + displayName, + projectId, + rootRef, + status, + hwpodId, + nodeId, + workspaceRootRef, + mdtodoRootRef, + maxFiles, + capabilities, + valuesRedacted: true + } + }; + } catch (error) { + return { ok: false, code: error?.code ?? "invalid_source", message: error?.message ?? "Invalid MDTODO source", status: error?.statusCode ?? 400 }; + } +} + +export function sourceProjection(source, documents) { + return { + sourceId: source.sourceId, + sourceKind: source.sourceKind, + rootRef: source.rootRef, + documentCount: documents.length, + taskCount: documents.reduce((sum, document) => sum + document.taskCount, 0), + projectedAt: new Date().toISOString(), + valuesRedacted: true + }; +} + +function createHwpodNodeOpsHttpClient({ env, fetchImpl }) { + const endpoint = String(env.HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL ?? env.HWLAB_HWPOD_NODE_OPS_URL ?? "").trim(); + if (!endpoint || typeof fetchImpl !== "function") return null; + return async (plan) => { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json", "x-source-service-id": "hwlab-project-management" }, + body: JSON.stringify(plan), + signal: AbortSignal.timeout(30000) + }); + return response.json(); + }; +} + +async function discoverHwpodDocuments(hwpodNodeOpsHandler, source) { + const files = await listHwpodMarkdownFiles(hwpodNodeOpsHandler, source); + const documents = []; + for (const relativePath of files.slice(0, source.maxFiles)) { + const content = (await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.cat", { path: hwpodWorkspacePath(source, relativePath), maxBytes: maxContentBytes })).content; + const parsed = parseMdtodoDocument(String(content ?? ""), { sourceId: source.sourceId, relativePath, projectId: source.projectId }); + if (parsed.tasks.length === 0 && !/mdtodo|todo/i.test(path.basename(relativePath))) continue; + documents.push(parsed); + } + documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return documents; +} + +async function listHwpodMarkdownFiles(hwpodNodeOpsHandler, source) { + const root = normalizeDirectoryRef(source.mdtodoRootRef ?? "."); + const files = []; + async function walk(relativeDir) { + if (files.length >= source.maxFiles) return; + const workspacePath = relativeDir ? joinRelative(root, relativeDir, { allowDirectory: true }) : root; + const output = await runHwpodOp(hwpodNodeOpsHandler, source, "workspace.ls", { path: workspacePath }); + const entries = Array.isArray(output.entries) ? output.entries : []; + entries.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""))); + for (const entry of entries) { + const name = String(entry.name ?? ""); + if (!name || isHiddenOrSecretSegment(name)) continue; + const child = relativeDir ? `${relativeDir}/${name}` : name; + if (entry.type === "dir") await walk(child); + else if (entry.type === "file" && name.toLowerCase().endsWith(".md")) files.push(normalizeMdtodoRelativePath(child)); + if (files.length >= source.maxFiles) return; + } + } + await walk(""); + return files; +} + +async function runHwpodOp(hwpodNodeOpsHandler, source, op, args) { + if (!hwpodNodeOpsHandler) throw sourceError("hwpod_adapter_unconfigured", "HWPOD node-ops adapter is not configured", 503); + const plan = { + contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, + planId: `hwpod_plan_${randomUUID()}`, + hwpodId: source.hwpodId, + nodeId: source.nodeId, + intent: op, + ops: [{ opId: "op_01", op, args: { workspacePath: source.workspaceRootRef, ...args } }] + }; + const payload = await hwpodNodeOpsHandler(plan, { source }); + const result = Array.isArray(payload?.results) ? payload.results[0] : null; + if (!payload?.ok || !result?.ok) { + const blocker = result?.blocker ?? payload?.blocker ?? payload?.error ?? {}; + throw sourceError(blocker.code ?? "hwpod_node_ops_failed", blocker.summary ?? blocker.message ?? "HWPOD node-ops request failed", payload?.status === "blocked" ? 503 : 502, { hwpodStatus: payload?.status ?? null }); + } + return result.output ?? {}; +} + +function fileContentPayload(source, relativePath, content) { + const parsed = parseMdtodoDocument(content, { sourceId: source.sourceId, relativePath, projectId: source.projectId }); + return { + sourceId: source.sourceId, + fileRef: parsed.fileRef, + relativePath, + content, + fingerprint: parsed.fingerprint, + revision: parsed.fingerprint, + parserMode: parsed.parserMode, + taskCount: parsed.taskCount, + document: parsed.document, + valuesRedacted: false + }; +} + +function localRootForSource(source) { + return path.resolve(String(source.rootRef ?? process.cwd())); +} + +function localFilePathForSource(source, relativePath) { + const root = localRootForSource(source); + const target = path.resolve(root, normalizeMdtodoRelativePath(relativePath)); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw sourceError("invalid_mdtodo_path", "MDTODO path escapes source root", 400); + return target; +} + +function hwpodWorkspacePath(source, relativePath) { + return joinRelative(normalizeDirectoryRef(source.mdtodoRootRef ?? "."), normalizeMdtodoRelativePath(relativePath)); +} + +function normalizeMdtodoRelativePath(value) { + const normalized = normalizeRelativeSegments(value, { allowDirectory: false }); + if (!normalized.toLowerCase().endsWith(".md")) throw sourceError("invalid_mdtodo_path", "MDTODO file path must end with .md", 400); + return normalized; +} + +function normalizeDirectoryRef(value) { + return normalizeRelativeSegments(value || ".", { allowDirectory: true }); +} + +function normalizeRelativeSegments(value, options = {}) { + const text = String(value ?? "").replace(/\\/gu, "/").trim(); + if (!text || text === ".") return options.allowDirectory ? "." : failPath(); + if (text.startsWith("/") || /^[A-Za-z]:\//u.test(text)) failPath(); + const segments = text.split("/").filter((segment) => segment && segment !== "."); + if (segments.length === 0) return options.allowDirectory ? "." : failPath(); + for (const segment of segments) { + if (segment === ".." || segment.includes("\0") || isHiddenOrSecretSegment(segment)) failPath(); + } + return segments.join("/"); +} + +function joinRelative(root, relativePath, options = {}) { + const rootRef = normalizeDirectoryRef(root); + const child = normalizeRelativeSegments(relativePath, { allowDirectory: options.allowDirectory === true }); + if (rootRef === ".") return child; + if (child === ".") return rootRef; + return `${rootRef}/${child}`; +} + +function failPath() { + throw sourceError("invalid_mdtodo_path", "MDTODO path must be a safe relative path inside the configured root", 400); +} + +function isHiddenOrSecretSegment(segment) { + return hiddenOrSecretPattern.test(String(segment ?? "")); +} + +function normalizeSourceKind(value) { + const text = String(value ?? "").trim().toLowerCase(); + if (["hwpod-workspace", "hwpod"].includes(text)) return "hwpod-workspace"; + if (["local-file-tree", "mdtodo-file-tree", "file-tree", "local"].includes(text)) return "local-file-tree"; + throw sourceError("invalid_source_kind", "sourceKind must be local-file-tree or hwpod-workspace", 400); +} + +function normalizeCapabilities(value, sourceKind) { + const defaults = sourceKind === "hwpod-workspace" ? ["probe", "list", "read", "write", "reindex"] : ["list", "read", "write", "reindex"]; + const input = Array.isArray(value) ? value : defaults; + return input.map((item) => safeToken(item, null)).filter(Boolean).slice(0, 20); +} + +function requiredOpaque(value, name) { + const text = String(value ?? "").trim(); + if (/^[A-Za-z0-9_.:-]{1,160}$/u.test(text)) return text; + throw sourceError("invalid_source_binding", `${name} is required`, 400); +} + +function requiredWorkspaceRoot(value) { + const text = String(value ?? "").trim(); + if (!text || text.length > 512 || /[\n\r\0]/u.test(text)) throw sourceError("invalid_workspace_root", "workspaceRootRef is required", 400); + return text; +} + +function stableSourceId(value) { + return `src_${sha256(JSON.stringify(value)).slice(0, 16)}`; +} + +function safeToken(value, fallback) { + const text = String(value ?? "").trim(); + return /^[A-Za-z0-9_.:-]{1,160}$/u.test(text) ? text : fallback; +} + +function shortText(value, max) { + const text = String(value ?? "").replace(/\s+/gu, " ").trim(); + return text ? text.slice(0, max) : null; +} + +function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? Math.min(number, 500) : fallback; +} + +function sha256(value) { + return createHash("sha256").update(String(value ?? "")).digest("hex"); +} + +function sourceError(code, message, statusCode = 400, details = {}) { + const error = new Error(message); + error.code = code; + error.statusCode = statusCode; + Object.assign(error, details); + return error; +} diff --git a/internal/project-management/store.ts b/internal/project-management/store.ts index ea079a1c..3532243f 100644 --- a/internal/project-management/store.ts +++ b/internal/project-management/store.ts @@ -206,6 +206,27 @@ export class PostgresProjectManagementStore { })); } + async getSource(sourceId) { + await this.ensureSchema(); + const result = await this.pool.query( + `SELECT source_id, kind, display_name, project_id, root_ref, status, source_json, updated_at + FROM project_sources WHERE source_id = $1`, + [sourceId] + ); + const row = result.rows[0]; + if (!row) return null; + return { + sourceId: row.source_id, + kind: row.kind, + displayName: row.display_name, + projectId: row.project_id, + rootRef: row.root_ref, + status: row.status, + updatedAt: row.updated_at, + ...(row.source_json ?? {}) + }; + } + async listDocuments(filters = {}) { await this.ensureSchema(); const params = []; @@ -316,6 +337,15 @@ export class PostgresProjectManagementStore { return workbenchLinkFromRow(result.rows[0]); } + async recordAuditEvent(event) { + await this.ensureSchema(); + await this.pool.query( + `INSERT INTO project_management_audit_events (event_id, actor_id, action, target_type, target_id, outcome, event_json, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, now())`, + [event.eventId, event.actorId ?? null, event.action, event.targetType, event.targetId, event.outcome, JSON.stringify(event)] + ); + } + async close() { await this.pool.end(); } @@ -327,6 +357,7 @@ class MemoryProjectManagementStore { this.documents = []; this.tasks = []; this.links = []; + this.auditEvents = []; } async ensureSchema() {} @@ -344,6 +375,7 @@ class MemoryProjectManagementStore { } async listSources() { return [...this.sources.values()]; } + async getSource(sourceId) { return this.sources.get(sourceId) ?? null; } async listDocuments(filters = {}) { return this.documents.filter((item) => !filters.sourceId || item.sourceId === filters.sourceId); } async listTasks(filters = {}) { return this.tasks.filter((item) => (!filters.sourceId || item.sourceId === filters.sourceId) && (!filters.fileRef || item.fileRef === filters.fileRef) && (!filters.projectId || item.projectId === filters.projectId)); @@ -364,6 +396,8 @@ class MemoryProjectManagementStore { } return saved; } + async recordAuditEvent(event) { this.auditEvents.push({ ...event, createdAt: new Date().toISOString() }); } + async listAuditEvents() { return this.auditEvents.slice(); } } class UnconfiguredProjectManagementStore { @@ -380,12 +414,14 @@ class UnconfiguredProjectManagementStore { async close() {} async upsertSource() { await this.ensureSchema(); } + async getSource() { await this.ensureSchema(); } async replaceProjection() { await this.ensureSchema(); } async listSources() { await this.ensureSchema(); } async listDocuments() { await this.ensureSchema(); } async listTasks() { await this.ensureSchema(); } async listWorkbenchLinks() { await this.ensureSchema(); } async upsertWorkbenchLink() { await this.ensureSchema(); } + async recordAuditEvent() { await this.ensureSchema(); } } function workbenchLinkFromRow(row) {