Files
pikasTech-HWLAB/internal/project-management/source-adapter.ts
T

310 lines
14 KiB
TypeScript

/**
* 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();
const apiKey = String(env.HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY ?? "").trim();
if (!endpoint || typeof fetchImpl !== "function") return null;
return async (plan) => {
const headers = { "content-type": "application/json", "x-source-service-id": "hwlab-project-management" };
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const response = await fetchImpl(endpoint, {
method: "POST",
headers,
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;
}