316 lines
17 KiB
TypeScript
316 lines
17 KiB
TypeScript
/*
|
|
* 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 { emitHttpServerRequestSpan, newOtelSpanId } from "./otel-trace.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 = {}) {
|
|
const env = options.env ?? process.env;
|
|
attachWorkbenchLaunchOtel(request, { stage: "start", result: "started" });
|
|
if (request.method !== "POST") {
|
|
attachWorkbenchLaunchOtel(request, { stage: "method", result: "failed", errorCode: "method_not_allowed", statusCode: 405 });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "method", { result: "failed", errorCode: "method_not_allowed", statusCode: 405 }, { status: "error" });
|
|
return sendJson(response, 405, launchError("method_not_allowed", "POST required."));
|
|
}
|
|
const auth = await options.accessController?.authenticate?.(request, { required: true });
|
|
if (!auth?.ok) {
|
|
const statusCode = auth?.status ?? 401;
|
|
const errorCode = auth?.error?.code ?? auth?.code ?? "auth_required";
|
|
attachWorkbenchLaunchOtel(request, { stage: "auth", result: "failed", errorCode, statusCode });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "auth", { result: "failed", errorCode, statusCode }, { status: "error" });
|
|
return sendJson(response, statusCode, auth ?? launchError("auth_required", "Authentication is required."));
|
|
}
|
|
|
|
const body = await readJsonObject(request, options.bodyLimitBytes);
|
|
if (!body.ok) {
|
|
attachWorkbenchLaunchOtel(request, { stage: "parse_body", result: "failed", errorCode: body.code, statusCode: 400 });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "parse_body", { result: "failed", errorCode: body.code, statusCode: 400 }, { status: "error" });
|
|
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) {
|
|
attachWorkbenchLaunchOtel(request, { stage: "validate", result: "failed", projectId, errorCode: "task_ref_required", statusCode: 400 });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "validate", { result: "failed", projectId, errorCode: "task_ref_required", statusCode: 400 }, { status: "error" });
|
|
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 baseOtel = { projectId, taskRef, sessionId, conversationId, traceId, linkId };
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, stage: "session_owner", result: "started" });
|
|
|
|
let session;
|
|
const sessionOwnerStartedAt = Date.now();
|
|
try {
|
|
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
|
|
}
|
|
});
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, stage: "session_owner", result: "ok", ownerUserId: auth.actor?.id ?? null, sessionId: session?.id ?? sessionId });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "session_owner", { ...baseOtel, sessionId: session?.id ?? sessionId, result: "ok", ownerUserId: auth.actor?.id ?? null, statusCode: 200 }, { startTimeMs: sessionOwnerStartedAt });
|
|
} catch (error) {
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, stage: "session_owner", result: "failed", errorCode: "workbench_launch_session_owner_failed", statusCode: 500 });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "session_owner", { ...baseOtel, result: "failed", errorCode: "workbench_launch_session_owner_failed", statusCode: 500 }, { startTimeMs: sessionOwnerStartedAt, status: "error", error });
|
|
throw error;
|
|
}
|
|
|
|
const admittedSessionId = session?.id ?? sessionId;
|
|
const admissionStartedAt = Date.now();
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, sessionId: admittedSessionId, stage: "admission_fact", result: "started" });
|
|
try {
|
|
const admissionWrite = await writeWorkbenchSessionAdmissionFact({
|
|
runtimeStore: options.runtimeStore,
|
|
session,
|
|
ownerUserId: auth.actor?.id,
|
|
ownerRole: auth.actor?.role,
|
|
projectId,
|
|
conversationId,
|
|
threadId: safeOpaqueId(params.threadId),
|
|
status: "idle",
|
|
now
|
|
});
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, sessionId: admittedSessionId, stage: "admission_fact", result: "ok", admissionFactWritten: Boolean(admissionWrite) });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "admission_fact", { ...baseOtel, sessionId: admittedSessionId, result: "ok", admissionFactWritten: Boolean(admissionWrite), statusCode: 200 }, { startTimeMs: admissionStartedAt });
|
|
} catch (error) {
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, sessionId: admittedSessionId, stage: "admission_fact", result: "failed", errorCode: "workbench_launch_admission_fact_failed", statusCode: 500 });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "admission_fact", { ...baseOtel, sessionId: admittedSessionId, result: "failed", errorCode: "workbench_launch_admission_fact_failed", statusCode: 500 }, { startTimeMs: admissionStartedAt, status: "error", error });
|
|
throw error;
|
|
}
|
|
|
|
const workbenchUrl = `/workbench/sessions/${encodeURIComponent(admittedSessionId)}`;
|
|
const linkWriteStartedAt = Date.now();
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, sessionId: admittedSessionId, stage: "project_link_write", result: "started" });
|
|
const linkWrite = await writeProjectManagementLink(request, {
|
|
env,
|
|
auth,
|
|
link: {
|
|
linkId,
|
|
projectId,
|
|
taskRef,
|
|
sessionId: admittedSessionId,
|
|
traceId,
|
|
role: "launch",
|
|
createdBy: auth.actor?.id ?? null,
|
|
link: { launchContext, workbenchUrl, valuesRedacted: true }
|
|
}
|
|
});
|
|
if (!linkWrite.ok) {
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, sessionId: admittedSessionId, stage: "project_link_write", result: "failed", errorCode: linkWrite.code, upstreamStatus: linkWrite.status, statusCode: linkWrite.status });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "project_link_write", { ...baseOtel, sessionId: admittedSessionId, result: "failed", errorCode: linkWrite.code, upstreamStatus: linkWrite.status, statusCode: linkWrite.status }, { startTimeMs: linkWriteStartedAt, status: "error", statusMessage: linkWrite.code });
|
|
return sendJson(response, linkWrite.status, launchError(linkWrite.code, linkWrite.message, { sessionId: admittedSessionId, retryable: true }));
|
|
}
|
|
attachWorkbenchLaunchOtel(request, { ...baseOtel, sessionId: admittedSessionId, linkId: linkWrite.link?.linkId ?? linkId, stage: "completed", result: "created", upstreamStatus: linkWrite.status, statusCode: 201 });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "project_link_write", { ...baseOtel, sessionId: admittedSessionId, linkId: linkWrite.link?.linkId ?? linkId, result: "ok", upstreamStatus: linkWrite.status, statusCode: linkWrite.status }, { startTimeMs: linkWriteStartedAt });
|
|
emitWorkbenchLaunchOtelSpan(request, env, "completed", { ...baseOtel, sessionId: admittedSessionId, linkId: linkWrite.link?.linkId ?? linkId, result: "created", upstreamStatus: linkWrite.status, statusCode: 201 });
|
|
|
|
return sendJson(response, 201, {
|
|
ok: true,
|
|
status: "created",
|
|
contractVersion,
|
|
sessionId: admittedSessionId,
|
|
conversationId,
|
|
projectId,
|
|
taskRef,
|
|
workbenchUrl,
|
|
linkId: linkWrite.link?.linkId ?? linkId,
|
|
link: linkWrite.link ?? null,
|
|
launchContext,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
});
|
|
}
|
|
|
|
function attachWorkbenchLaunchOtel(request, fields = {}) {
|
|
const context = request?.hwlabHttpRequestContext;
|
|
if (!context) return;
|
|
const errorCode = safeAttr(fields.errorCode, null);
|
|
context.route = "/v1/workbench/launches";
|
|
if (errorCode) {
|
|
context.lastHttpError = {
|
|
code: errorCode,
|
|
category: "workbench_launch",
|
|
layer: `workbench.launch.${safeAttr(fields.stage, "unknown")}`
|
|
};
|
|
}
|
|
context.otelAttributes = {
|
|
...(context.otelAttributes ?? {}),
|
|
"workbench.route": "launches",
|
|
"workbench.launch.stage": safeAttr(fields.stage, null),
|
|
"workbench.launch.result": safeAttr(fields.result, null),
|
|
"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.session_id": safeAttr(fields.sessionId, null),
|
|
"workbench.launch.conversation_id": safeAttr(fields.conversationId, null),
|
|
"workbench.launch.link_id": safeAttr(fields.linkId, null),
|
|
"workbench.launch.trace_id": safeAttr(fields.traceId, null),
|
|
"workbench.launch.owner_user_id": safeAttr(fields.ownerUserId, null),
|
|
"workbench.launch.admission_fact_written": typeof fields.admissionFactWritten === "boolean" ? fields.admissionFactWritten : null,
|
|
"workbench.launch.upstream_status": Number.isFinite(Number(fields.upstreamStatus)) ? Math.trunc(Number(fields.upstreamStatus)) : null,
|
|
"workbench.launch.values_redacted": true,
|
|
...(errorCode ? { "error.code": errorCode, "error.category": "workbench_launch", "error.layer": `workbench.launch.${safeAttr(fields.stage, "unknown")}` } : {})
|
|
};
|
|
}
|
|
|
|
function emitWorkbenchLaunchOtelSpan(request, env, stage, fields = {}, options = {}) {
|
|
const context = request?.hwlabHttpRequestContext;
|
|
if (!context?.traceId) return;
|
|
const statusCode = Number.isFinite(Number(fields.statusCode)) ? Math.trunc(Number(fields.statusCode)) : (options.status === "error" ? 500 : 200);
|
|
void emitHttpServerRequestSpan(context, env, {
|
|
name: `workbench.launch.${stage}`,
|
|
spanId: newOtelSpanId(),
|
|
parentSpanId: context.spanId,
|
|
startTimeMs: options.startTimeMs,
|
|
endTimeMs: Date.now(),
|
|
statusCode,
|
|
method: "POST",
|
|
route: "/v1/workbench/launches",
|
|
status: options.status ?? (statusCode >= 400 ? "error" : "ok"),
|
|
statusMessage: options.statusMessage ?? safeAttr(fields.errorCode, null),
|
|
error: options.error,
|
|
errorCode: safeAttr(fields.errorCode, null),
|
|
errorCategory: fields.errorCode ? "workbench_launch" : null,
|
|
errorLayer: fields.errorCode ? `workbench.launch.${stage}` : null,
|
|
attributes: {
|
|
...(context.otelAttributes ?? {}),
|
|
"workbench.launch.span": stage,
|
|
"workbench.launch.stage": stage,
|
|
"workbench.launch.result": safeAttr(fields.result, null),
|
|
"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.session_id": safeAttr(fields.sessionId, null),
|
|
"workbench.launch.conversation_id": safeAttr(fields.conversationId, null),
|
|
"workbench.launch.link_id": safeAttr(fields.linkId, null),
|
|
"workbench.launch.trace_id": safeAttr(fields.traceId, null),
|
|
"workbench.launch.owner_user_id": safeAttr(fields.ownerUserId, null),
|
|
"workbench.launch.admission_fact_written": typeof fields.admissionFactWritten === "boolean" ? fields.admissionFactWritten : null,
|
|
"workbench.launch.upstream_status": Number.isFinite(Number(fields.upstreamStatus)) ? Math.trunc(Number(fields.upstreamStatus)) : null,
|
|
"workbench.launch.values_redacted": true
|
|
}
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
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 stableHash(value) {
|
|
return createHash("sha256").update(String(value ?? "")).digest("hex").slice(0, 24);
|
|
}
|
|
|
|
function safeAttr(value, fallback) {
|
|
const text = String(value ?? "").trim();
|
|
return text ? text.slice(0, 360) : fallback;
|
|
}
|
|
|
|
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;
|
|
}
|