fix: carry MDTODO HWPOD context into Workbench
This commit is contained in:
@@ -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,或在真实空集合时显示空态。
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user