fix: carry MDTODO HWPOD context into Workbench
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user