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
+4
View File
@@ -19,6 +19,10 @@ Workbench 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单
Workbench 投影写路径必须做到 0 隐式 fallback。Admission、projection event、terminal/finalizer 等上游写入如果无法把 session/message/turn/checkpoint facts 写入 durable read model,不能 `catch` 后返回空值继续表现为成功;admission 阶段必须显式失败并把错误传给调用方,后台投影阶段必须至少写入 trace diagnostic 和 OTel error span。只有成功落库的 Workbench facts 才能驱动控制页、观察页、session rail、耗时和 final response;前端或 read path 不得用内存 trace、local optimistic state、历史 snapshot 或多来源仲裁去修补失败写入。
MDTODO 发起 Workbench 执行时,HWPOD 执行上下文的唯一权威来源是 Project Management source registry。Workbench Launch 服务端必须通过 `taskRef -> sourceId/fileRef -> source` 解析 `launchContext.executionContext`,并把同一份 `contextFingerprint` 写入 session owner、Workbench facts、project-management link 和 OTel span;浏览器传入的 HWPOD 字段只能作为任务元数据,不能作为权威执行上下文。`sourceKind=hwpod-workspace` 但缺少 `hwpodId``nodeId``workspaceRootRef` 时,launch 必须显式失败,不能创建“看似成功但无法执行”的空 session。
MDTODO 首轮 Code Agent prompt 必须包含 `hwpodId``mdtodoRootRef``hwpodWorkspaceArgs`,并要求所有 `hwpod/hwpod-ctl` 命令携带该参数。Agent 不得猜测容器本地路径,不得创建、复制或修补本地 `.hwlab/hwpod-spec.yaml` fallback。Workbench session header 可以显示 source、HWPOD 和 MDTODO root 的只读短标识;workspace host path 的长期可观测性默认使用 basename/label、hash 或 redacted 形态。
Cloud Web 的通用加载态使用 `web/hwlab-cloud-web/src/components/common/LoadingState.vue`。新增或修复页面加载态时优先复用该组件,并通过明确的 ready/loading 状态控制展示;不要在每个组件里重新实现一套 spinner、点状动画或默认占位数据。紧凑区域可以使用组件的 compact 形态,文案默认保持“加载中”。
Session rail 是该规则的高频区域。`/v1/agent/conversations` 还未返回时,即使 workspace 中已有 `selectedConversationId`、sessionId、traceId 或 selected conversation snapshot,也不能把选中 session stub 渲染成单条 `.session-tab`,更不能让它占满整个 session 列表高度。加载窗口应只显示 `LoadingState`,并隐藏当前 trace 元信息、复制/删除等依赖真实 active tab 的动作;待 conversations ready 后再渲染真实 session tabs,或在真实空集合时显示空态。
+77 -1
View File
@@ -97,6 +97,7 @@ export async function handleCodeAgentChatHttp(request, response, options) {
});
return;
}
params = withMdtodoExecutionPrompt(params);
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`;
const lifecycle = codeAgentTurnLifecycleFields(traceId, params);
@@ -133,7 +134,7 @@ export async function handleCodeAgentChatHttp(request, response, options) {
const admittedTiming = codeAgentAdmissionTimingFields(submitted?.createdAt ?? submitted?.updatedAt);
void emitCodeAgentOtelSpan("POST /v1/agent/chat", traceId, options.env ?? process.env, {
kind: 2,
attributes: { "http.method": "POST", "http.route": "/v1/agent/chat", "http.status_code": statusCode, sessionId: safeSessionId(nativeSessionChatParams.sessionId) || null, turnId: lifecycle.turnId }
attributes: { "http.method": "POST", "http.route": "/v1/agent/chat", "http.status_code": statusCode, sessionId: safeSessionId(nativeSessionChatParams.sessionId) || null, turnId: lifecycle.turnId, ...agentChatOtelAttributes(nativeSessionChatParams) }
});
sendJson(response, 202, {
accepted: true,
@@ -800,6 +801,80 @@ function safeOtelUrlHost(value) {
}
}
function withMdtodoExecutionPrompt(params = {}) {
const execution = mdtodoExecutionContextFromParams(params);
if (!execution?.hwpodId || !execution?.hwpodWorkspaceArgs) return params;
const message = firstNonEmptyValue(params.message, params.prompt, params.text);
if (!message || /hwpodWorkspaceArgs\s*:/u.test(message)) return params;
const taskRef = firstNonEmptyValue(params.taskRef, execution.taskRef) ?? "-";
const mdtodoRootRef = execution.mdtodoRootRef ?? "docs/MDTODO";
const prefix = [
"# MDTODO HWPOD 执行上下文",
`TaskRef: ${taskRef}`,
`sourceId: ${execution.sourceId ?? "-"}`,
`hwpodId: ${execution.hwpodId}`,
`nodeId: ${execution.nodeId ?? "-"}`,
`mdtodoRootRef: ${mdtodoRootRef}`,
`hwpodWorkspaceArgs: ${execution.hwpodWorkspaceArgs}`,
"所有 hwpod/hwpod-ctl 命令都必须携带上述 hwpodWorkspaceArgs。读取 MDTODO、报告和工程文件必须通过 HWPOD workspace/node-ops 入口,不得猜测或访问普通容器本地路径,也不得创建本地 .hwlab/hwpod-spec.yaml fallback。"
].join("\n");
const nextMessage = `${prefix}\n\n${message}`;
return {
...params,
message: nextMessage,
prompt: nextMessage,
launchContext: {
...(params.launchContext && typeof params.launchContext === "object" ? params.launchContext : {}),
hwpodId: execution.hwpodId,
nodeId: execution.nodeId,
mdtodoRootRef: execution.mdtodoRootRef,
hwpodWorkspaceArgs: execution.hwpodWorkspaceArgs,
contextFingerprint: execution.contextFingerprint,
executionContext: execution,
valuesRedacted: true
}
};
}
function mdtodoExecutionContextFromParams(params = {}) {
const launchContext = params.launchContext && typeof params.launchContext === "object" && !Array.isArray(params.launchContext) ? params.launchContext : null;
const execution = launchContext?.executionContext && typeof launchContext.executionContext === "object" && !Array.isArray(launchContext.executionContext) ? launchContext.executionContext : launchContext;
if (!execution || typeof execution !== "object") return null;
return {
sourceId: safeOtelConfigText(execution.sourceId ?? launchContext?.sourceId),
sourceKind: safeOtelConfigText(execution.sourceKind ?? launchContext?.sourceKind),
projectId: safeOtelConfigText(execution.projectId ?? launchContext?.projectId ?? params.projectId),
taskRef: safeOtelText(execution.taskRef ?? launchContext?.taskRef ?? params.taskRef),
fileRef: safeOtelConfigText(execution.fileRef ?? launchContext?.fileRef),
relativePath: safeOtelText(execution.relativePath ?? launchContext?.relativePath),
taskId: safeOtelConfigText(execution.taskId ?? launchContext?.taskId),
hwpodId: safeOtelText(execution.hwpodId ?? launchContext?.hwpodId),
nodeId: safeOtelText(execution.nodeId ?? launchContext?.nodeId),
mdtodoRootRef: safeOtelText(execution.mdtodoRootRef ?? launchContext?.mdtodoRootRef),
workspaceRootHash: safeOtelText(execution.workspaceRootHash ?? launchContext?.workspaceRootHash),
hwpodWorkspaceArgs: safeOtelText(execution.hwpodWorkspaceArgs ?? launchContext?.hwpodWorkspaceArgs, 500),
contextFingerprint: safeOtelText(execution.contextFingerprint ?? launchContext?.contextFingerprint),
valuesRedacted: true
};
}
function agentChatOtelAttributes(params = {}) {
const execution = mdtodoExecutionContextFromParams(params);
return {
"agent.chat.session_id": safeSessionId(params.sessionId) || null,
"agent.chat.task_ref": safeOtelText(params.taskRef ?? execution?.taskRef),
"agent.chat.source_id": safeOtelConfigText(execution?.sourceId ?? params.sourceId),
"agent.chat.hwpod_id": safeOtelText(execution?.hwpodId),
"agent.chat.workspace_root_hash": safeOtelText(execution?.workspaceRootHash),
"agent.chat.context_fingerprint": safeOtelText(execution?.contextFingerprint)
};
}
function safeOtelText(value, max = 360) {
const text = firstNonEmptyValue(value);
return text === null ? null : text.slice(0, max);
}
async function submitCodeAgentChatTurn({ params, options, traceId }) {
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const results = options.codeAgentChatResults ?? createCodeAgentChatResultStore();
@@ -822,6 +897,7 @@ async function submitCodeAgentChatTurn({ params, options, traceId }) {
results.set(traceId, annotateOwner(acceptedPayload, params));
void emitCodeAgentOtelSpan("provider_decision", traceId, runtimeEnv, {
attributes: {
...agentChatOtelAttributes(params),
adapterEnabled,
adapter: safeOtelConfigText(runtimeEnv.HWLAB_CODE_AGENT_ADAPTER ?? runtimeEnv.HWLAB_CODE_AGENT_PROVIDER),
provider: safeOtelConfigText(runtimeEnv.HWLAB_CODE_AGENT_PROVIDER),
+49
View File
@@ -1029,20 +1029,69 @@ function compactLaunchContext(value) {
const projectId = textValue(value.projectId) || null;
const taskRef = textValue(value.taskRef) || null;
if (!projectId && !taskRef) return null;
const execution = compactLaunchExecutionContext(value.executionContext ?? value.execution ?? value);
return {
source: textValue(value.source) || "project-management",
projectId,
taskRef,
sourceId: textValue(value.sourceId) || null,
sourceKind: textValue(value.sourceKind) || execution?.sourceKind || null,
fileRef: textValue(value.fileRef) || null,
relativePath: textValue(value.relativePath) || execution?.relativePath || null,
taskId: textValue(value.taskId) || null,
title: textValue(value.title) || null,
status: textValue(value.status) || null,
hwpodId: (execution?.hwpodId ?? textValue(value.hwpodId)) || null,
nodeId: (execution?.nodeId ?? textValue(value.nodeId)) || null,
mdtodoRootRef: (execution?.mdtodoRootRef ?? textValue(value.mdtodoRootRef)) || null,
workspaceRootHash: (execution?.workspaceRootHash ?? textValue(value.workspaceRootHash)) || null,
workspaceRootLabel: (execution?.workspaceRootLabel ?? textValue(value.workspaceRootLabel)) || null,
hwpodWorkspaceArgs: (execution?.hwpodWorkspaceArgs ?? textValue(value.hwpodWorkspaceArgs)) || null,
contextFingerprint: (execution?.contextFingerprint ?? textValue(value.contextFingerprint)) || null,
capabilities: compactPlainObject(value.capabilities),
executionContext: execution,
route: textValue(value.route) || "/projects/mdtodo",
valuesRedacted: true
};
}
function compactLaunchExecutionContext(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const sourceId = textValue(value.sourceId) || null;
const taskRef = textValue(value.taskRef) || null;
const hwpodId = textValue(value.hwpodId) || null;
if (!sourceId && !taskRef && !hwpodId) return null;
return {
sourceId,
sourceKind: textValue(value.sourceKind) || null,
projectId: textValue(value.projectId) || null,
taskRef,
fileRef: textValue(value.fileRef) || null,
relativePath: textValue(value.relativePath) || null,
taskId: textValue(value.taskId) || null,
hwpodId,
nodeId: textValue(value.nodeId) || null,
mdtodoRootRef: textValue(value.mdtodoRootRef) || null,
workspaceRootRef: textValue(value.workspaceRootRef) || null,
workspaceRootHash: textValue(value.workspaceRootHash) || null,
workspaceRootLabel: textValue(value.workspaceRootLabel) || null,
hwpodWorkspaceArgs: textValue(value.hwpodWorkspaceArgs) || null,
contextFingerprint: textValue(value.contextFingerprint) || null,
capabilities: compactPlainObject(value.capabilities),
valuesRedacted: true
};
}
function compactPlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out = {};
for (const [key, item] of Object.entries(value)) {
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 factMessagesForSession(session, facts = {}) {
const sessionId = factSessionId(session);
if (!sessionId) return [];
@@ -16,6 +16,12 @@ test("workbench launch records OTel stage for Project Management link write 404"
const fetchCalls = [];
globalThis.fetch = async (url, init) => {
fetchCalls.push({ url: String(url), method: init?.method, body: init?.body });
if (String(url).includes("/v1/project-management/mdtodo/launch-context")) {
return new Response(JSON.stringify(projectManagementLaunchContextPayload()), {
status: 200,
headers: { "content-type": "application/json" }
});
}
return new Response(JSON.stringify({ error: { code: "not_found", message: "Project link route was not found." } }), {
status: 404,
headers: { "content-type": "application/json" }
@@ -55,7 +61,10 @@ test("workbench launch records OTel stage for Project Management link write 404"
}
assert.equal(response.statusCode, 404);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls.length, 2);
assert.equal(fetchCalls[0].method, "GET");
assert.match(fetchCalls[0].url, /\/v1\/project-management\/mdtodo\/launch-context/u);
assert.equal(fetchCalls[1].method, "POST");
const body = JSON.parse(response.body);
assert.equal(body.error.code, "not_found");
const context = request.hwlabHttpRequestContext;
@@ -68,6 +77,10 @@ test("workbench launch records OTel stage for Project Management link write 404"
assert.equal(context.otelAttributes["workbench.launch.upstream_status"], 404);
assert.equal(context.otelAttributes["workbench.launch.session_id"], "ses_launch_otel");
assert.equal(context.otelAttributes["workbench.launch.task_ref"], "mdtodo:hwlab-v03-mdtodo:file_demo:t4_open");
assert.equal(context.otelAttributes["workbench.launch.source_id"], "hwlab-v03-mdtodo");
assert.equal(context.otelAttributes["workbench.launch.hwpod_id"], "constart-71freq-c");
assert.equal(context.otelAttributes["workbench.launch.workspace_root_hash"], "workspace-hash");
assert.equal(context.otelAttributes["workbench.launch.context_fingerprint"], "ctx_launch_otel");
assert.match(context.otelAttributes["workbench.launch.task_ref_hash"], /^[0-9a-f]{24}$/u);
assert.equal(context.otelAttributes["workbench.launch.values_redacted"], true);
});
@@ -109,3 +122,41 @@ function jsonResponse() {
}
};
}
function projectManagementLaunchContextPayload() {
return {
ok: true,
launchContext: {
source: "project-management",
projectId: "project_hwlab_v03",
taskRef: "mdtodo:hwlab-v03-mdtodo:file_demo:t4_open",
sourceId: "hwlab-v03-mdtodo",
sourceKind: "hwpod-workspace",
fileRef: "file_demo",
taskId: "t4_open",
hwpodId: "constart-71freq-c",
nodeId: "D518",
mdtodoRootRef: "docs/MDTODO",
workspaceRootHash: "workspace-hash",
hwpodWorkspaceArgs: "--hwpod-id constart-71freq-c --workspace-path 'F:\\Work\\ConStart'",
contextFingerprint: "ctx_launch_otel",
executionContext: {
sourceId: "hwlab-v03-mdtodo",
sourceKind: "hwpod-workspace",
projectId: "project_hwlab_v03",
taskRef: "mdtodo:hwlab-v03-mdtodo:file_demo:t4_open",
fileRef: "file_demo",
taskId: "t4_open",
hwpodId: "constart-71freq-c",
nodeId: "D518",
mdtodoRootRef: "docs/MDTODO",
workspaceRootHash: "workspace-hash",
hwpodWorkspaceArgs: "--hwpod-id constart-71freq-c --workspace-path 'F:\\Work\\ConStart'",
contextFingerprint: "ctx_launch_otel",
valuesRedacted: true
},
valuesRedacted: true
},
valuesRedacted: true
};
}
+152 -8
View File
@@ -49,9 +49,19 @@ export async function handleWorkbenchLaunchHttp(request, response, options = {})
const sessionId = safeSessionId(params.sessionId) || `ses_${randomUUID()}`;
const traceId = safeTraceId(params.traceId) || null;
const providerProfile = safeToken(params.providerProfile ?? params.codeAgentProviderProfile, null);
const launchContext = normalizeLaunchContext(params.launchContext, { projectId, taskRef, now });
const contextStartedAt = Date.now();
attachWorkbenchLaunchOtel(request, { projectId, taskRef, sessionId, conversationId, traceId, stage: "launch_context", result: "started" });
const authoritativeContext = await readProjectManagementLaunchContext(request, { env, auth, projectId, taskRef });
if (!authoritativeContext.ok) {
attachWorkbenchLaunchOtel(request, { projectId, taskRef, sessionId, conversationId, traceId, stage: "launch_context", result: "failed", errorCode: authoritativeContext.code, upstreamStatus: authoritativeContext.status, statusCode: authoritativeContext.status });
emitWorkbenchLaunchOtelSpan(request, env, "launch_context", { projectId, taskRef, sessionId, conversationId, traceId, result: "failed", errorCode: authoritativeContext.code, upstreamStatus: authoritativeContext.status, statusCode: authoritativeContext.status }, { startTimeMs: contextStartedAt, status: "error", statusMessage: authoritativeContext.code });
return sendJson(response, authoritativeContext.status, launchError(authoritativeContext.code, authoritativeContext.message, { retryable: authoritativeContext.retryable }));
}
const launchContext = normalizeLaunchContext(params.launchContext, { projectId, taskRef, now, authoritative: authoritativeContext.launchContext });
const linkId = safeToken(params.linkId, stableLinkId({ projectId, taskRef, sessionId }));
const baseOtel = { projectId, taskRef, sessionId, conversationId, traceId, linkId };
const baseOtel = launchContextOtelFields({ projectId, taskRef, sessionId, conversationId, traceId, linkId, launchContext });
attachWorkbenchLaunchOtel(request, { ...baseOtel, stage: "launch_context", result: "ok", upstreamStatus: authoritativeContext.status, statusCode: 200 });
emitWorkbenchLaunchOtelSpan(request, env, "launch_context", { ...baseOtel, result: "ok", upstreamStatus: authoritativeContext.status, statusCode: 200 }, { startTimeMs: contextStartedAt });
attachWorkbenchLaunchOtel(request, { ...baseOtel, stage: "session_owner", result: "started" });
let session;
@@ -171,6 +181,10 @@ function attachWorkbenchLaunchOtel(request, fields = {}) {
"workbench.launch.project_id": safeAttr(fields.projectId, null),
"workbench.launch.task_ref": safeAttr(fields.taskRef, null),
"workbench.launch.task_ref_hash": fields.taskRef ? stableHash(fields.taskRef) : null,
"workbench.launch.source_id": safeAttr(fields.sourceId, null),
"workbench.launch.hwpod_id": safeAttr(fields.hwpodId, null),
"workbench.launch.workspace_root_hash": safeAttr(fields.workspaceRootHash, null),
"workbench.launch.context_fingerprint": safeAttr(fields.contextFingerprint, null),
"workbench.launch.session_id": safeAttr(fields.sessionId, null),
"workbench.launch.conversation_id": safeAttr(fields.conversationId, null),
"workbench.launch.link_id": safeAttr(fields.linkId, null),
@@ -210,6 +224,10 @@ function emitWorkbenchLaunchOtelSpan(request, env, stage, fields = {}, options =
"workbench.launch.project_id": safeAttr(fields.projectId, null),
"workbench.launch.task_ref": safeAttr(fields.taskRef, null),
"workbench.launch.task_ref_hash": fields.taskRef ? stableHash(fields.taskRef) : null,
"workbench.launch.source_id": safeAttr(fields.sourceId, null),
"workbench.launch.hwpod_id": safeAttr(fields.hwpodId, null),
"workbench.launch.workspace_root_hash": safeAttr(fields.workspaceRootHash, null),
"workbench.launch.context_fingerprint": safeAttr(fields.contextFingerprint, null),
"workbench.launch.session_id": safeAttr(fields.sessionId, null),
"workbench.launch.conversation_id": safeAttr(fields.conversationId, null),
"workbench.launch.link_id": safeAttr(fields.linkId, null),
@@ -260,23 +278,135 @@ async function writeProjectManagementLink(request, { env, auth, link }) {
}
}
function normalizeLaunchContext(value, { projectId, taskRef, now }) {
async function readProjectManagementLaunchContext(request, { env, auth, projectId, taskRef }) {
const baseUrl = String(env.HWLAB_PROJECT_MANAGEMENT_URL ?? "").trim();
if (!baseUrl) return { ok: false, status: 503, code: "project_management_service_unconfigured", message: "HWLAB_PROJECT_MANAGEMENT_URL is required to resolve MDTODO launch context.", retryable: true };
let upstreamUrl;
try {
upstreamUrl = new URL("/v1/project-management/mdtodo/launch-context", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
upstreamUrl.searchParams.set("taskRef", taskRef);
upstreamUrl.searchParams.set("projectId", projectId);
} catch {
return { ok: false, status: 503, code: "project_management_service_url_invalid", message: "HWLAB_PROJECT_MANAGEMENT_URL is invalid.", retryable: true };
}
const headers = new Headers({ accept: "application/json", "x-hwlab-bridge-service": "hwlab-cloud-api" });
for (const name of ["x-request-id", "traceparent", "tracestate"]) {
const value = getHeader(request, name);
if (value) headers.set(name, value);
}
if (auth.actor?.id) headers.set("x-hwlab-actor-id", auth.actor.id);
if (auth.actor?.role) headers.set("x-hwlab-actor-role", auth.actor.role);
headers.set("x-hwlab-auth-method", auth.authMethod ?? (auth.apiKey ? "api-key" : auth.session ? "web-session" : "unknown"));
try {
const upstream = await fetch(upstreamUrl, { method: "GET", headers, signal: AbortSignal.timeout(positiveInteger(env.HWLAB_PROJECT_MANAGEMENT_PROXY_TIMEOUT_MS, 30000)) });
const payload = await upstream.json().catch(() => null);
if (!upstream.ok) {
return { ok: false, status: upstream.status, code: payload?.error?.code ?? "project_management_launch_context_failed", message: payload?.error?.message ?? "Project management launch context resolution failed.", retryable: upstream.status >= 500 };
}
const launchContext = payload?.launchContext && typeof payload.launchContext === "object" ? payload.launchContext : payload?.executionContext;
if (!launchContext || typeof launchContext !== "object") return { ok: false, status: 502, code: "project_management_launch_context_empty", message: "Project management launch context response did not include launchContext.", retryable: true };
return { ok: true, status: upstream.status, launchContext };
} catch (error) {
return { ok: false, status: 502, code: "project_management_launch_context_proxy_failed", message: error?.message ?? "Project management launch context resolution failed.", retryable: true };
}
}
function normalizeLaunchContext(value, { projectId, taskRef, now, authoritative = null }) {
const input = value && typeof value === "object" && !Array.isArray(value) ? value : {};
const trusted = authoritative && typeof authoritative === "object" && !Array.isArray(authoritative) ? authoritative : {};
const executionContext = normalizeExecutionContext(trusted.executionContext ?? trusted.execution);
return {
source: "project-management",
projectId,
taskRef,
sourceId: safeToken(input.sourceId, null),
fileRef: safeToken(input.fileRef, null),
taskId: safeToken(input.taskId, null),
title: shortText(input.title, 220),
status: safeToken(input.status, null),
sourceId: safeToken(trusted.sourceId ?? input.sourceId, null),
sourceKind: safeToken(trusted.sourceKind ?? input.sourceKind, null),
fileRef: safeToken(trusted.fileRef ?? input.fileRef, null),
relativePath: safeRelativePath(trusted.relativePath ?? input.relativePath),
taskId: safeToken(trusted.taskId ?? input.taskId, null),
title: shortText(trusted.title ?? input.title, 220),
status: safeToken(trusted.status ?? input.status, null),
hwpodId: executionContext?.hwpodId ?? safeProjectOpaque(trusted.hwpodId ?? input.hwpodId),
nodeId: executionContext?.nodeId ?? safeProjectOpaque(trusted.nodeId ?? input.nodeId),
workspaceRootRef: executionContext?.workspaceRootRef ?? safeWorkspaceRef(trusted.workspaceRootRef ?? input.workspaceRootRef),
workspaceRootHash: executionContext?.workspaceRootHash ?? safeProjectOpaque(trusted.workspaceRootHash ?? input.workspaceRootHash),
workspaceRootLabel: executionContext?.workspaceRootLabel ?? shortText(trusted.workspaceRootLabel ?? input.workspaceRootLabel, 180),
mdtodoRootRef: executionContext?.mdtodoRootRef ?? safeRelativePath(trusted.mdtodoRootRef ?? input.mdtodoRootRef),
hwpodWorkspaceArgs: executionContext?.hwpodWorkspaceArgs ?? shortText(trusted.hwpodWorkspaceArgs ?? input.hwpodWorkspaceArgs, 500),
contextFingerprint: executionContext?.contextFingerprint ?? safeProjectOpaque(trusted.contextFingerprint ?? input.contextFingerprint),
capabilities: sanitizeCapabilities(trusted.capabilities ?? input.capabilities),
executionContext,
bodyPreview: shortText(input.bodyPreview, 800),
reportLinks: sanitizeReportLinks(input.reportLinks),
route: "/projects/mdtodo",
launchedAt: now,
valuesRedacted: true
};
}
function normalizeExecutionContext(value) {
const input = value && typeof value === "object" && !Array.isArray(value) ? value : null;
if (!input) return null;
return {
sourceId: safeToken(input.sourceId, null),
sourceKind: safeToken(input.sourceKind, null),
projectId: safeToken(input.projectId, null),
taskRef: safeProjectOpaque(input.taskRef),
fileRef: safeToken(input.fileRef, null),
relativePath: safeRelativePath(input.relativePath),
taskId: safeToken(input.taskId, null),
hwpodId: safeProjectOpaque(input.hwpodId),
nodeId: safeProjectOpaque(input.nodeId),
workspaceRootRef: safeWorkspaceRef(input.workspaceRootRef),
workspaceRootHash: safeProjectOpaque(input.workspaceRootHash),
workspaceRootLabel: shortText(input.workspaceRootLabel, 180),
mdtodoRootRef: safeRelativePath(input.mdtodoRootRef),
hwpodWorkspaceArgs: shortText(input.hwpodWorkspaceArgs, 500),
contextFingerprint: safeProjectOpaque(input.contextFingerprint),
capabilities: sanitizeCapabilities(input.capabilities),
valuesRedacted: true
};
}
function launchContextOtelFields({ projectId, taskRef, sessionId, conversationId, traceId, linkId, launchContext }) {
const execution = launchContext?.executionContext && typeof launchContext.executionContext === "object" ? launchContext.executionContext : launchContext;
return {
projectId,
taskRef,
sessionId,
conversationId,
traceId,
linkId,
sourceId: launchContext?.sourceId ?? null,
hwpodId: execution?.hwpodId ?? null,
workspaceRootHash: execution?.workspaceRootHash ?? null,
contextFingerprint: execution?.contextFingerprint ?? launchContext?.contextFingerprint ?? null
};
}
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 sanitizeReportLinks(value) {
if (!Array.isArray(value)) return [];
return value.slice(0, 12).map((item) => {
const input = item && typeof item === "object" ? item : {};
return {
linkId: safeToken(input.linkId, null),
label: shortText(input.label, 160),
href: shortText(input.href, 500),
relativePath: safeRelativePath(input.relativePath)
};
});
}
function launchError(code, message, extra = {}) {
return { ok: false, contractVersion, error: { code, message, status: extra.status ?? null, retryable: extra.retryable ?? false, sessionId: extra.sessionId ?? null, reason: extra.reason ?? null }, valuesRedacted: true };
}
@@ -304,6 +434,20 @@ function safeProjectOpaque(value) {
return /^[A-Za-z0-9_.:/#-]{1,360}$/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;
@@ -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;
@@ -352,6 +352,7 @@ async function projectManagementPayload(request: IncomingMessage, response: Serv
if (path === "/v1/project-management/projects") return json(response, 200, projectManagementEnvelope({ projects: [{ projectId: "project_hwlab_v03", name: "HWLAB MDTODO", sourceIds: [source.sourceId], sourceOfTruth: "markdown-files", status: "active" }] }));
if (path === "/v1/project-management/mdtodo/sources") return json(response, 200, projectManagementEnvelope({ sources: [source] }));
if (path === "/v1/project-management/mdtodo/files") return json(response, 200, projectManagementEnvelope({ files: files.filter((file) => !url.searchParams.get("sourceId") || file.sourceId === url.searchParams.get("sourceId")) }));
if (path === "/v1/project-management/mdtodo/launch-context") return projectManagementLaunchContext(response, url);
if (path === "/v1/project-management/mdtodo/task-detail") return projectManagementTaskDetail(response, url);
if (path === "/v1/project-management/mdtodo/report-preview") return projectManagementReportPreview(response, url);
if (path === "/v1/project-management/mdtodo/tasks") return json(response, 200, projectManagementEnvelope({ tasks: tasks.filter((task) => (!url.searchParams.get("sourceId") || task.sourceId === url.searchParams.get("sourceId")) && (!url.searchParams.get("fileRef") || task.fileRef === url.searchParams.get("fileRef"))) }));
@@ -364,7 +365,7 @@ function projectManagementEnvelope(payload: JsonRecord): JsonRecord {
}
function projectManagementSource(): JsonRecord {
return { sourceId: "hwlab-v03-mdtodo", kind: "mdtodo-file-tree", sourceKind: "hwpod-workspace", displayName: "HWLAB v0.3 MDTODO", projectId: "project_hwlab_v03", status: "active", hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", workspaceRootRef: "", mdtodoRootRef: "docs/MDTODO/", maxFiles: 120, valuesRedacted: true };
return { sourceId: "hwlab-v03-mdtodo", kind: "mdtodo-file-tree", sourceKind: "hwpod-workspace", displayName: "HWLAB v0.3 MDTODO", projectId: "project_hwlab_v03", status: "active", hwpodId: "constart-71freq-c", nodeId: "D518", workspaceRootRef: "F:\\Work\\ConStart", mdtodoRootRef: "docs/MDTODO/", maxFiles: 120, valuesRedacted: true };
}
function projectManagementSourceFromBody(body: JsonRecord, base: JsonRecord): JsonRecord {
@@ -374,8 +375,8 @@ function projectManagementSourceFromBody(body: JsonRecord, base: JsonRecord): Js
sourceKind: String(body.sourceKind ?? "hwpod-workspace"),
displayName: String(body.displayName ?? base.displayName ?? "HWLAB v0.3 MDTODO"),
projectId: String(body.projectId ?? base.projectId ?? "project_hwlab_v03"),
hwpodId: String(body.hwpodId ?? base.hwpodId ?? "d601-f103-v2"),
nodeId: String(body.nodeId ?? base.nodeId ?? "node-d601-f103-v2"),
hwpodId: String(body.hwpodId ?? base.hwpodId ?? ""),
nodeId: String(body.nodeId ?? base.nodeId ?? ""),
workspaceRootRef: String(body.workspaceRootRef ?? base.workspaceRootRef ?? ""),
mdtodoRootRef: String(body.mdtodoRootRef ?? base.mdtodoRootRef ?? "docs/MDTODO/"),
maxFiles: positiveInteger(body.maxFiles, Number(base.maxFiles ?? 120)),
@@ -467,6 +468,63 @@ function projectManagementReportPreview(response: ServerResponse, url: URL): voi
}));
}
function projectManagementLaunchContext(response: ServerResponse, url: URL): void {
const taskRef = url.searchParams.get("taskRef");
const payload = projectManagementLaunchContextForTask(taskRef);
if (!payload) return json(response, 404, { ok: false, contractVersion: "project-management-v1", serviceId: "hwlab-project-management", error: { code: "task_not_found" }, valuesRedacted: true });
return json(response, 200, projectManagementEnvelope(payload));
}
function projectManagementLaunchContextForTask(taskRef: string | null): JsonRecord | null {
const task = state.projectTasks.find((item) => item.taskRef === taskRef);
if (!task) return null;
const source = projectManagementSource();
const file = projectFileForTask(task);
const workspaceRootRef = String(source.workspaceRootRef ?? "");
const executionContext = {
sourceId: task.sourceId,
sourceKind: source.sourceKind,
projectId: task.projectId,
taskRef: task.taskRef,
fileRef: task.fileRef,
relativePath: file.relativePath,
taskId: task.taskId,
hwpodId: source.hwpodId,
nodeId: source.nodeId,
workspaceRootRef,
workspaceRootHash: "e2e-workspace-root-hash",
workspaceRootLabel: "ConStart",
mdtodoRootRef: source.mdtodoRootRef,
hwpodWorkspaceArgs: `--hwpod-id ${source.hwpodId} --workspace-path '${workspaceRootRef}'`,
contextFingerprint: `ctx_e2e_${String(task.taskId ?? "task").replace(/[^A-Za-z0-9]/gu, "_")}`,
capabilities: source.capabilities ?? {},
valuesRedacted: true
};
const launchContext = {
source: "project-management",
projectId: task.projectId,
taskRef: task.taskRef,
sourceId: task.sourceId,
sourceKind: source.sourceKind,
fileRef: task.fileRef,
relativePath: file.relativePath,
taskId: task.taskId,
title: task.title,
status: task.status,
hwpodId: executionContext.hwpodId,
nodeId: executionContext.nodeId,
mdtodoRootRef: executionContext.mdtodoRootRef,
hwpodWorkspaceArgs: executionContext.hwpodWorkspaceArgs,
workspaceRootHash: executionContext.workspaceRootHash,
workspaceRootLabel: executionContext.workspaceRootLabel,
contextFingerprint: executionContext.contextFingerprint,
executionContext,
route: "/projects/mdtodo",
valuesRedacted: true
};
return { task, source, executionContext, launchContext };
}
function projectManagementTaskLinks(task: JsonRecord, body: string): JsonRecord[] {
if (String(task.taskId) !== "R1") return [];
if (body.includes("20260609_161845_Task_Report.md")) {
@@ -633,7 +691,8 @@ async function createWorkbenchLaunch(request: IncomingMessage): Promise<JsonReco
const now = new Date().toISOString();
const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_project_launch_${Date.now().toString(36)}`;
const conversationId = typeof body.conversationId === "string" && body.conversationId.trim() ? body.conversationId : `cnv_project_launch_${Date.now().toString(36)}`;
const launchContext = body.launchContext && typeof body.launchContext === "object" ? body.launchContext as JsonRecord : {};
const authoritative = projectManagementLaunchContextForTask(typeof body.taskRef === "string" ? body.taskRef : null)?.launchContext;
const launchContext = authoritative ?? (body.launchContext && typeof body.launchContext === "object" ? body.launchContext as JsonRecord : {});
const session: SessionRecord = {
sessionId,
conversationId,
+1 -1
View File
@@ -14,7 +14,7 @@ export { apiKeysAPI } from "./apiKeys";
export { usageAPI } from "./usage";
export { billingAPI } from "./billing";
export { systemAPI, type SkillUploadFileInput } from "./system";
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
import { fetchJson } from "./client";
import { accessAPI } from "./access";
@@ -156,6 +156,26 @@ export interface MdtodoTaskDetailRecord {
valuesRedacted?: boolean;
}
export interface MdtodoExecutionContext {
sourceId?: string | null;
sourceKind?: string | null;
projectId?: string | null;
taskRef?: string | null;
fileRef?: string | null;
relativePath?: string | null;
taskId?: string | null;
hwpodId?: string | null;
nodeId?: string | null;
workspaceRootRef?: string | null;
workspaceRootHash?: string | null;
workspaceRootLabel?: string | null;
mdtodoRootRef?: string | null;
hwpodWorkspaceArgs?: string | null;
contextFingerprint?: string | null;
capabilities?: Record<string, unknown>;
valuesRedacted?: boolean;
}
export interface MdtodoReportPreviewRecord {
sourceId?: string;
relativePath?: string;
@@ -197,6 +217,7 @@ export interface MdtodoTaskPage {
}
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[]; page?: MdtodoTaskPage }
export interface MdtodoTaskDetailResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; detail?: MdtodoTaskDetailRecord; file?: MdtodoFileRecord }
export interface MdtodoLaunchContextResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; source?: Partial<ProjectSource>; executionContext?: MdtodoExecutionContext; launchContext?: Record<string, unknown> }
export interface MdtodoReportPreviewResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; link?: MdtodoTaskLinkRecord; report?: MdtodoReportPreviewRecord; file?: MdtodoFileRecord }
export interface MdtodoTaskMutationInput {
sourceId?: string;
@@ -246,6 +267,7 @@ export const projectManagementAPI = {
files: (sourceId?: string | null): Promise<ApiResult<MdtodoFileListResponse>> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }),
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId, parentTaskRef: query.parentTaskRef, status: query.status, search: query.search, limit: query.limit, offset: query.offset })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
taskDetail: (taskRef: string): Promise<ApiResult<MdtodoTaskDetailResponse>> => fetchJson(`/v1/project-management/mdtodo/task-detail${queryString({ taskRef })}`, { timeoutMs: 12000, timeoutName: "project mdtodo task detail" }),
launchContext: (taskRef: string): Promise<ApiResult<MdtodoLaunchContextResponse>> => fetchJson(`/v1/project-management/mdtodo/launch-context${queryString({ taskRef })}`, { timeoutMs: 12000, timeoutName: "project mdtodo launch context" }),
reportPreview: (taskRef: string, linkId: string): Promise<ApiResult<MdtodoReportPreviewResponse>> => fetchJson(`/v1/project-management/mdtodo/report-preview${queryString({ taskRef, linkId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo report preview" }),
updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task update" }),
createTask: (input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }),
+32
View File
@@ -48,15 +48,47 @@ export interface WorkbenchLaunchContext {
projectId: string;
taskRef: string;
sourceId?: string | null;
sourceKind?: string | null;
fileRef?: string | null;
relativePath?: string | null;
fileName?: string | null;
taskId?: string | null;
title?: string | null;
status?: string | null;
hwpodId?: string | null;
nodeId?: string | null;
workspaceRootRef?: string | null;
workspaceRootHash?: string | null;
workspaceRootLabel?: string | null;
mdtodoRootRef?: string | null;
hwpodWorkspaceArgs?: string | null;
contextFingerprint?: string | null;
capabilities?: Record<string, unknown>;
executionContext?: WorkbenchLaunchExecutionContext | null;
bodyPreview?: string | null;
reportLinks?: Array<{ linkId?: string; label?: string; href?: string; relativePath?: string | null }>;
}
export interface WorkbenchLaunchExecutionContext {
sourceId?: string | null;
sourceKind?: string | null;
projectId?: string | null;
taskRef?: string | null;
fileRef?: string | null;
relativePath?: string | null;
taskId?: string | null;
hwpodId?: string | null;
nodeId?: string | null;
workspaceRootRef?: string | null;
workspaceRootHash?: string | null;
workspaceRootLabel?: string | null;
mdtodoRootRef?: string | null;
hwpodWorkspaceArgs?: string | null;
contextFingerprint?: string | null;
capabilities?: Record<string, unknown>;
valuesRedacted?: boolean;
}
export interface WorkbenchLaunchRequest {
projectId: string;
taskRef: string;
@@ -34,12 +34,12 @@ export function useMdtodoSource() {
const sources = ref<ProjectSource[]>([]);
const navigation = ref<ProjectNavigationResponse | null>(null);
const sourceForm = reactive<ProjectSourceInput>({
sourceId: "d601-f103-v2-mdtodo",
sourceId: "",
sourceKind: "hwpod-workspace",
displayName: "D601 F103 MDTODO",
displayName: "",
projectId: "project_hwlab_v03",
hwpodId: "d601-f103-v2",
nodeId: "node-d601-f103-v2",
hwpodId: "",
nodeId: "",
workspaceRootRef: "",
mdtodoRootRef: "docs/MDTODO/",
maxFiles: 120
@@ -63,12 +63,12 @@ export function useMdtodoSource() {
sourceMessage.value = null;
sourceError.value = null;
Object.assign(sourceForm, {
sourceId: source?.sourceKind === "hwpod-workspace" ? source.sourceId : "d601-f103-v2-mdtodo",
sourceId: source?.sourceKind === "hwpod-workspace" ? source.sourceId : "",
sourceKind: "hwpod-workspace",
displayName: source?.sourceKind === "hwpod-workspace" ? source.displayName : "D601 F103 MDTODO",
displayName: source?.sourceKind === "hwpod-workspace" ? source.displayName : "",
projectId: source?.projectId || "project_hwlab_v03",
hwpodId: source?.hwpodId || "d601-f103-v2",
nodeId: source?.nodeId || "node-d601-f103-v2",
hwpodId: source?.hwpodId || "",
nodeId: source?.nodeId || "",
workspaceRootRef: source?.workspaceRootRef || "",
mdtodoRootRef: source?.mdtodoRootRef || "docs/MDTODO/",
maxFiles: source?.maxFiles || 120
@@ -10,15 +10,47 @@ export interface WorkbenchLaunchContext {
projectId: string;
taskRef: string;
sourceId?: string;
sourceKind?: string | null;
fileRef?: string;
relativePath?: string | null;
fileName?: string;
taskId?: string;
title?: string;
status?: string;
hwpodId?: string | null;
nodeId?: string | null;
workspaceRootRef?: string | null;
workspaceRootHash?: string | null;
workspaceRootLabel?: string | null;
mdtodoRootRef?: string | null;
hwpodWorkspaceArgs?: string | null;
contextFingerprint?: string | null;
capabilities?: Record<string, unknown>;
executionContext?: WorkbenchLaunchExecutionContext | null;
bodyPreview: string;
reportLinks: { linkId?: string; label?: string; href?: string; relativePath?: string | null }[];
}
export interface WorkbenchLaunchExecutionContext {
sourceId?: string | null;
sourceKind?: string | null;
projectId?: string | null;
taskRef?: string | null;
fileRef?: string | null;
relativePath?: string | null;
taskId?: string | null;
hwpodId?: string | null;
nodeId?: string | null;
workspaceRootRef?: string | null;
workspaceRootHash?: string | null;
workspaceRootLabel?: string | null;
mdtodoRootRef?: string | null;
hwpodWorkspaceArgs?: string | null;
contextFingerprint?: string | null;
capabilities?: Record<string, unknown>;
valuesRedacted?: boolean;
}
/**
* Launches a Workbench session through the public Workbench Launch API and seeds
* it with a first-round user message built from the MDTODO task context. An empty
@@ -31,11 +63,24 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
const launchError = ref<string | null>(null);
const launchResult = ref<{ sessionId: string; workbenchUrl: string } | null>(null);
function buildPrompt(task: MdtodoTaskRecord, body: string, links: MdtodoTaskLinkRecord[], fileName: string): string {
function buildPrompt(task: MdtodoTaskRecord, body: string, links: MdtodoTaskLinkRecord[], fileName: string, launchContext?: WorkbenchLaunchContext | null): string {
const reportLines = links
.filter((link) => link.kind === "markdown-report")
.map((link) => `- ${link.label || link.href}: ${link.relativePath || link.href}`)
.join("\n");
const execution = launchContext?.executionContext ?? launchContext ?? null;
const hwpodLines = execution?.hwpodId && execution?.hwpodWorkspaceArgs
? [
"",
"HWPOD 执行上下文:",
`sourceId: ${execution.sourceId || launchContext?.sourceId || "-"}`,
`hwpodId: ${execution.hwpodId}`,
`nodeId: ${execution.nodeId || "-"}`,
`mdtodoRootRef: ${execution.mdtodoRootRef || "docs/MDTODO"}`,
`hwpodWorkspaceArgs: ${execution.hwpodWorkspaceArgs}`,
"所有 hwpod/hwpod-ctl 命令都必须携带 hwpodWorkspaceArgs。读取 MDTODO、报告和工程文件必须使用 HWPOD workspace/node-ops 入口,不得猜本地容器路径或创建本地 .hwlab/hwpod-spec.yaml fallback。"
]
: [];
return [
"请基于以下 MDTODO 任务开始工作,先读取相关文件和报告,再给出下一步执行结果。",
"",
@@ -48,7 +93,8 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
body.trim() || "(无正文)",
"",
"相关报告链接:",
reportLines || "(无)"
reportLines || "(无)",
...hwpodLines
].join("\n");
}
@@ -101,7 +147,6 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
launchLoading.value = true;
launchError.value = null;
try {
const prompt = buildPrompt(task, body, links, fileName);
const launchContext = buildContext(task, body, links, fileName);
const response = await workbenchAPI.launch({
projectId: task.projectId,
@@ -112,6 +157,8 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
if (!response.ok) throw response;
const sessionId = response.data?.sessionId;
if (!sessionId) throw new Error("Workbench launch 未返回 sessionId");
const authoritativeLaunchContext = (response.data?.launchContext as WorkbenchLaunchContext | null | undefined) ?? launchContext;
const prompt = buildPrompt(task, body, links, fileName, authoritativeLaunchContext);
const chat = await agentAPI.sendAgentMessage({
message: prompt,
prompt,
@@ -119,7 +166,7 @@ export function useMdtodoWorkbenchLaunch(router: Router) {
providerProfile: "codex",
projectId: task.projectId,
taskRef: task.taskRef,
launchContext
launchContext: authoritativeLaunchContext
}, 120000);
if (!chat.ok) throw chat;
await waitSessionHasPrompt(sessionId);
+1 -1
View File
@@ -1845,7 +1845,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
installRealtimeVisibilityHandler();
installWorkbenchProjectionSignalHandler();
return { sessions, messages, activeMessages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, activeSessionId, activeSessionSelectionSource, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
return { sessions, messages, activeMessages, activeSession, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, sessionDetailLoading, sessionListLoadedCount, sessionListHasMore, sessionListNextCursor, sessionListLoadingMore, sessionListLoadMoreError, chatPending, error, activeSessionId, activeSessionSelectionSource, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectSession, selectSessionById, deleteCurrentSession, refreshSessions, loadMoreSessions, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearSessionMessages, recordActivity };
});
function workbenchSessionsFromPayload(payload: unknown): WorkbenchSessionRecord[] {
@@ -663,6 +663,30 @@
overflow: hidden;
}
.workbench-center.has-launch-context {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.workbench-context-strip {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-bottom: 1px solid #d8e1eb;
color: #334155;
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
}
.workbench-context-strip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.session-rail,
.hwpod-panel,
.caserun-panel {
@@ -23,6 +23,27 @@ const rawRouteSessionId = computed(() => typeof route.params.sessionId === "stri
const routeReflectionTarget = computed(() => routeRequestId.value ?? (rawRouteSessionId.value || null));
const applyingRouteSession = ref(Boolean(rawRouteSessionId.value));
const componentActive = ref(false);
type LaunchContextRecord = {
sourceId?: string | null;
hwpodId?: string | null;
mdtodoRootRef?: string | null;
workspaceRootLabel?: string | null;
executionContext?: LaunchContextRecord | null;
};
const workbenchLaunchContext = computed(() => {
const context = (workbench.activeSession?.launchContext ?? null) as LaunchContextRecord | null;
if (!context) return null;
const execution = context.executionContext ?? context;
const sourceId = execution.sourceId ?? context.sourceId ?? null;
const hwpodId = execution.hwpodId ?? context.hwpodId ?? null;
if (!sourceId && !hwpodId) return null;
return {
sourceId,
hwpodId,
mdtodoRootRef: execution.mdtodoRootRef ?? context.mdtodoRootRef ?? null,
workspaceRootLabel: execution.workspaceRootLabel ?? context.workspaceRootLabel ?? null
};
});
onMounted(async () => {
componentActive.value = true;
@@ -89,7 +110,13 @@ async function reflectActiveSessionInUrl(value: string | null): Promise<void> {
<section id="workspace" class="workbench-route">
<div class="workbench-grid">
<SessionRail />
<main class="workbench-center">
<main class="workbench-center" :class="{ 'has-launch-context': workbenchLaunchContext }">
<div v-if="workbenchLaunchContext" class="workbench-context-strip" data-testid="workbench-launch-context">
<span>Source: {{ workbenchLaunchContext.sourceId || '-' }}</span>
<span v-if="workbenchLaunchContext.hwpodId">HWPOD: {{ workbenchLaunchContext.hwpodId }}</span>
<span v-if="workbenchLaunchContext.mdtodoRootRef">MDTODO: {{ workbenchLaunchContext.mdtodoRootRef }}</span>
<span v-if="workbenchLaunchContext.workspaceRootLabel">Workspace: {{ workbenchLaunchContext.workspaceRootLabel }}</span>
</div>
<ConversationPanel />
<CommandComposer />
</main>