feat(team): surface provisioning trace in live output
This commit is contained in:
parent
a8d53ca5cb
commit
075976fd23
6 changed files with 546 additions and 33 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue