@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user