fix: carry MDTODO HWPOD context into Workbench

This commit is contained in:
root
2026-06-28 17:43:58 +08:00
committed by root
parent d17dd6f6a8
commit dc5b06cc62
16 changed files with 777 additions and 29 deletions
@@ -166,6 +166,38 @@ test("project management app configures HWPOD source and writes MDTODO content",
const report = await json(app, `/v1/project-management/mdtodo/report-preview?taskRef=${encodeURIComponent(rootTaskRef)}&linkId=${encodeURIComponent(detail.detail.links[0].linkId)}`);
expect(report.report.content).toContain("Report body.");
const launchContext = await json(app, `/v1/project-management/mdtodo/launch-context?taskRef=${encodeURIComponent(rootTaskRef)}`);
expect(launchContext.executionContext).toMatchObject({
sourceId: "hwpod-mdtodo",
sourceKind: "hwpod-workspace",
projectId: "project_test",
taskRef: rootTaskRef,
fileRef,
relativePath: "hwlab-v03-mdtodo-web-sample.md",
taskId: "R1",
hwpodId: "d601-f103-v2",
nodeId: "node-d601-f103-v2",
mdtodoRootRef: "docs/MDTODO",
valuesRedacted: true
});
expect(launchContext.executionContext.hwpodWorkspaceArgs).toContain("--hwpod-id d601-f103-v2");
expect(launchContext.executionContext.hwpodWorkspaceArgs).toContain("--workspace-path");
expect(launchContext.executionContext.workspaceRootHash).toMatch(/^[0-9a-f]{64}$/u);
expect(launchContext.executionContext.contextFingerprint).toMatch(/^ctx_[0-9a-f]{24}$/u);
const linkWithContext = await app.fetch(jsonRequest("/v1/project-management/workbench-links", {
method: "POST",
body: { projectId: "project_test", taskRef: rootTaskRef, sessionId: "ses_hwpod_launch", role: "launch", link: { workbenchUrl: "/workbench/sessions/ses_hwpod_launch", launchContext: launchContext.launchContext } }
}));
expect(linkWithContext.status).toBe(201);
const savedLink = await linkWithContext.json();
expect(savedLink.link.link.launchContext).toMatchObject({
sourceId: "hwpod-mdtodo",
hwpodId: "d601-f103-v2",
mdtodoRootRef: "docs/MDTODO",
contextFingerprint: launchContext.executionContext.contextFingerprint
});
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",
+181
View File
@@ -147,6 +147,9 @@ export function createProjectManagementApp(options = {}) {
if (fileContent) {
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
}
if (url.pathname === "/v1/project-management/mdtodo/launch-context") {
return await handleReadMdtodoLaunchContext(url, store);
}
if (url.pathname === "/v1/project-management/mdtodo/task-detail") {
return await handleReadMdtodoTaskDetail(url, store, sourceAdapter);
}
@@ -395,6 +398,19 @@ async function handleReadMdtodoTaskDetail(url, store, sourceAdapter) {
}));
}
async function handleReadMdtodoLaunchContext(url, store) {
const taskRef = queryOpaque(url, "taskRef");
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
const task = await getTaskByRef(store, taskRef);
if (!task) return jsonError(404, "task_not_found", "MDTODO task was not found");
const source = await getSource(store, task.sourceId);
if (!source) return jsonError(404, "source_not_found", "MDTODO source was not found");
const executionContext = buildMdtodoExecutionContext({ task, source });
if (!executionContext.ok) return jsonError(executionContext.status, executionContext.code, executionContext.message);
const launchContext = buildMdtodoLaunchContext({ task, source, executionContext: executionContext.context });
return json(200, envelope({ task, source: sourceSummary(source), executionContext: executionContext.context, launchContext }));
}
async function handleReadMdtodoReportPreview(url, store, sourceAdapter) {
const taskRef = queryOpaque(url, "taskRef");
const linkId = queryToken(url, "linkId");
@@ -591,16 +607,29 @@ function normalizeWorkbenchLinkInput(input, actor) {
function sanitizeLinkJson(value) {
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
const context = input.launchContext && typeof input.launchContext === "object" && !Array.isArray(input.launchContext) ? input.launchContext : {};
const execution = sanitizeExecutionContext(context.executionContext ?? context.execution);
return {
launchContext: {
source: "project-management",
projectId: safeToken(context.projectId, null),
taskRef: safeOpaqueValue(context.taskRef),
sourceId: safeToken(context.sourceId, null),
sourceKind: safeToken(context.sourceKind, null),
fileRef: safeToken(context.fileRef, null),
relativePath: safeRelativePath(context.relativePath),
taskId: safeToken(context.taskId, null),
title: shortText(context.title, 220),
status: safeToken(context.status, null),
hwpodId: execution?.hwpodId ?? safeOpaqueValue(context.hwpodId),
nodeId: execution?.nodeId ?? safeOpaqueValue(context.nodeId),
mdtodoRootRef: execution?.mdtodoRootRef ?? safeRelativePath(context.mdtodoRootRef),
workspaceRootRef: execution?.workspaceRootRef ?? safeWorkspaceRef(context.workspaceRootRef),
workspaceRootHash: execution?.workspaceRootHash ?? safeOpaqueValue(context.workspaceRootHash),
workspaceRootLabel: execution?.workspaceRootLabel ?? shortText(context.workspaceRootLabel, 180),
hwpodWorkspaceArgs: execution?.hwpodWorkspaceArgs ?? shortText(context.hwpodWorkspaceArgs, 500),
contextFingerprint: execution?.contextFingerprint ?? safeOpaqueValue(context.contextFingerprint),
capabilities: sanitizeCapabilities(context.capabilities),
executionContext: execution,
route: "/projects/mdtodo",
valuesRedacted: true
},
@@ -609,6 +638,144 @@ function sanitizeLinkJson(value) {
};
}
function buildMdtodoLaunchContext({ task, source, executionContext }) {
return {
source: "project-management",
projectId: task.projectId,
taskRef: task.taskRef,
sourceId: task.sourceId,
sourceKind: source.sourceKind ?? source.kind ?? null,
fileRef: task.fileRef,
relativePath: safeRelativePath(task.relativePath),
taskId: task.taskId,
title: shortText(task.title, 220),
status: safeToken(task.status, null),
hwpodId: executionContext.hwpodId,
nodeId: executionContext.nodeId,
workspaceRootRef: executionContext.workspaceRootRef,
workspaceRootHash: executionContext.workspaceRootHash,
workspaceRootLabel: executionContext.workspaceRootLabel,
mdtodoRootRef: executionContext.mdtodoRootRef,
hwpodWorkspaceArgs: executionContext.hwpodWorkspaceArgs,
contextFingerprint: executionContext.contextFingerprint,
capabilities: executionContext.capabilities,
executionContext,
route: "/projects/mdtodo",
valuesRedacted: true
};
}
function buildMdtodoExecutionContext({ task, source }) {
const sourceKind = safeToken(source.sourceKind ?? source.kind, null);
const projectId = safeToken(task.projectId ?? source.projectId, null);
const taskRef = safeOpaqueValue(task.taskRef);
const sourceId = safeToken(task.sourceId ?? source.sourceId, null);
const fileRef = safeToken(task.fileRef, null);
if (!projectId || !taskRef || !sourceId || !fileRef) return { ok: false, status: 409, code: "mdtodo_launch_context_incomplete", message: "MDTODO launch context is missing task identity." };
const base = {
sourceId,
sourceKind,
projectId,
taskRef,
fileRef,
relativePath: safeRelativePath(task.relativePath),
taskId: safeToken(task.taskId, null),
valuesRedacted: true
};
if (sourceKind !== "hwpod-workspace") {
const context = { ...base, capabilities: sanitizeCapabilities(source.capabilities), contextFingerprint: contextFingerprint(base) };
return { ok: true, context };
}
const hwpodId = safeOpaqueValue(source.hwpodId);
const nodeId = safeOpaqueValue(source.nodeId);
const workspaceRootRef = safeWorkspaceRef(source.workspaceRootRef);
const mdtodoRootRef = safeRelativePath(source.mdtodoRootRef) ?? ".";
if (!hwpodId || !nodeId || !workspaceRootRef) {
return { ok: false, status: 409, code: "hwpod_launch_context_missing", message: "HWPOD MDTODO source is missing hwpodId, nodeId or workspaceRootRef." };
}
const hwpodWorkspaceArgs = `--hwpod-id ${hwpodId} --workspace-path ${promptShellArg(workspaceRootRef)}`;
const contextBase = {
...base,
hwpodId,
nodeId,
workspaceRootRef,
workspaceRootHash: hashText(workspaceRootRef),
workspaceRootLabel: workspaceRootLabel(workspaceRootRef),
mdtodoRootRef,
hwpodWorkspaceArgs,
capabilities: sanitizeCapabilities(source.capabilities),
valuesRedacted: true
};
return { ok: true, context: { ...contextBase, contextFingerprint: contextFingerprint(contextBase) } };
}
function sourceSummary(source) {
return {
sourceId: source.sourceId,
sourceKind: source.sourceKind ?? source.kind ?? null,
projectId: source.projectId ?? null,
hwpodId: safeOpaqueValue(source.hwpodId),
nodeId: safeOpaqueValue(source.nodeId),
mdtodoRootRef: safeRelativePath(source.mdtodoRootRef),
workspaceRootHash: source.workspaceRootRef ? hashText(source.workspaceRootRef) : null,
workspaceRootLabel: source.workspaceRootRef ? workspaceRootLabel(source.workspaceRootRef) : null,
valuesRedacted: true
};
}
function sanitizeExecutionContext(value) {
const input = value && typeof value === "object" && !Array.isArray(value) ? value : null;
if (!input) return null;
const sourceId = safeToken(input.sourceId, null);
const taskRef = safeOpaqueValue(input.taskRef);
return {
sourceId,
sourceKind: safeToken(input.sourceKind, null),
projectId: safeToken(input.projectId, null),
taskRef,
fileRef: safeToken(input.fileRef, null),
relativePath: safeRelativePath(input.relativePath),
taskId: safeToken(input.taskId, null),
hwpodId: safeOpaqueValue(input.hwpodId),
nodeId: safeOpaqueValue(input.nodeId),
workspaceRootRef: safeWorkspaceRef(input.workspaceRootRef),
workspaceRootHash: safeOpaqueValue(input.workspaceRootHash),
workspaceRootLabel: shortText(input.workspaceRootLabel, 180),
mdtodoRootRef: safeRelativePath(input.mdtodoRootRef),
hwpodWorkspaceArgs: shortText(input.hwpodWorkspaceArgs, 500),
contextFingerprint: safeOpaqueValue(input.contextFingerprint),
capabilities: sanitizeCapabilities(input.capabilities),
valuesRedacted: true
};
}
function sanitizeCapabilities(value) {
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
const out = {};
for (const [key, item] of Object.entries(input)) {
if (!/^[A-Za-z0-9_.:-]{1,80}$/u.test(key)) continue;
if (["string", "number", "boolean"].includes(typeof item) || item === null) out[key] = item;
}
return out;
}
function contextFingerprint(value) {
return `ctx_${hashText(JSON.stringify(value)).slice(0, 24)}`;
}
function hashText(value) {
return createHash("sha256").update(String(value ?? "")).digest("hex");
}
function promptShellArg(value) {
return `'${String(value ?? "").replace(/'/gu, `'\\''`)}'`;
}
function workspaceRootLabel(value) {
const normalized = String(value ?? "").replace(/\\/gu, "/").replace(/\/+$/u, "");
return normalized.split("/").filter(Boolean).pop() || normalized || null;
}
function stableLinkId({ projectId, taskRef, sessionId }) {
return `lnk_${createHash("sha256").update(`${projectId}\n${taskRef}\n${sessionId}`).digest("hex").slice(0, 24)}`;
}
@@ -653,6 +820,20 @@ function safeWorkbenchUrl(value) {
return /^\/workbench\/sessions\/[^/?#]+$/u.test(text) ? text : null;
}
function safeRelativePath(value) {
const text = String(value ?? "").replace(/\\/gu, "/").trim().replace(/^\/+|\/+$/gu, "");
if (!text) return null;
if (text === ".") return ".";
if (text.includes("..") || /(^|\/)\./u.test(text)) return null;
return /^[\p{L}\p{N}_./ -]{1,360}$/u.test(text) ? text : null;
}
function safeWorkspaceRef(value) {
const text = String(value ?? "").trim();
if (!text || text.includes("\0") || text.length > 500) return null;
return /^[\p{L}\p{N}_:./\\ -]+$/u.test(text) ? text : null;
}
function shortText(value, max) {
const text = String(value ?? "").replace(/\s+/gu, " ").trim();
return text ? text.slice(0, max) : null;