197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
import * as fs from 'fs/promises';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
classifyLaunchFailureArtifact,
|
|
extractLaunchBootstrapTransportBreadcrumb,
|
|
redactLaunchFailureArtifactText,
|
|
writeTeamLaunchFailureArtifactPack,
|
|
} from '../../../../src/main/services/team/TeamLaunchFailureArtifactPack';
|
|
import {
|
|
getTeamsBasePath,
|
|
setClaudeBasePathOverride,
|
|
} from '../../../../src/main/utils/pathDecoder';
|
|
|
|
describe('TeamLaunchFailureArtifactPack', () => {
|
|
let tempRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-launch-artifact-pack-'));
|
|
setClaudeBasePathOverride(path.join(tempRoot, '.claude'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
setClaudeBasePathOverride(null);
|
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('writes a bounded redacted launch failure artifact pack with known launch files', async () => {
|
|
const teamName = 'artifact-team';
|
|
const runId = 'run-secret-1';
|
|
const teamDir = path.join(getTeamsBasePath(), teamName);
|
|
await fs.mkdir(path.join(teamDir, '.bootstrap.lock'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(teamDir, 'launch-state.json'),
|
|
JSON.stringify({
|
|
teamName,
|
|
runId,
|
|
secret: 'sk-ant-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
token: 'abcdefghijklmnopqrstuvwxyz123456',
|
|
}),
|
|
'utf8'
|
|
);
|
|
await fs.writeFile(path.join(teamDir, 'launch-summary.json'), '{"summary":true}\n', 'utf8');
|
|
await fs.writeFile(path.join(teamDir, 'bootstrap-state.json'), '{"bootstrap":true}\n', 'utf8');
|
|
await fs.writeFile(
|
|
path.join(teamDir, 'bootstrap-journal.jsonl'),
|
|
'{"event":"started"}\n',
|
|
'utf8'
|
|
);
|
|
await fs.writeFile(
|
|
path.join(teamDir, '.bootstrap.lock', 'metadata.json'),
|
|
'{"pid":123,"runId":"run-secret-1"}\n',
|
|
'utf8'
|
|
);
|
|
|
|
const result = await writeTeamLaunchFailureArtifactPack({
|
|
teamName,
|
|
runId,
|
|
reason: 'launch_progress_failed',
|
|
startedAt: '2026-05-09T00:00:00.000Z',
|
|
cwd: '/repo',
|
|
pid: 123,
|
|
providerId: 'anthropic',
|
|
model: 'claude-opus',
|
|
expectedMembers: ['alice'],
|
|
effectiveMembers: [{ name: 'alice', role: 'developer', provider: 'anthropic' } as never],
|
|
progress: {
|
|
runId,
|
|
teamName,
|
|
state: 'failed',
|
|
message: 'Launch failed',
|
|
startedAt: '2026-05-09T00:00:00.000Z',
|
|
updatedAt: '2026-05-09T00:01:00.000Z',
|
|
error:
|
|
'Authentication failed: ANTHROPIC_API_KEY=sk-ant-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
},
|
|
memberSpawnStatuses: {
|
|
alice: {
|
|
status: 'error',
|
|
launchState: 'failed_to_start',
|
|
hardFailureReason: 'bootstrap timeout',
|
|
updatedAt: '2026-05-09T00:01:00.000Z',
|
|
},
|
|
},
|
|
cliLogs: 'stderr OPENAI_API_KEY=sk-proj-cccccccccccccccccccccccccccccccccccccccc',
|
|
progressTraceLines: ['[failed] launch failed'],
|
|
runtimeAdapterTraceLines: ['runtime trace'],
|
|
});
|
|
|
|
const manifest = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')) as {
|
|
reason: string;
|
|
artifactFiles: string[];
|
|
classification: { code: string };
|
|
bootstrapTransportBreadcrumb: { lastTransportStage: string | null };
|
|
progress: { error: string };
|
|
};
|
|
expect(manifest.reason).toBe('launch_progress_failed');
|
|
expect(manifest.classification.code).toBe('provider_auth');
|
|
expect(manifest.artifactFiles).toContain('cli-logs-tail.txt');
|
|
expect(manifest.artifactFiles).toContain('launch-state.json');
|
|
expect(manifest.progress.error).toContain('[REDACTED]');
|
|
|
|
const copiedLaunchState = await fs.readFile(path.join(result.directory, 'launch-state.json'), 'utf8');
|
|
expect(copiedLaunchState).toContain('[REDACTED_ANTHROPIC_API_KEY]');
|
|
expect(() => JSON.parse(copiedLaunchState)).not.toThrow();
|
|
expect(copiedLaunchState).toContain('"token":"[REDACTED]"');
|
|
expect(copiedLaunchState).not.toContain('sk-ant-');
|
|
|
|
const cliLogs = await fs.readFile(path.join(result.directory, 'cli-logs-tail.txt'), 'utf8');
|
|
expect(cliLogs).toContain('OPENAI_API_KEY=[REDACTED]');
|
|
expect(cliLogs).not.toContain('sk-proj-');
|
|
|
|
const latest = JSON.parse(
|
|
await fs.readFile(path.join(teamDir, 'launch-failure-artifacts', 'latest.json'), 'utf8')
|
|
) as { manifestPath: string };
|
|
expect(latest.manifestPath).toBe(result.manifestPath);
|
|
});
|
|
|
|
it('redacts common bearer and token-shaped secrets', () => {
|
|
const redacted = redactLaunchFailureArtifactText(
|
|
'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456 token: abcdefghijklmnopqrstuvwxyz123456'
|
|
);
|
|
expect(redacted).toContain('Authorization: Bearer [REDACTED]');
|
|
expect(redacted).toContain('token: [REDACTED]');
|
|
});
|
|
|
|
it('classifies bootstrap transport rejection and extracts breadcrumb details', () => {
|
|
const input = {
|
|
teamName: 'artifact-team',
|
|
runId: 'run-transport',
|
|
reason: 'launch_cleanup_unconfirmed_bootstrap',
|
|
progressTraceLines: [
|
|
'bob did not submit bootstrap prompt: timed out waiting for bootstrap_submitted; last transport stage: bootstrap_submit_rejected: submit rejected by local prompt handler retryable=true',
|
|
'Warning: no stdin data received in 3s, proceeding without it.',
|
|
],
|
|
};
|
|
|
|
expect(classifyLaunchFailureArtifact(input).code).toBe('transport_rejected');
|
|
expect(extractLaunchBootstrapTransportBreadcrumb(input)).toMatchObject({
|
|
lastTransportStage: 'bootstrap_submit_rejected: submit rejected by local prompt handler retryable=true',
|
|
submitRejected: true,
|
|
retryable: true,
|
|
noStdinWarning: true,
|
|
bootstrapSubmitted: false,
|
|
});
|
|
});
|
|
|
|
it('classifies provider quota separately from protocol errors', () => {
|
|
expect(
|
|
classifyLaunchFailureArtifact({
|
|
teamName: 'artifact-team',
|
|
runId: 'run-quota',
|
|
reason:
|
|
'OpenCode quota exhausted. This request requires more credits, or fewer max_tokens.',
|
|
}).code
|
|
).toBe('provider_quota');
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'stdin warning',
|
|
text: 'Warning: no stdin data received in 3s, proceeding without it.',
|
|
code: 'stdin_missing',
|
|
},
|
|
{
|
|
name: 'provider auth',
|
|
text: 'Codex API error. Token refresh failed: 401 Unauthorized',
|
|
code: 'provider_auth',
|
|
},
|
|
{
|
|
name: 'model bootstrap timeout',
|
|
text: 'bob: Teammate was registered but did not bootstrap-confirm before timeout.',
|
|
code: 'model_no_bootstrap',
|
|
},
|
|
{
|
|
name: 'process stale pid',
|
|
text: 'persisted runtime pid is not alive; persisted runtime pid was not found in process table',
|
|
code: 'process_exited',
|
|
},
|
|
{
|
|
name: 'opencode protocol',
|
|
text: 'OpenCode API error. non_visible_tool_without_task_progress',
|
|
code: 'opencode_protocol',
|
|
},
|
|
])('classifies production-like failure string: $name', ({ text, code }) => {
|
|
expect(
|
|
classifyLaunchFailureArtifact({
|
|
teamName: 'artifact-team',
|
|
runId: `run-${code}`,
|
|
reason: text,
|
|
}).code
|
|
).toBe(code);
|
|
});
|
|
});
|