/* * SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-project-management-mdtodo; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-010403 API契约 draft-2026-06-18-r1. * 职责: Workbench 公共 launch API,用稳定 project/task 元数据创建 Workbench session 并写入项目管理 link 投影。 */ import { createHash, randomUUID } from "node:crypto"; import { getHeader, readBody, safeConversationId, safeOpaqueId, safeSessionId, safeTraceId, sendJson } from "./server-http-utils.ts"; import { writeWorkbenchSessionAdmissionFact } from "./workbench-projection-writer.ts"; const contractVersion = "workbench-launch-v1"; const defaultProjectId = "project_hwlab_v03"; export async function handleWorkbenchLaunchHttp(request, response, options = {}) { if (request.method !== "POST") return sendJson(response, 405, launchError("method_not_allowed", "POST required.")); const auth = await options.accessController?.authenticate?.(request, { required: true }); if (!auth?.ok) return sendJson(response, auth?.status ?? 401, auth ?? launchError("auth_required", "Authentication is required.")); const body = await readJsonObject(request, options.bodyLimitBytes); if (!body.ok) return sendJson(response, 400, launchError(body.code, body.message, { reason: body.reason })); const params = body.value; const projectId = safeToken(params.projectId, defaultProjectId); const taskRef = safeProjectOpaque(params.taskRef); if (!taskRef) return sendJson(response, 400, launchError("task_ref_required", "Workbench launch requires taskRef.")); const now = new Date().toISOString(); const conversationId = safeConversationId(params.conversationId) || `cnv_${randomUUID()}`; 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 linkId = safeToken(params.linkId, stableLinkId({ projectId, taskRef, sessionId })); const session = await options.accessController.recordAgentSessionOwner({ ownerUserId: auth.actor?.id, ownerRole: auth.actor?.role, sessionId, projectId, agentId: "hwlab-code-agent", status: "idle", conversationId, threadId: safeOpaqueId(params.threadId), traceId, session: { source: "workbench-launch", providerProfile, sessionStatus: "idle", launchContext, createdAt: now, valuesRedacted: true, secretMaterialStored: false } }); await writeWorkbenchSessionAdmissionFact({ runtimeStore: options.runtimeStore, session, ownerUserId: auth.actor?.id, ownerRole: auth.actor?.role, projectId, conversationId, threadId: safeOpaqueId(params.threadId), status: "idle", now }); const workbenchUrl = `/workbench/sessions/${encodeURIComponent(session?.id ?? sessionId)}`; const linkWrite = await writeProjectManagementLink(request, { env: options.env ?? process.env, auth, link: { linkId, projectId, taskRef, sessionId: session?.id ?? sessionId, traceId, role: "launch", createdBy: auth.actor?.id ?? null, link: { launchContext, workbenchUrl, valuesRedacted: true } } }); if (!linkWrite.ok) return sendJson(response, linkWrite.status, launchError(linkWrite.code, linkWrite.message, { sessionId: session?.id ?? sessionId, retryable: true })); return sendJson(response, 201, { ok: true, status: "created", contractVersion, sessionId: session?.id ?? sessionId, conversationId, projectId, taskRef, workbenchUrl, linkId: linkWrite.link?.linkId ?? linkId, link: linkWrite.link ?? null, launchContext, valuesRedacted: true, secretMaterialStored: false }); } async function readJsonObject(request, bodyLimitBytes) { const body = await readBody(request, bodyLimitBytes); try { const value = body ? JSON.parse(body) : {}; if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false, code: "invalid_params", message: "body must be a JSON object" }; return { ok: true, value }; } catch (error) { return { ok: false, code: "parse_error", message: "Invalid JSON body", reason: error?.message ?? String(error) }; } } async function writeProjectManagementLink(request, { env, auth, link }) { 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 write Workbench link projection." }; let upstreamUrl; try { upstreamUrl = new URL("/v1/project-management/workbench-links", baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); } catch { return { ok: false, status: 503, code: "project_management_service_url_invalid", message: "HWLAB_PROJECT_MANAGEMENT_URL is invalid." }; } const headers = new Headers({ accept: "application/json", "content-type": "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: "POST", headers, body: JSON.stringify(link), 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_link_write_failed", message: payload?.error?.message ?? "Project management link write failed." }; return { ok: true, status: upstream.status, link: payload?.link ?? null }; } catch (error) { return { ok: false, status: 502, code: "project_management_link_proxy_failed", message: error?.message ?? "Project management link write failed." }; } } function normalizeLaunchContext(value, { projectId, taskRef, now }) { const input = value && typeof value === "object" && !Array.isArray(value) ? value : {}; 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), route: "/projects/mdtodo", launchedAt: now, valuesRedacted: true }; } 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 }; } function stableLinkId({ projectId, taskRef, sessionId }) { return `lnk_${createHash("sha256").update(`${projectId}\n${taskRef}\n${sessionId}`).digest("hex").slice(0, 24)}`; } function safeToken(value, fallback) { const text = String(value ?? "").trim(); return /^[A-Za-z0-9_.:-]{1,160}$/u.test(text) ? text : fallback; } function safeProjectOpaque(value) { const text = String(value ?? "").trim(); return /^[A-Za-z0-9_.:/#-]{1,360}$/u.test(text) ? text : null; } function shortText(value, max) { const text = String(value ?? "").replace(/\s+/gu, " ").trim(); return text ? text.slice(0, max) : null; } function positiveInteger(value, fallback) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : fallback; }