feat: add mdtodo source adapter api

Refs #2159
This commit is contained in:
lyon
2026-06-25 21:54:22 +08:00
parent 79719e7b4f
commit 562b148946
4 changed files with 592 additions and 27 deletions
+103 -1
View File
@@ -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)
});
}
+147 -26
View File
@@ -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;
@@ -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;
}
+36
View File
@@ -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) {