From 075976fd2302535a2acba0bf5342e5446dcb74dc Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 16:37:17 +0300 Subject: [PATCH] feat(team): surface provisioning trace in live output --- .../services/team/TeamProvisioningService.ts | 191 ++++++++++++++++-- src/main/services/team/progressPayload.ts | 72 ++++++- .../team/ProvisioningProgressBlock.tsx | 184 ++++++++++++++++- src/shared/types/team.ts | 2 +- .../services/team/progressPayload.test.ts | 58 ++++++ .../team/ProvisioningProgressBlock.test.tsx | 72 +++++++ 6 files changed, 546 insertions(+), 33 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index cbfac5cb..5a2fb99a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -183,8 +183,9 @@ import { withInboxLock } from './inboxLock'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; import { boundLaunchDiagnostics, - buildProgressAssistantOutput, + buildProgressLiveOutput, buildProgressLogsTail, + buildProgressTraceLine, } from './progressPayload'; import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode'; import { @@ -1340,6 +1341,10 @@ interface ProvisioningRun { pendingInboxRelayCandidates: PendingInboxRelayCandidate[]; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; + /** Bounded orchestration checkpoints shown in the Live output panel. */ + provisioningTraceLines: string[]; + /** Last emitted trace key, used to avoid duplicate progress spam. */ + lastProvisioningTraceKey: string | null; /** Stable assistant message ids -> provisioningOutputParts index for in-place updates. */ provisioningOutputIndexByMessageId: Map; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ @@ -1405,6 +1410,8 @@ interface ProvisioningRun { lastMemberSpawnAuditMissingWarningAt: Map; } +const PROVISIONING_TRACE_STORAGE_LIMIT = 500; + interface MixedSecondaryRuntimeLaneState { laneId: string; providerId: 'opencode'; @@ -3369,6 +3376,75 @@ function clearGeminiPostLaunchHydrationState(run: ProvisioningRun): void { run.suppressGeminiPostLaunchHydrationOutput = false; } +function buildProvisioningTraceDetail( + extras?: Pick< + TeamProvisioningProgress, + 'pid' | 'error' | 'warnings' | 'configReady' | 'launchDiagnostics' + > +): string | undefined { + const parts = [ + extras?.pid != null ? `pid=${extras.pid}` : undefined, + extras?.configReady === true ? 'configReady=true' : undefined, + extras?.error ? `error=${extras.error}` : undefined, + extras?.warnings?.length ? `warnings=${extras.warnings.join('; ')}` : undefined, + extras?.launchDiagnostics?.length + ? `launchDiagnostics=${extras.launchDiagnostics.length}` + : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(' | ') : undefined; +} + +function appendProvisioningTrace( + run: ProvisioningRun, + state: Exclude, + message: string, + detail?: string +): void { + run.provisioningTraceLines ??= []; + run.lastProvisioningTraceKey ??= null; + const key = `${state}\u0000${message}\u0000${detail ?? ''}`; + if (run.lastProvisioningTraceKey === key) { + return; + } + run.lastProvisioningTraceKey = key; + run.provisioningTraceLines.push( + buildProgressTraceLine({ + timestamp: nowIso(), + state, + message, + detail, + }) + ); + if (run.provisioningTraceLines.length > PROVISIONING_TRACE_STORAGE_LIMIT) { + run.provisioningTraceLines.splice( + 0, + run.provisioningTraceLines.length - PROVISIONING_TRACE_STORAGE_LIMIT + ); + } +} + +function buildProvisioningLiveOutput(run: ProvisioningRun): string | undefined { + return buildProgressLiveOutput(run.provisioningTraceLines, run.provisioningOutputParts); +} + +function initializeProvisioningTrace(run: ProvisioningRun): void { + appendProvisioningTrace(run, run.progress.state, run.progress.message); + run.progress = { + ...run.progress, + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, + }; +} + +function emitProvisioningCheckpoint(run: ProvisioningRun, message: string, detail?: string): void { + appendProvisioningTrace(run, run.progress.state, message, detail); + run.progress = { + ...run.progress, + updatedAt: nowIso(), + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, + }; + run.onProgress(run.progress); +} + function updateProgress( run: ProvisioningRun, state: Exclude, @@ -3388,8 +3464,8 @@ function updateProgress( // from ~20 event-driven sites (auth retries, stall warnings, spawn events), // and an unbounded `provisioningOutputParts.join` was part of the same OOM // class that `emitLogsProgress` already guards against. - const assistantOutput = - buildProgressAssistantOutput(run.provisioningOutputParts) ?? run.progress.assistantOutput; + appendProvisioningTrace(run, state, message, buildProvisioningTraceDetail(extras)); + const assistantOutput = buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput; run.progress = { ...run.progress, state, @@ -3753,16 +3829,18 @@ function emitLogsProgress(run: ProvisioningRun): void { const logsTail = buildProgressLogsTail(run.claudeLogLines) ?? extractLogsTail(run.stdoutBuffer, run.stderrBuffer); - const assistantOutput = buildProgressAssistantOutput(run.provisioningOutputParts); + const assistantOutput = buildProvisioningLiveOutput(run); + const assistantOutputChanged = + assistantOutput !== undefined && assistantOutput !== run.progress.assistantOutput; - if (!logsTail && !assistantOutput) { + if (!logsTail && !assistantOutputChanged) { return; } run.progress = { ...run.progress, updatedAt: nowIso(), ...(logsTail !== undefined && { cliLogsTail: logsTail }), - ...(assistantOutput !== undefined && { assistantOutput }), + ...(assistantOutputChanged && { assistantOutput }), }; run.onProgress(run.progress); } @@ -3925,6 +4003,8 @@ export class TeamProvisioningService { private readonly provisioningRunByTeam = new Map(); private readonly aliveRunByTeam = new Map(); private readonly runtimeAdapterProgressByRunId = new Map(); + private readonly runtimeAdapterTraceLinesByRunId = new Map(); + private readonly runtimeAdapterTraceKeyByRunId = new Map(); private readonly runtimeAdapterRunByTeam = new Map< string, { @@ -6337,13 +6417,41 @@ export class TeamProvisioningService { ); } + private enrichRuntimeAdapterProgressTrace( + progress: TeamProvisioningProgress + ): TeamProvisioningProgress { + const detail = buildProvisioningTraceDetail(progress); + const key = `${progress.state}\u0000${progress.message}\u0000${detail ?? ''}`; + const lines = this.runtimeAdapterTraceLinesByRunId.get(progress.runId) ?? []; + if (this.runtimeAdapterTraceKeyByRunId.get(progress.runId) !== key) { + this.runtimeAdapterTraceKeyByRunId.set(progress.runId, key); + lines.push( + buildProgressTraceLine({ + timestamp: progress.updatedAt, + state: progress.state, + message: progress.message, + detail, + }) + ); + if (lines.length > PROVISIONING_TRACE_STORAGE_LIMIT) { + lines.splice(0, lines.length - PROVISIONING_TRACE_STORAGE_LIMIT); + } + this.runtimeAdapterTraceLinesByRunId.set(progress.runId, lines); + } + return { + ...progress, + assistantOutput: buildProgressLiveOutput(lines, []) ?? progress.assistantOutput, + }; + } + private setRuntimeAdapterProgress( progress: TeamProvisioningProgress, onProgress?: (progress: TeamProvisioningProgress) => void ): TeamProvisioningProgress { - this.runtimeAdapterProgressByRunId.set(progress.runId, progress); - onProgress?.(progress); - return progress; + const nextProgress = this.enrichRuntimeAdapterProgressTrace(progress); + this.runtimeAdapterProgressByRunId.set(nextProgress.runId, nextProgress); + onProgress?.(nextProgress); + return nextProgress; } private async getPersistedTranscriptClaudeLogs( @@ -11101,9 +11209,7 @@ export class TeamProvisioningService { message: this.buildStallProgressMessage(silenceSec, elapsed), messageSeverity: 'warning' as const, }), - assistantOutput: - buildProgressAssistantOutput(run.provisioningOutputParts) ?? - run.progress.assistantOutput, + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, }; run.onProgress(run.progress); } catch (err) { @@ -11735,6 +11841,8 @@ export class TeamProvisioningService { silentUserDmForwardClearHandle: null, pendingInboxRelayCandidates: [], provisioningOutputParts: [], + provisioningTraceLines: [], + lastProvisioningTraceKey: null, provisioningOutputIndexByMessageId: new Map(), detectedSessionId: null, leadActivityState: 'active', @@ -11775,9 +11883,16 @@ export class TeamProvisioningService { this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); + initializeProvisioningTrace(run); run.onProgress(run.progress); + emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); await this.clearPersistedLaunchState(request.teamName); + emitProvisioningCheckpoint( + run, + 'Building deterministic create bootstrap spec', + `expectedMembers=${effectiveMemberSpecs.length}` + ); const bootstrapSpec = buildDeterministicCreateBootstrapSpec( runId, request, @@ -11795,15 +11910,23 @@ export class TeamProvisioningService { let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; try { + emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); run.bootstrapSpecPath = bootstrapSpecPath; if (initialUserPrompt) { + emitProvisioningCheckpoint( + run, + 'Writing deferred user prompt file', + `chars=${promptSize.chars} lines=${promptSize.lines}` + ); bootstrapUserPromptPath = await writeDeterministicBootstrapUserPromptFile(initialUserPrompt); run.bootstrapUserPromptPath = bootstrapUserPromptPath; } + emitProvisioningCheckpoint(run, 'Writing MCP config file'); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + emitProvisioningCheckpoint(run, 'Validating agent-teams MCP runtime'); await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, { isCancelled: () => run.cancelRequested || @@ -11871,6 +11994,7 @@ export class TeamProvisioningService { try { // Pre-save our meta files before spawn — CLI doesn't touch these. // If provisioning fails before TeamCreate, user can retry without re-entering config. + emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); const teamDir = path.join(getTeamsBasePath(), request.teamName); const tasksDir = path.join(getTasksBasePath(), request.teamName); await fs.promises.mkdir(teamDir, { recursive: true }); @@ -11905,9 +12029,15 @@ export class TeamProvisioningService { throw new Error('Team launch cancelled by app shutdown'); } if (request.skipPermissions === false) { + emitProvisioningCheckpoint(run, 'Seeding lead bootstrap permission rules'); await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } + emitProvisioningCheckpoint( + run, + 'Spawning Claude CLI process', + `args=${spawnArgs.length} cwd=${request.cwd}` + ); child = spawnCli(claudePath, spawnArgs, { cwd: request.cwd, env: { ...shellEnv }, @@ -12790,6 +12920,8 @@ export class TeamProvisioningService { silentUserDmForwardClearHandle: null, pendingInboxRelayCandidates: [], provisioningOutputParts: [], + provisioningTraceLines: [], + lastProvisioningTraceKey: null, provisioningOutputIndexByMessageId: new Map(), detectedSessionId: previousSessionId ?? null, leadActivityState: 'active', @@ -12836,13 +12968,17 @@ export class TeamProvisioningService { this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); + initializeProvisioningTrace(run); run.onProgress(run.progress); + emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); await this.clearPersistedLaunchState(request.teamName); + emitProvisioningCheckpoint(run, 'Publishing mixed secondary lane status'); for (const lane of run.mixedSecondaryLanes ?? []) { await this.publishMixedSecondaryLaneStatusChange(run, lane); } // Read existing tasks to include in teammate prompts for work resumption + emitProvisioningCheckpoint(run, 'Reading existing tasks for launch prompt'); const taskReader = new TeamTaskReader(); let existingTasks: TeamTask[] = []; try { @@ -12870,17 +13006,30 @@ export class TeamProvisioningService { let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; try { + emitProvisioningCheckpoint( + run, + 'Building deterministic launch bootstrap spec', + `expectedMembers=${effectiveMemberSpecs.length}` + ); const bootstrapSpec = buildDeterministicLaunchBootstrapSpec( runId, request, effectiveMemberSpecs ); + emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); run.bootstrapSpecPath = bootstrapSpecPath; + emitProvisioningCheckpoint( + run, + 'Writing launch hydration prompt file', + `chars=${promptSize.chars} lines=${promptSize.lines}` + ); bootstrapUserPromptPath = await writeDeterministicBootstrapUserPromptFile(prompt); run.bootstrapUserPromptPath = bootstrapUserPromptPath; + emitProvisioningCheckpoint(run, 'Writing MCP config file'); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + emitProvisioningCheckpoint(run, 'Validating agent-teams MCP runtime'); await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, { isCancelled: () => run.cancelRequested || @@ -12952,6 +13101,7 @@ export class TeamProvisioningService { // can be inherited by the teammate subprocess via buildInheritedCliFlags. // Without this, a codex teammate spawned from an anthropic lead has no way to learn // about the required forced_login_method (chatgpt/api) and fails to start. + emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args'); const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs( resolvedProviderId, effectiveMemberSpecs @@ -12971,6 +13121,7 @@ export class TeamProvisioningService { }); // --resume is added above when a valid previous session JSONL exists. // Without it, CLI creates a fresh session ID automatically. + emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); await this.teamMetaStore.writeMeta(request.teamName, { displayName: syntheticRequest.displayName, description: syntheticRequest.description, @@ -13006,8 +13157,14 @@ export class TeamProvisioningService { throw new Error('Team launch cancelled by app shutdown'); } if (request.skipPermissions === false) { + emitProvisioningCheckpoint(run, 'Seeding lead bootstrap permission rules'); await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } + emitProvisioningCheckpoint( + run, + 'Spawning Claude CLI process for team launch', + `args=${finalLaunchArgs.length} cwd=${request.cwd}` + ); child = spawnCli(claudePath, finalLaunchArgs, { cwd: request.cwd, env: { ...shellEnv }, @@ -18911,14 +19068,18 @@ export class TeamProvisioningService { run.provisioningOutputParts.push(warningText); } run.lastRetryAt = Date.now(); + appendProvisioningTrace( + run, + run.progress.state, + retryText, + errorMessage ? `error=${errorMessage}` : undefined + ); run.progress = { ...run.progress, updatedAt: nowIso(), message: retryText, messageSeverity: 'error' as const, - assistantOutput: - buildProgressAssistantOutput(run.provisioningOutputParts) ?? - run.progress.assistantOutput, + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, }; run.onProgress(run.progress); } diff --git a/src/main/services/team/progressPayload.ts b/src/main/services/team/progressPayload.ts index e7ecb51c..baf8898c 100644 --- a/src/main/services/team/progressPayload.ts +++ b/src/main/services/team/progressPayload.ts @@ -16,10 +16,16 @@ 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; + /(--(?: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; /** * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" @@ -57,15 +63,65 @@ export function buildProgressAssistantOutput( return joined.trim().length === 0 ? undefined : joined; } -function boundDiagnosticText(value: string | undefined): string | undefined { - const trimmed = value?.replace(/\s+/g, ' ').trim(); - if (!trimmed) { +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 = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]'); - return redacted.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - ? `${redacted.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...` - : redacted; + 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(/```/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( diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index fecba68a..3dc2c59e 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; import { AlertTriangle, + Check, CheckCircle2, ChevronDown, ChevronRight, + ClipboardList, Info, Loader2, X, @@ -26,6 +28,13 @@ const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({ key: s.key, label: s.label, })); +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; export interface ProvisioningProgressBlockProps { /** Title above the steps, e.g. "Launching team" */ @@ -138,6 +147,86 @@ function sanitizeAssistantOutput(raw?: string, isError = false): string | null { ); } +function redactProvisioningDiagnosticsCopy(text: string): string { + return text + .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]'); +} + +function formatOptionalValue(value: string | number | null | undefined): string { + if (value === null || value === undefined || value === '') { + return '(none)'; + } + return String(value); +} + +function formatLaunchDiagnosticsCopy( + items: readonly TeamLaunchDiagnosticItem[] | undefined +): string { + if (!items || items.length === 0) { + return '(none)'; + } + + return items + .map((item) => + [ + `- id: ${item.id}`, + item.memberName ? ` member: ${item.memberName}` : undefined, + ` severity: ${item.severity}`, + ` code: ${item.code}`, + ` label: ${item.label}`, + item.detail ? ` detail: ${item.detail}` : undefined, + ` observedAt: ${item.observedAt}`, + ] + .filter((line): line is string => Boolean(line)) + .join('\n') + ) + .join('\n'); +} + +function buildProvisioningDiagnosticsCopy(input: { + title: string; + message?: string | null; + messageSeverity?: 'error' | 'warning' | 'info'; + tone: 'default' | 'error'; + startedAt?: string; + elapsed?: string | null; + pid?: number; + currentStepIndex: number; + errorStepIndex?: number; + liveOutput?: string | null; + cliLogsTail?: string; + launchDiagnostics?: TeamLaunchDiagnosticItem[]; +}): string { + const payload = [ + '# Team provisioning diagnostics', + '', + '## Summary', + `Title: ${input.title}`, + `Message: ${formatOptionalValue(input.message)}`, + `Message severity: ${formatOptionalValue(input.messageSeverity)}`, + `Tone: ${input.tone}`, + `Started at: ${formatOptionalValue(input.startedAt)}`, + `Elapsed: ${formatOptionalValue(input.elapsed)}`, + `PID: ${formatOptionalValue(input.pid)}`, + `Current step index: ${input.currentStepIndex}`, + `Error step index: ${formatOptionalValue(input.errorStepIndex)}`, + '', + '## Launch diagnostics', + formatLaunchDiagnosticsCopy(input.launchDiagnostics), + '', + '## Live output', + input.liveOutput?.trim() || '(empty)', + '', + '## CLI logs tail', + input.cliLogsTail?.trim() || '(empty)', + ].join('\n'); + + return redactProvisioningDiagnosticsCopy(payload).trim(); +} + export const ProvisioningProgressBlock = ({ title, message, @@ -164,9 +253,42 @@ export const ProvisioningProgressBlock = ({ const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); + const [diagnosticsCopied, setDiagnosticsCopied] = useState(false); const outputScrollRef = useRef(null); + const copyResetTimerRef = useRef(null); const isError = tone === 'error'; const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); + const diagnosticsCopyText = useMemo( + () => + buildProvisioningDiagnosticsCopy({ + title, + message, + messageSeverity, + tone, + startedAt, + elapsed, + pid, + currentStepIndex, + errorStepIndex, + liveOutput: displayAssistantOutput, + cliLogsTail, + launchDiagnostics, + }), + [ + title, + message, + messageSeverity, + tone, + startedAt, + elapsed, + pid, + currentStepIndex, + errorStepIndex, + displayAssistantOutput, + cliLogsTail, + launchDiagnostics, + ] + ); const visibleLaunchDiagnostics = launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ?? []; @@ -198,6 +320,36 @@ export const ProvisioningProgressBlock = ({ } }, [isError, cliLogsTail]); + useEffect( + () => () => { + if (copyResetTimerRef.current !== null) { + window.clearTimeout(copyResetTimerRef.current); + } + }, + [] + ); + + const copyDiagnostics = async (): Promise => { + if (!navigator.clipboard?.writeText) { + setDiagnosticsCopied(false); + return; + } + try { + await navigator.clipboard.writeText(diagnosticsCopyText); + } catch { + setDiagnosticsCopied(false); + return; + } + setDiagnosticsCopied(true); + if (copyResetTimerRef.current !== null) { + window.clearTimeout(copyResetTimerRef.current); + } + copyResetTimerRef.current = window.setTimeout(() => { + copyResetTimerRef.current = null; + setDiagnosticsCopied(false); + }, 1500); + }; + return (
) : null}
- +
+ + +
{liveOutputOpen ? (
{ @@ -77,6 +81,60 @@ describe('buildProgressAssistantOutput', () => { }); }); +describe('buildProgressTraceLine', () => { + it('redacts secrets and strips markdown fence delimiters', () => { + const result = buildProgressTraceLine({ + timestamp: '2026-04-28T12:00:00.000Z', + state: 'spawning', + message: 'Starting runtime --api-key sk-test', + detail: 'OPENAI_API_KEY=super-secret CODEX_API_KEY="also-secret" ```', + }); + + expect(result).toContain('--api-key [redacted]'); + expect(result).toContain('OPENAI_API_KEY=[redacted]'); + expect(result).toContain('CODEX_API_KEY=[redacted]'); + expect(result).not.toContain('sk-test'); + expect(result).not.toContain('super-secret'); + expect(result).not.toContain('also-secret'); + expect(result).not.toContain('```'); + }); +}); + +describe('buildProgressTraceTail', () => { + it('caps trace output to the last N lines', () => { + const lines = Array.from({ length: 10 }, (_, i) => `trace-${i}`); + + expect(buildProgressTraceTail(lines, 3)).toBe('trace-7\ntrace-8\ntrace-9'); + }); + + it('uses the default trace tail size when not overridden', () => { + const lines = Array.from({ length: PROGRESS_TRACE_TAIL_LINES + 10 }, (_, i) => `trace-${i}`); + const result = buildProgressTraceTail(lines); + + expect(result).toBeDefined(); + expect(result!.split('\n')).toHaveLength(PROGRESS_TRACE_TAIL_LINES); + }); +}); + +describe('buildProgressLiveOutput', () => { + it('preserves assistant-only output when no trace is available', () => { + expect(buildProgressLiveOutput([], ['hello'], { maxAssistantParts: 10 })).toBe('hello'); + }); + + it('combines bounded launch trace with runtime output', () => { + const result = buildProgressLiveOutput(['trace-1', 'trace-2'], ['assistant'], { + maxTraceLines: 1, + maxAssistantParts: 10, + }); + + expect(result).toContain('**Launch trace**'); + expect(result).not.toContain('trace-1'); + expect(result).toContain('trace-2'); + expect(result).toContain('**Runtime output**'); + expect(result).toContain('assistant'); + }); +}); + describe('boundLaunchDiagnostics', () => { it('redacts secret CLI flags and caps diagnostic payload size', () => { const longDetail = `node runtime --token super-secret ${'x'.repeat(800)}`; diff --git a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx index 68ec66de..1080e575 100644 --- a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx +++ b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx @@ -24,9 +24,11 @@ vi.mock('lucide-react', () => { const Icon = (props: React.SVGProps) => React.createElement('svg', props); return { AlertTriangle: Icon, + Check: Icon, CheckCircle2: Icon, ChevronDown: Icon, ChevronRight: Icon, + ClipboardList: Icon, Info: Icon, Loader2: Icon, X: Icon, @@ -38,6 +40,7 @@ import { ProvisioningProgressBlock } from '@renderer/components/team/Provisionin describe('ProvisioningProgressBlock', () => { afterEach(() => { document.body.innerHTML = ''; + vi.unstubAllGlobals(); }); it('keeps live output and CLI logs collapsed by default while launch is still running', async () => { @@ -185,4 +188,73 @@ describe('ProvisioningProgressBlock', () => { await Promise.resolve(); }); }); + + it('copies a combined diagnostics payload from the live output toolbar', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText }, + }); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProgressBlock, { + title: 'Launching team', + message: 'Starting Claude CLI process', + currentStepIndex: 1, + loading: true, + defaultLiveOutputOpen: true, + startedAt: '2026-04-28T12:00:00.000Z', + pid: 321, + assistantOutput: 'Launch trace line', + cliLogsTail: '[stderr] OPENAI_API_KEY=secret-value\n[stdout] booted', + launchDiagnostics: [ + { + id: 'alice:runtime_not_found', + memberName: 'alice', + severity: 'warning', + code: 'runtime_not_found', + label: 'alice - waiting for runtime', + detail: 'codex --api-key hidden-value', + observedAt: '2026-04-28T12:00:01.000Z', + }, + ], + }) + ); + await Promise.resolve(); + }); + + const button = Array.from(host.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes('Copy diagnostics') + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + const copied = String(writeText.mock.calls[0]?.[0] ?? ''); + expect(copied).toContain('# Team provisioning diagnostics'); + expect(copied).toContain('Title: Launching team'); + expect(copied).toContain('Message: Starting Claude CLI process'); + expect(copied).toContain('PID: 321'); + expect(copied).toContain('alice - waiting for runtime'); + expect(copied).toContain('Launch trace line'); + expect(copied).toContain('[stdout] booted'); + expect(copied).toContain('OPENAI_API_KEY=[redacted]'); + expect(copied).toContain('--api-key [redacted]'); + expect(copied).not.toContain('secret-value'); + expect(copied).not.toContain('hidden-value'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); });