agent-ecosystem/src/main/services/team/progressPayload.ts

143 lines
5.2 KiB
TypeScript

/**
* Helpers that shape provisioning progress payloads before they are emitted
* to the renderer over IPC.
*
* Rationale: the renderer only renders a small "tail" preview of CLI logs
* and assistant output in ProvisioningProgressBlock / CliLogsRichView. Sending
* the full accumulated history on every throttled progress tick (≈ every
* second under load) serialized a multi-megabyte string over IPC and forced
* Zustand to produce a new immutable state object — which triggered renderer
* V8 OOM crashes for users with long-running teams. These helpers keep the
* hot emission path bounded while leaving the full history in-process for
* diagnostics and completion-time reports.
*/
import type { TeamLaunchDiagnosticItem } from '@shared/types';
export const PROGRESS_LOG_TAIL_LINES = 200;
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
export const PROGRESS_TRACE_TAIL_LINES = 120;
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
const PROGRESS_TRACE_TEXT_LIMIT = 800;
const PROVIDER_API_KEY_FLAG_PATTERN =
/(--(?:openai|codex|anthropic)[-_]api[-_]key(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const SECRET_FLAG_PATTERN =
/(--(?:api[-_]key|token|password|secret|authorization|auth[-_]token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const SECRET_ENV_ASSIGNMENT_PATTERN =
/\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi;
const AUTH_HEADER_PATTERN = /\b(Authorization\s*:\s*)(Bearer\s+)?("[^"]*"|'[^']*'|\S+)/gi;
/**
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
* and trimmed. Returns `undefined` when the tail is empty so callers can
* skip emitting a noop update.
*/
export function buildProgressLogsTail(
lines: readonly string[],
maxLines: number = PROGRESS_LOG_TAIL_LINES
): string | undefined {
if (lines.length === 0) {
return undefined;
}
const effectiveMax = Math.max(1, maxLines);
const tail = lines.length > effectiveMax ? lines.slice(-effectiveMax) : lines;
const joined = tail.join('\n').trim();
return joined.length === 0 ? undefined : joined;
}
/**
* Return the trailing `maxParts` of assistant output parts joined with a
* blank line, matching the renderer's rendering contract. Returns `undefined`
* when no parts are available.
*/
export function buildProgressAssistantOutput(
parts: readonly string[],
maxParts: number = PROGRESS_OUTPUT_TAIL_PARTS
): string | undefined {
if (parts.length === 0) {
return undefined;
}
const effectiveMax = Math.max(1, maxParts);
const tail = parts.length > effectiveMax ? parts.slice(-effectiveMax) : parts;
const joined = tail.join('\n\n');
return joined.trim().length === 0 ? undefined : joined;
}
function boundRedactedText(
value: string | undefined,
limit: number,
whitespace: 'collapse' | 'preserve'
): string | undefined {
const prepared = whitespace === 'collapse' ? value?.replace(/\s+/g, ' ').trim() : value?.trim();
if (!prepared) {
return undefined;
}
const redacted = prepared
.replace(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]')
.replace(SECRET_FLAG_PATTERN, '$1[redacted]')
.replace(SECRET_ENV_ASSIGNMENT_PATTERN, '$1[redacted]')
.replace(AUTH_HEADER_PATTERN, '$1$2[redacted]')
.replace(/```/g, "'''");
return redacted.length > limit ? `${redacted.slice(0, limit - 3).trimEnd()}...` : redacted;
}
function boundDiagnosticText(value: string | undefined): string | undefined {
return boundRedactedText(value, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT, 'collapse');
}
export function buildProgressTraceLine(input: {
timestamp: string;
state: string;
message: string;
detail?: string;
}): string {
const message = boundRedactedText(input.message, PROGRESS_TRACE_TEXT_LIMIT, 'collapse') ?? '';
const detail = boundRedactedText(input.detail, PROGRESS_TRACE_TEXT_LIMIT, 'collapse');
return detail
? `${input.timestamp} [${input.state}] ${message} - ${detail}`
: `${input.timestamp} [${input.state}] ${message}`;
}
export function buildProgressTraceTail(
lines: readonly string[],
maxLines: number = PROGRESS_TRACE_TAIL_LINES
): string | undefined {
return buildProgressLogsTail(lines, maxLines);
}
export function buildProgressLiveOutput(
traceLines: readonly string[],
assistantParts: readonly string[],
options?: {
maxTraceLines?: number;
maxAssistantParts?: number;
}
): string | undefined {
const trace = buildProgressTraceTail(traceLines, options?.maxTraceLines);
const assistant = buildProgressAssistantOutput(assistantParts, options?.maxAssistantParts);
if (!trace) {
return assistant;
}
const traceBlock = `**Launch trace**\n\n\`\`\`text\n${trace}\n\`\`\``;
if (!assistant) {
return traceBlock;
}
return `${traceBlock}\n\n**Runtime output**\n\n${assistant}`;
}
export function boundLaunchDiagnostics(
items: readonly TeamLaunchDiagnosticItem[] | undefined,
maxItems: number = PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT
): TeamLaunchDiagnosticItem[] | undefined {
if (!items || items.length === 0) {
return undefined;
}
const bounded = items.slice(0, Math.max(1, maxItems)).map((item) => ({
...item,
label: boundDiagnosticText(item.label) ?? item.code,
detail: boundDiagnosticText(item.detail),
}));
return bounded.length > 0 ? bounded : undefined;
}