diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 31638963..b20207fc 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -15,6 +15,7 @@ import { } from "./server-http-utils.ts"; import { createWorkbenchReadModel } from "./workbench-read-model.ts"; import { createWorkbenchRuntimeClient } from "./workbench-runtime-client.ts"; +import { buildWorkbenchSessionDetail, compactLaunchContext, includeMessagesForSessionDetail } from "./workbench-session-detail-response.ts"; import { durableTraceStatus, RUNNING_STATUSES, terminalFinalResponse, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; import { emitCodeAgentOtelSpan, emitHttpServerRequestSpan } from "./otel-trace.ts"; @@ -1018,87 +1019,13 @@ function factSessionSummary(session, facts = {}) { function factSessionDetail(session, facts = {}, options = {}) { const sessionId = factSessionId(session); - const detail = { - ...factSessionSummary(session, facts), - metadata: { - startedAt: session?.startedAt ?? session?.createdAt ?? null, - endedAt: session?.endedAt ?? null, - ownerUserId: session?.ownerUserId ?? null, - launchContext: compactLaunchContext(session?.sessionJson?.launchContext) - }, - messagePageUrl: sessionId ? `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages` : null, - valuesRedacted: true, - secretMaterialStored: false - }; - return options.includeMessages === true ? { ...detail, messages: factMessagesForSession(session, facts) } : detail; -} - -function compactLaunchContext(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - 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; + return buildWorkbenchSessionDetail({ + summary: factSessionSummary(session, facts), + session, + sessionId, + includeMessages: options.includeMessages === true, + messages: factMessagesForSession(session, facts) + }); } function factMessagesForSession(session, facts = {}) { @@ -2931,17 +2858,6 @@ function boundedSessionListLimit(value) { return Math.min(MAX_PAGE_LIMIT, parsePositiveInteger(value, DEFAULT_SESSION_LIST_LIMIT)); } -function includeMessagesForSessionDetail(params) { - if (params.get("includeMessages") !== null) return truthyQueryFlag(params.get("includeMessages")); - if (params.get("messages") !== null) return truthyQueryFlag(params.get("messages")); - return false; -} - -function truthyQueryFlag(value) { - const normalized = textValue(value).toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "yes"; -} - function cursorOffset(value) { const text = textValue(value); if (!text) return 0; diff --git a/internal/cloud/workbench-session-detail-response.ts b/internal/cloud/workbench-session-detail-response.ts new file mode 100644 index 00000000..2b96c35b --- /dev/null +++ b/internal/cloud/workbench-session-detail-response.ts @@ -0,0 +1,120 @@ +/* + * SPEC: pikasTech/HWLAB#2359 Workbench session detail metadata-only contract. + * Responsibility: Build Workbench session detail payloads and parse detail payload mode flags. + */ + +export function includeMessagesForSessionDetail(params: URLSearchParams): boolean { + if (params.get("includeMessages") !== null) return truthyQueryFlag(params.get("includeMessages")); + if (params.get("messages") !== null) return truthyQueryFlag(params.get("messages")); + return false; +} + +export function buildWorkbenchSessionDetail(input: { + summary: Record; + session: Record | null | undefined; + sessionId: string | null | undefined; + includeMessages: boolean; + messages: unknown[]; +}): Record { + const detail = { + ...input.summary, + metadata: { + startedAt: input.session?.startedAt ?? input.session?.createdAt ?? null, + endedAt: input.session?.endedAt ?? null, + ownerUserId: input.session?.ownerUserId ?? null, + launchContext: compactLaunchContext(sessionJson(input.session)?.launchContext) + }, + messagePageUrl: input.sessionId ? `/v1/workbench/sessions/${encodeURIComponent(input.sessionId)}/messages` : null, + valuesRedacted: true, + secretMaterialStored: false + }; + return input.includeMessages ? { ...detail, messages: input.messages } : detail; +} + +export function compactLaunchContext(value: unknown): Record | null { + const record = objectRecord(value); + if (!record) return null; + const projectId = textValue(record.projectId) || null; + const taskRef = textValue(record.taskRef) || null; + if (!projectId && !taskRef) return null; + const execution = compactLaunchExecutionContext(record.executionContext ?? record.execution ?? record); + return { + source: textValue(record.source) || "project-management", + projectId, + taskRef, + sourceId: textValue(record.sourceId) || null, + sourceKind: textValue(record.sourceKind) || execution?.sourceKind || null, + fileRef: textValue(record.fileRef) || null, + relativePath: textValue(record.relativePath) || execution?.relativePath || null, + taskId: textValue(record.taskId) || null, + title: textValue(record.title) || null, + status: textValue(record.status) || null, + hwpodId: (execution?.hwpodId ?? textValue(record.hwpodId)) || null, + nodeId: (execution?.nodeId ?? textValue(record.nodeId)) || null, + mdtodoRootRef: (execution?.mdtodoRootRef ?? textValue(record.mdtodoRootRef)) || null, + workspaceRootHash: (execution?.workspaceRootHash ?? textValue(record.workspaceRootHash)) || null, + workspaceRootLabel: (execution?.workspaceRootLabel ?? textValue(record.workspaceRootLabel)) || null, + hwpodWorkspaceArgs: (execution?.hwpodWorkspaceArgs ?? textValue(record.hwpodWorkspaceArgs)) || null, + contextFingerprint: (execution?.contextFingerprint ?? textValue(record.contextFingerprint)) || null, + capabilities: compactPlainObject(record.capabilities), + executionContext: execution, + route: textValue(record.route) || "/projects/mdtodo", + valuesRedacted: true + }; +} + +function compactLaunchExecutionContext(value: unknown): Record | null { + const record = objectRecord(value); + if (!record) return null; + const sourceId = textValue(record.sourceId) || null; + const taskRef = textValue(record.taskRef) || null; + const hwpodId = textValue(record.hwpodId) || null; + if (!sourceId && !taskRef && !hwpodId) return null; + return { + sourceId, + sourceKind: textValue(record.sourceKind) || null, + projectId: textValue(record.projectId) || null, + taskRef, + fileRef: textValue(record.fileRef) || null, + relativePath: textValue(record.relativePath) || null, + taskId: textValue(record.taskId) || null, + hwpodId, + nodeId: textValue(record.nodeId) || null, + mdtodoRootRef: textValue(record.mdtodoRootRef) || null, + workspaceRootRef: textValue(record.workspaceRootRef) || null, + workspaceRootHash: textValue(record.workspaceRootHash) || null, + workspaceRootLabel: textValue(record.workspaceRootLabel) || null, + hwpodWorkspaceArgs: textValue(record.hwpodWorkspaceArgs) || null, + contextFingerprint: textValue(record.contextFingerprint) || null, + capabilities: compactPlainObject(record.capabilities), + valuesRedacted: true + }; +} + +function compactPlainObject(value: unknown): Record { + const record = objectRecord(value); + if (!record) return {}; + const out: Record = {}; + for (const [key, item] of Object.entries(record)) { + 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 sessionJson(session: Record | null | undefined): Record | null { + return objectRecord(session?.sessionJson); +} + +function objectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function textValue(value: unknown): string { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + +function truthyQueryFlag(value: string | null): boolean { + const normalized = textValue(value).toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +}