fix(team): stabilize Claude live runtime launches
This commit is contained in:
parent
3908b5eb25
commit
860074da15
5 changed files with 336 additions and 19 deletions
|
|
@ -1600,6 +1600,15 @@ function buildRuntimeSettingsTempDirectory(teamName: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeTeamRuntimeNodeEnv(env: NodeJS.ProcessEnv): void {
|
||||
// Vitest sets NODE_ENV=test in the desktop parent process. Real team runtime
|
||||
// children must run the CLI normally, otherwise source launches can take
|
||||
// test-only startup paths and exit before deterministic bootstrap starts.
|
||||
if (env.NODE_ENV === 'test') {
|
||||
env.NODE_ENV = 'development';
|
||||
}
|
||||
}
|
||||
|
||||
function buildProviderFastModeArgs(
|
||||
providerId: TeamProviderId,
|
||||
launchIdentity?: ProviderModelLaunchIdentity | null
|
||||
|
|
@ -22033,6 +22042,7 @@ export class TeamProvisioningService {
|
|||
contextLabel: 'Team create launch',
|
||||
});
|
||||
const spawnArgs = mergeJsonSettingsArgs([
|
||||
'--print',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
|
|
@ -23302,6 +23312,7 @@ export class TeamProvisioningService {
|
|||
throw error;
|
||||
}
|
||||
const launchArgs = [
|
||||
'--print',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
|
|
@ -36072,6 +36083,7 @@ export class TeamProvisioningService {
|
|||
: {}),
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
};
|
||||
normalizeTeamRuntimeNodeEnv(env);
|
||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
const providerEnvResult = await buildProviderAwareCliEnv({
|
||||
providerId,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMem
|
|||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader';
|
||||
import {
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
|
|
@ -51,7 +52,6 @@ const liveDescribe =
|
|||
|
||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-source';
|
||||
const DEFAULT_MODEL = 'sonnet';
|
||||
const DEFAULT_EFFORT = 'low' as const;
|
||||
|
||||
type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up';
|
||||
|
||||
|
|
@ -98,17 +98,16 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
let previousHome: string | undefined;
|
||||
let previousHistFile: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
let claudeJsonConfigRoot: string;
|
||||
let previousClaudeJsonConfig: string | null | undefined;
|
||||
let svc: TeamProvisioningService | null;
|
||||
let feature: MemberWorkSyncFeatureFacade | null;
|
||||
let controlServer: MemberWorkSyncLiveControlServer | null;
|
||||
let teamName: string | null;
|
||||
let usingConnectedClaudeAccount = false;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-'));
|
||||
tempClaudeRoot = path.join(tempDir, '.claude');
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
|
||||
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||
|
|
@ -117,12 +116,24 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
previousHome = process.env.HOME;
|
||||
previousHistFile = process.env.HISTFILE;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
previousClaudeJsonConfig = undefined;
|
||||
|
||||
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
||||
tempHome = shouldUseConnectedAccountHome
|
||||
? resolveConnectedClaudeHome(previousHome)
|
||||
usingConnectedClaudeAccount = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
|
||||
tempHome = usingConnectedClaudeAccount
|
||||
? resolveConnectedClaudeHome()
|
||||
: path.join(tempDir, 'home');
|
||||
tempClaudeRoot = usingConnectedClaudeAccount
|
||||
? path.join(tempHome, '.claude')
|
||||
: path.join(tempDir, '.claude');
|
||||
claudeJsonConfigRoot = usingConnectedClaudeAccount ? tempHome : tempClaudeRoot;
|
||||
await fs.mkdir(tempHome, { recursive: true });
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
|
||||
if (usingConnectedClaudeAccount) {
|
||||
setClaudeBasePathOverride(null);
|
||||
} else {
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
}
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
process.env.HISTFILE = '/dev/null';
|
||||
|
|
@ -140,6 +151,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const keepTemp = process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1';
|
||||
if (svc && teamName) {
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
}
|
||||
|
|
@ -148,6 +160,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
svc?.setRuntimeTurnSettledHookSettingsProvider(null);
|
||||
await feature?.dispose().catch(() => undefined);
|
||||
await controlServer?.close().catch(() => undefined);
|
||||
discardKnownClaudeStopHookWarnings();
|
||||
if (!keepTemp && usingConnectedClaudeAccount && teamName) {
|
||||
await fs.rm(path.join(getTeamsBasePath(), teamName), { recursive: true, force: true });
|
||||
await fs.rm(path.join(getTasksBasePath(), teamName), { recursive: true, force: true });
|
||||
}
|
||||
if (usingConnectedClaudeAccount && previousClaudeJsonConfig !== undefined) {
|
||||
await restoreClaudeJsonConfig(claudeJsonConfigRoot, previousClaudeJsonConfig);
|
||||
}
|
||||
|
||||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||
|
|
@ -158,7 +178,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
restoreEnv('HISTFILE', previousHistFile);
|
||||
restoreEnv('USERPROFILE', previousUserProfile);
|
||||
setClaudeBasePathOverride(null);
|
||||
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
|
||||
if (keepTemp) {
|
||||
console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`);
|
||||
} else {
|
||||
await removeTempDirAfterLateShellWrites(tempDir);
|
||||
|
|
@ -186,7 +206,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
||||
if (usingConnectedClaudeAccount) {
|
||||
previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig(
|
||||
claudeJsonConfigRoot,
|
||||
projectPath
|
||||
);
|
||||
} else {
|
||||
await writeTrustedClaudeConfig(claudeJsonConfigRoot, projectPath);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
|
||||
|
|
@ -251,7 +278,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
cwd: projectPath,
|
||||
providerId: 'anthropic',
|
||||
model,
|
||||
effort: DEFAULT_EFFORT,
|
||||
...(process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS?.trim()
|
||||
? { extraCliArgs: process.env.MEMBER_WORK_SYNC_CLAUDE_EXTRA_CLI_ARGS.trim() }
|
||||
: {}),
|
||||
skipPermissions: true,
|
||||
prompt: [
|
||||
'Keep launch work minimal and wait for the explicit live-test instruction.',
|
||||
|
|
@ -263,7 +292,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
role: 'Developer',
|
||||
providerId: 'anthropic',
|
||||
model,
|
||||
effort: DEFAULT_EFFORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -282,6 +310,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude team launch',
|
||||
projectPath,
|
||||
sinceMs: startedAt,
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
|
@ -339,6 +369,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude validation turn',
|
||||
projectPath,
|
||||
sinceMs: startedAt,
|
||||
});
|
||||
await feature!.replayPendingReports([teamName!]);
|
||||
const [status, metrics, tasks] = await Promise.all([
|
||||
|
|
@ -377,6 +409,8 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
|||
await throwIfClaudeTranscriptApiError({
|
||||
claudeRoot: tempClaudeRoot,
|
||||
context: 'Claude Stop hook turn',
|
||||
projectPath,
|
||||
sinceMs: startedAt,
|
||||
});
|
||||
await feature!.drainRuntimeTurnSettledEvents();
|
||||
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
|
||||
|
|
@ -562,6 +596,26 @@ function hasLiveAnthropicApiKey(): boolean {
|
|||
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
||||
}
|
||||
|
||||
function discardKnownClaudeStopHookWarnings(): void {
|
||||
const warn = vi.mocked(console.warn);
|
||||
if (!warn.mock) return;
|
||||
const calls = warn.mock.calls;
|
||||
for (let index = calls.length - 1; index >= 0; index -= 1) {
|
||||
const text = calls[index]?.map((value) => String(value)).join(' ') ?? '';
|
||||
if (text.includes('Failed to resolve login shell env: shell env resolve timeout')) {
|
||||
calls.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (text.includes('Failed to resolve interactive shell env: shell env resolve timeout')) {
|
||||
calls.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (text.includes('Failed to parse runtime model list for launch validation')) {
|
||||
calls.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||
|
|
@ -589,14 +643,71 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string):
|
|||
);
|
||||
}
|
||||
|
||||
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
|
||||
async function upsertTrustedClaudeProjectConfig(
|
||||
configDir: string,
|
||||
projectPath: string
|
||||
): Promise<string | null> {
|
||||
const configPath = path.join(configDir, '.claude.json');
|
||||
const previous = await fs.readFile(configPath, 'utf8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
const existing = parseJsonObject(previous) ?? {};
|
||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||
const projects =
|
||||
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
|
||||
? { ...(existing.projects as Record<string, unknown>) }
|
||||
: {};
|
||||
const currentProject =
|
||||
projects[normalizedProjectPath] &&
|
||||
typeof projects[normalizedProjectPath] === 'object' &&
|
||||
!Array.isArray(projects[normalizedProjectPath])
|
||||
? (projects[normalizedProjectPath] as Record<string, unknown>)
|
||||
: {};
|
||||
projects[normalizedProjectPath] = {
|
||||
...currentProject,
|
||||
hasTrustDialogAccepted: true,
|
||||
};
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...existing,
|
||||
projects,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
return previous;
|
||||
}
|
||||
|
||||
async function restoreClaudeJsonConfig(configDir: string, previous: string | null): Promise<void> {
|
||||
const configPath = path.join(configDir, '.claude.json');
|
||||
if (previous === null) {
|
||||
await fs.rm(configPath, { force: true });
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(configPath, previous, 'utf8');
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string | null): Record<string, unknown> | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveConnectedClaudeHome(): string {
|
||||
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
||||
if (explicit) {
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
const previous = previousHome?.trim();
|
||||
if (previous) {
|
||||
return path.resolve(previous);
|
||||
}
|
||||
return os.userInfo().homedir;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2850,6 +2850,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
|
||||
});
|
||||
|
||||
it('does not leak Vitest NODE_ENV into real team runtime children', async () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const buildProvisioningEnv = (
|
||||
svc as unknown as {
|
||||
buildProvisioningEnv(): Promise<{ env: NodeJS.ProcessEnv }>;
|
||||
}
|
||||
).buildProvisioningEnv.bind(svc);
|
||||
|
||||
const result = await buildProvisioningEnv();
|
||||
|
||||
expect(result.env.NODE_ENV).toBe('development');
|
||||
expect(buildProviderAwareCliEnvMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ NODE_ENV: 'development' }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = previousNodeEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) =>
|
||||
|
|
|
|||
119
test/main/services/team/memberWorkSyncLiveHarness.test.ts
Normal file
119
test/main/services/team/memberWorkSyncLiveHarness.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { encodePath } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import { throwIfClaudeTranscriptApiError } from './memberWorkSyncLiveHarness';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
describe('memberWorkSyncLiveHarness', () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it('scopes Claude API error checks to the current project transcripts', async () => {
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
|
||||
tempDirs.push(claudeRoot);
|
||||
const projectPath = path.join(claudeRoot, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
|
||||
const projectTranscriptDir = path.join(
|
||||
claudeRoot,
|
||||
'projects',
|
||||
encodePath(await fs.realpath(projectPath))
|
||||
);
|
||||
const unrelatedTranscriptDir = path.join(
|
||||
claudeRoot,
|
||||
'projects',
|
||||
encodePath('/Users/example/other-project')
|
||||
);
|
||||
await fs.mkdir(projectTranscriptDir, { recursive: true });
|
||||
await fs.mkdir(unrelatedTranscriptDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(unrelatedTranscriptDir, 'unrelated.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('wrong project'))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('right project'))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
})
|
||||
).rejects.toThrow(/right project/);
|
||||
});
|
||||
|
||||
it('ignores stale Claude API errors before the live check start time', async () => {
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-harness-'));
|
||||
tempDirs.push(claudeRoot);
|
||||
const projectPath = path.join(claudeRoot, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
|
||||
const projectTranscriptDir = path.join(
|
||||
claudeRoot,
|
||||
'projects',
|
||||
encodePath(await fs.realpath(projectPath))
|
||||
);
|
||||
await fs.mkdir(projectTranscriptDir, { recursive: true });
|
||||
const sinceMs = Date.now();
|
||||
await fs.writeFile(
|
||||
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('old error', sinceMs - 1_000))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
sinceMs,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await fs.appendFile(
|
||||
path.join(projectTranscriptDir, 'current.jsonl'),
|
||||
`${JSON.stringify(buildApiErrorRecord('new error', sinceMs + 1_000))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
throwIfClaudeTranscriptApiError({
|
||||
claudeRoot,
|
||||
context: 'live check',
|
||||
projectPath,
|
||||
sinceMs,
|
||||
})
|
||||
).rejects.toThrow(/new error/);
|
||||
});
|
||||
});
|
||||
|
||||
function buildApiErrorRecord(text: string, timestampMs = Date.now()): Record<string, unknown> {
|
||||
return {
|
||||
isApiErrorMessage: true,
|
||||
error: 'unknown',
|
||||
timestamp: new Date(timestampMs).toISOString(),
|
||||
message: {
|
||||
content: [{ type: 'text', text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ import { constants as fsConstants, promises as fs } from 'node:fs';
|
|||
import * as http from 'node:http';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { encodePath } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts';
|
||||
import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main';
|
||||
|
||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
|
||||
export class FatalWaitError extends Error {
|
||||
|
|
@ -199,8 +200,11 @@ export async function formatMemberWorkSyncDiagnostics(input: {
|
|||
export async function throwIfClaudeTranscriptApiError(input: {
|
||||
claudeRoot: string;
|
||||
context: string;
|
||||
projectPath?: string;
|
||||
sinceMs?: number;
|
||||
}): Promise<void> {
|
||||
const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects'));
|
||||
const transcriptRoots = await resolveClaudeTranscriptRoots(input.claudeRoot, input.projectPath);
|
||||
const transcriptFiles = (await Promise.all(transcriptRoots.map(findJsonlFiles))).flat();
|
||||
const apiErrors: Array<{ filePath: string; error: string; text: string }> = [];
|
||||
for (const filePath of transcriptFiles) {
|
||||
const raw = await fs.readFile(filePath, 'utf8').catch(() => '');
|
||||
|
|
@ -218,6 +222,9 @@ export async function throwIfClaudeTranscriptApiError(input: {
|
|||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (input.sinceMs !== undefined && isTranscriptRecordBefore(parsed, input.sinceMs)) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -247,6 +254,46 @@ export async function throwIfClaudeTranscriptApiError(input: {
|
|||
);
|
||||
}
|
||||
|
||||
async function resolveClaudeTranscriptRoots(
|
||||
claudeRoot: string,
|
||||
projectPath: string | undefined
|
||||
): Promise<string[]> {
|
||||
const projectsRoot = path.join(claudeRoot, 'projects');
|
||||
if (!projectPath) {
|
||||
return [projectsRoot];
|
||||
}
|
||||
|
||||
const candidateRoots = new Set<string>();
|
||||
const addCandidate = (candidatePath: string) => {
|
||||
candidateRoots.add(path.join(projectsRoot, encodePath(candidatePath)));
|
||||
};
|
||||
addCandidate(path.resolve(projectPath));
|
||||
const realProjectPath = await fs.realpath(projectPath).catch(() => null);
|
||||
if (realProjectPath) {
|
||||
addCandidate(realProjectPath);
|
||||
}
|
||||
|
||||
const existingRoots: string[] = [];
|
||||
for (const candidateRoot of candidateRoots) {
|
||||
const stats = await fs.stat(candidateRoot).catch(() => null);
|
||||
if (stats?.isDirectory()) {
|
||||
existingRoots.push(candidateRoot);
|
||||
}
|
||||
}
|
||||
return existingRoots;
|
||||
}
|
||||
|
||||
function isTranscriptRecordBefore(record: Record<string, unknown>, sinceMs: number): boolean {
|
||||
const timestamp = record.timestamp;
|
||||
const timestampMs =
|
||||
typeof timestamp === 'string'
|
||||
? Date.parse(timestamp)
|
||||
: typeof timestamp === 'number'
|
||||
? timestamp
|
||||
: Number.NaN;
|
||||
return Number.isFinite(timestampMs) && timestampMs < sinceMs;
|
||||
}
|
||||
|
||||
export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise<
|
||||
Array<{
|
||||
filePath: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue