diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 7f2e5670..cc47b543 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -271,6 +271,10 @@ function summarizeEnvelope(envelope: JsonEnvelope): Record 0) summary.arrayCounts = arrayCounts; + const debugDispatch = summarizeDebugDispatch(source); + if (debugDispatch !== null) summary.debugDispatch = debugDispatch; + const providerTriage = summarizeProviderTriage(source); + if (providerTriage !== null) summary.providerTriage = providerTriage; const issue = source.issue; if (typeof issue === "object" && issue !== null) { const issueRecord = issue as Record; @@ -303,6 +307,189 @@ function summarizeArrayCounts(source: Record): Record): Record | null { + const dispatch = recordOrNull(source.dispatch); + const wait = recordOrNull(source.wait); + if (dispatch === null && wait === null) return null; + const dispatchBody = recordOrNull(dispatch?.body); + const waitTask = recordOrNull(wait?.task); + const waitPayload = recordOrNull(waitTask?.payload); + const waitResult = recordOrNull(waitTask?.result); + const result: Record = { + dispatch: dispatch === null ? null : { + ...pickSummary(dispatch, ["ok", "status", "responseBytesRead", "responseContentLength", "responseTruncated"]), + body: dispatchBody === null ? null : pickSummary(dispatchBody, ["ok", "providerOnline", "status", "taskId"]), + }, + wait: wait === null ? null : pickSummary(wait, ["ok", "timeoutMs"]), + task: waitTask === null ? null : { + ...pickSummary(waitTask, ["id", "status", "command", "providerId", "createdAt", "updatedAt"]), + payload: waitPayload === null ? null : pickSummary(waitPayload, ["mode", "source"]), + }, + }; + const providerUpgrade = summarizeProviderUpgradeResult(waitResult); + if (providerUpgrade !== null) result.providerUpgrade = providerUpgrade; + return result; +} + +function summarizeProviderUpgradeResult(result: Record | null): Record | null { + if (result === null) return null; + const schedulerResult = recordOrNull(result.schedulerResult); + const plan = recordOrNull(result.plan) ?? recordOrNull(schedulerResult?.plan); + const finalPromotedContainer = recordOrNull(result.finalPromotedContainer) + ?? recordOrNull(schedulerResult?.finalPromotedContainer) + ?? recordOrNull(plan?.finalPromotedContainer); + const heartbeat = recordOrNull(result.heartbeat); + const heartbeatConfirmation = recordOrNull(plan?.finalPromotedHeartbeatConfirmation) + ?? recordOrNull(schedulerResult?.finalPromotedHeartbeatConfirmation); + const summary: Record = { + ...pickSummary(result, ["ok", "reason", "taskId", "providerId", "targetProviderGatewayVersion", "candidateValidationWasTerminal"]), + }; + const mode = result.mode ?? schedulerResult?.mode; + if (mode !== undefined) summary.mode = mode; + const message = stringOrNull(result.message) ?? stringOrNull(schedulerResult?.message); + if (message !== null) summary.message = shortText(message, 240); + const providerGatewayVersion = result.providerGatewayVersion ?? schedulerResult?.providerGatewayVersion ?? plan?.providerGatewayVersion; + if (providerGatewayVersion !== undefined) summary.providerGatewayVersion = providerGatewayVersion; + if (schedulerResult !== null) { + summary.schedulerResult = { + ...pickSummary(schedulerResult, [ + "mode", + "message", + "providerId", + "providerName", + "providerGatewayVersion", + "targetProviderGatewayVersion", + "updaterContainerId", + "candidateValidationIsNotTerminalSuccess", + "terminalStatusManagedByBackendCore", + ]), + }; + } + if (plan !== null) { + summary.plan = { + ...pickSummary(plan, [ + "taskId", + "providerId", + "providerName", + "policy", + "hostProjectRoot", + "workspace", + "workspaceStateMount", + "providerGatewayName", + "providerGatewayVersion", + "targetProviderGatewayName", + "targetProviderGatewayVersion", + "runnerImage", + "updaterName", + "candidateName", + ]), + compose: pickSummary(recordOrNull(plan.compose) ?? {}, ["composeFile", "envFile", "project", "service"]), + heartbeatConfirmation: heartbeatConfirmation === null ? null : pickSummary(heartbeatConfirmation, [ + "providerId", + "required", + "heartbeatTimeoutMs", + "targetProviderGatewayVersion", + "finalContainerMustNotEqualCandidate", + ]), + }; + } + if (finalPromotedContainer !== null) { + summary.finalPromotedContainer = pickSummary(finalPromotedContainer, [ + "containerId", + "containerName", + "version", + "restartPolicy", + "pidMode", + "heartbeatTimestamp", + "containerIdUnavailableReason", + "versionUnavailableReason", + "restartPolicyUnavailableReason", + "pidModeUnavailableReason", + "heartbeatTimestampUnavailableReason", + ]); + } + if (heartbeat !== null) { + const heartbeatSummary = pickSummary(heartbeat, ["providerId", "version", "containerId", "containerName", "restartPolicy", "pidMode", "timestamp"]); + if (Object.keys(heartbeatSummary).length > 0) summary.heartbeat = heartbeatSummary; + } + return summary; +} + +function summarizeProviderTriage(source: Record): Record | null { + if (!("decision" in source) || !("providerId" in source) || !Array.isArray(source.signals)) return null; + const signals = source.signals.map(recordOrNull).filter((item): item is Record => item !== null); + const issueSignals = signals + .filter((item) => item.status === "failed" || item.status === "degraded" || item.status === "unknown") + .sort((left, right) => signalStatusRank(left.status) - signalStatusRank(right.status)); + const previewSignals = (issueSignals.length > 0 ? issueSignals : signals).slice(0, 8); + return { + ...pickSummary(source, ["ok", "providerId", "decision", "scope", "retryable", "blockingDisposition", "observedAt"]), + failedScopes: boundedStringArray(source.failedScopes, 12), + degradedScopes: boundedStringArray(source.degradedScopes, 12), + healthyScopes: boundedStringArray(source.healthyScopes, 12), + failedIndependentScopes: boundedStringArray(source.failedIndependentScopes, 12), + healthyIndependentScopes: boundedStringArray(source.healthyIndependentScopes, 12), + signalCounts: providerSignalCounts(signals, previewSignals.length), + recommendedCrossChecks: boundedStringArray(source.recommendedCrossChecks, 8), + rationale: boundedStringArray(source.rationale, 8), + signalsPreview: previewSignals.map((item) => ({ + ...pickSummary(item, ["id", "scope", "status", "independentPath", "observedAt"]), + summary: shortText(stringOrNull(item.summary) ?? "", 220), + })), + }; +} + +function providerSignalCounts(signals: Record[], returned: number): Record { + const counts = { + total: signals.length, + returned, + ok: 0, + degraded: 0, + failed: 0, + unknown: 0, + }; + for (const signal of signals) { + if (signal.status === "ok") counts.ok += 1; + else if (signal.status === "degraded") counts.degraded += 1; + else if (signal.status === "failed") counts.failed += 1; + else counts.unknown += 1; + } + return { + ...counts, + omittedSignals: Math.max(0, signals.length - returned), + }; +} + +function signalStatusRank(status: unknown): number { + if (status === "failed") return 0; + if (status === "degraded") return 1; + if (status === "unknown") return 2; + if (status === "ok") return 3; + return 4; +} + +function boundedStringArray(value: unknown, limit: number): Record { + const items = Array.from(new Set((Array.isArray(value) ? value : []).map((item) => String(item ?? "")).filter(Boolean))); + return { + items: items.slice(0, limit), + count: items.length, + truncated: items.length > limit, + omitted: Math.max(0, items.length - limit), + }; +} + +function recordOrNull(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function shortText(value: string, maxChars: number): string { + return value.length <= maxChars ? value : `${value.slice(0, Math.max(0, maxChars - 3))}...`; +} + function pickSummary(source: Record, keys: string[]): Record { const result: Record = {}; for (const key of keys) {