test: fail fast on claude live api errors
This commit is contained in:
parent
0d83f94537
commit
dbec466c14
2 changed files with 105 additions and 0 deletions
|
|
@ -25,6 +25,7 @@ import {
|
|||
readRuntimeTurnSettledProcessedMetas,
|
||||
restoreEnv,
|
||||
startMemberWorkSyncControlServer,
|
||||
throwIfClaudeTranscriptApiError,
|
||||
type MemberWorkSyncLiveControlServer,
|
||||
waitUntil,
|
||||
} from './memberWorkSyncLiveHarness';
|
||||
|
|
@ -251,6 +252,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
}
|
||||
return last?.state === 'ready';
|
||||
}, 240_000);
|
||||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude team launch',
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.stat(
|
||||
|
|
@ -304,6 +309,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
);
|
||||
|
||||
await waitUntil(async () => {
|
||||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude validation turn',
|
||||
});
|
||||
await feature!.replayPendingReports([teamName!]);
|
||||
const [status, tasks] = await Promise.all([
|
||||
feature!.getStatus({ teamName: teamName!, memberName }),
|
||||
|
|
@ -331,6 +340,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
|
||||
const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled;
|
||||
await waitUntil(async () => {
|
||||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude Stop hook turn',
|
||||
});
|
||||
await feature!.drainRuntimeTurnSettledEvents();
|
||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||
return metas.some(({ filePath, meta }) => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/membe
|
|||
|
||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
|
||||
export class FatalWaitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FatalWaitError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncLiveControlServer {
|
||||
baseUrl: string;
|
||||
close(): Promise<void>;
|
||||
|
|
@ -102,6 +109,9 @@ export async function waitUntil(
|
|||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof FatalWaitError) {
|
||||
throw error;
|
||||
}
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
|
|
@ -173,6 +183,57 @@ export async function formatMemberWorkSyncDiagnostics(input: {
|
|||
].join('\n');
|
||||
}
|
||||
|
||||
export async function throwIfClaudeTranscriptApiError(input: {
|
||||
claudeRoot: string;
|
||||
context: string;
|
||||
}): Promise<void> {
|
||||
const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects'));
|
||||
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
|
||||
for (const filePath of transcriptFiles) {
|
||||
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.includes('"isApiErrorMessage"') && !line.includes('"error"')) {
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const message = parsed.message as Record<string, unknown> | undefined;
|
||||
apiErrors.push({
|
||||
filePath,
|
||||
error: typeof parsed.error === 'string' ? parsed.error : 'api_error',
|
||||
text: extractClaudeMessageText(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (apiErrors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = apiErrors.at(-1)!;
|
||||
throw new FatalWaitError(
|
||||
[
|
||||
`${input.context}: Claude API error detected in live transcript.`,
|
||||
`error=${latest.error}`,
|
||||
latest.text ? `message=${latest.text}` : undefined,
|
||||
`transcript=${latest.filePath}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
||||
Array<{
|
||||
filePath: string;
|
||||
|
|
@ -198,6 +259,37 @@ export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string
|
|||
return metas.sort((left, right) => left.filePath.localeCompare(right.filePath));
|
||||
}
|
||||
|
||||
async function findJsonlFiles(rootPath: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []);
|
||||
const nested = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(rootPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return findJsonlFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [entryPath] : [];
|
||||
})
|
||||
);
|
||||
return nested.flat().sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function extractClaudeMessageText(message: Record<string, unknown> | undefined): string {
|
||||
const content = message?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === 'string' ? text : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function readRequestJson(request: http.IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue