feat(team): surface provisioning trace in live output

This commit is contained in:
777genius 2026-04-28 16:37:17 +03:00
parent a8d53ca5cb
commit 075976fd23
6 changed files with 546 additions and 33 deletions

View file

@ -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<string, number>;
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
@ -1405,6 +1410,8 @@ interface ProvisioningRun {
lastMemberSpawnAuditMissingWarningAt: Map<string, number>;
}
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<TeamProvisioningState, 'idle'>,
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<TeamProvisioningState, 'idle'>,
@ -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<string, string>();
private readonly aliveRunByTeam = new Map<string, string>();
private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>();
private readonly runtimeAdapterTraceLinesByRunId = new Map<string, string[]>();
private readonly runtimeAdapterTraceKeyByRunId = new Map<string, string>();
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);
}

View file

@ -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(

View file

@ -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<HTMLDivElement>(null);
const copyResetTimerRef = useRef<number | null>(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<void> => {
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 (
<div
className={cn(
@ -338,14 +490,28 @@ export const ProvisioningProgressBlock = ({
</div>
) : null}
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setLiveOutputOpen((v) => !v)}
>
{liveOutputOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Live output
</button>
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setLiveOutputOpen((v) => !v)}
>
{liveOutputOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Live output
</button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 shrink-0 gap-1 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
title={diagnosticsCopied ? 'Diagnostics copied' : 'Copy diagnostics'}
aria-label={diagnosticsCopied ? 'Diagnostics copied' : 'Copy diagnostics'}
onClick={() => void copyDiagnostics()}
>
{diagnosticsCopied ? <Check size={12} /> : <ClipboardList size={12} />}
<span>{diagnosticsCopied ? 'Copied' : 'Copy diagnostics'}</span>
</Button>
</div>
{liveOutputOpen ? (
<div
ref={outputScrollRef}

View file

@ -1314,7 +1314,7 @@ export interface TeamProvisioningProgress {
warnings?: string[];
/** Provisioning CLI logs shown in the launch progress UI. */
cliLogsTail?: string;
/** Accumulated assistant text output during provisioning (for live preview). */
/** Bounded launch trace plus assistant/runtime text output for the live preview. */
assistantOutput?: string;
/** True once provisioning has written a readable config.json for this team. */
configReady?: boolean;

View file

@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest';
import {
PROGRESS_LOG_TAIL_LINES,
PROGRESS_OUTPUT_TAIL_PARTS,
PROGRESS_TRACE_TAIL_LINES,
boundLaunchDiagnostics,
buildProgressAssistantOutput,
buildProgressLiveOutput,
buildProgressLogsTail,
buildProgressTraceLine,
buildProgressTraceTail,
} from '../../../../src/main/services/team/progressPayload';
describe('buildProgressLogsTail', () => {
@ -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)}`;

View file

@ -24,9 +24,11 @@ vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => 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();
});
});
});