diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 98a421dd..ad46eae4 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -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,或在真实空集合时显示空态。 diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 10eaf152..0e4f265b 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -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), diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 3132172c..8f930a78 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -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 []; diff --git a/internal/cloud/server-workbench-launch-http.test.ts b/internal/cloud/server-workbench-launch-http.test.ts index 49767765..4d59fc05 100644 --- a/internal/cloud/server-workbench-launch-http.test.ts +++ b/internal/cloud/server-workbench-launch-http.test.ts @@ -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 + }; +} diff --git a/internal/cloud/server-workbench-launch-http.ts b/internal/cloud/server-workbench-launch-http.ts index 66336a9b..cf213fd7 100644 --- a/internal/cloud/server-workbench-launch-http.ts +++ b/internal/cloud/server-workbench-launch-http.ts @@ -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; diff --git a/internal/project-management/server.test.ts b/internal/project-management/server.test.ts index d4c6cf89..eae0c1e2 100644 --- a/internal/project-management/server.test.ts +++ b/internal/project-management/server.test.ts @@ -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", diff --git a/internal/project-management/server.ts b/internal/project-management/server.ts index 106566d3..93d61313 100644 --- a/internal/project-management/server.ts +++ b/internal/project-management/server.ts @@ -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; diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 92abcf94..94b0e31c 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -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; + 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; executionContext?: MdtodoExecutionContext; launchContext?: Record } 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> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }), tasks: (query: MdtodoTaskQuery = {}): Promise> => 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> => fetchJson(`/v1/project-management/mdtodo/task-detail${queryString({ taskRef })}`, { timeoutMs: 12000, timeoutName: "project mdtodo task detail" }), + launchContext: (taskRef: string): Promise> => fetchJson(`/v1/project-management/mdtodo/launch-context${queryString({ taskRef })}`, { timeoutMs: 12000, timeoutName: "project mdtodo launch context" }), reportPreview: (taskRef: string, linkId: string): Promise> => fetchJson(`/v1/project-management/mdtodo/report-preview${queryString({ taskRef, linkId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo report preview" }), updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise> => 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> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }), diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 06fdeecf..6b5a78f2 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -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; + 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; + valuesRedacted?: boolean; +} + export interface WorkbenchLaunchRequest { projectId: string; taskRef: string; diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoSource.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoSource.ts index d1dfd85a..7ce6693d 100644 --- a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoSource.ts +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoSource.ts @@ -34,12 +34,12 @@ export function useMdtodoSource() { const sources = ref([]); const navigation = ref(null); const sourceForm = reactive({ - 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 diff --git a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkbenchLaunch.ts b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkbenchLaunch.ts index 470bdb62..997645f7 100644 --- a/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkbenchLaunch.ts +++ b/web/hwlab-cloud-web/src/composables/mdtodo/useMdtodoWorkbenchLaunch.ts @@ -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; + 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; + 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(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); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index b085c719..1d790382 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -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[] { diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 79c32b2f..8bd6a70e 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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 { diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue index cb696e87..614e39b2 100644 --- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue +++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue @@ -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 {
-
+
+
+ Source: {{ workbenchLaunchContext.sourceId || '-' }} + HWPOD: {{ workbenchLaunchContext.hwpodId }} + MDTODO: {{ workbenchLaunchContext.mdtodoRootRef }} + Workspace: {{ workbenchLaunchContext.workspaceRootLabel }} +