Merge pull request #14 from AlexeyZelenko/fix/remove-stale-oauth-token-injection
fix: remove stale OAuth token injection causing 401 errors
This commit is contained in:
commit
ea4cf85e2e
4 changed files with 231 additions and 183 deletions
|
|
@ -1,38 +1,16 @@
|
||||||
{
|
{
|
||||||
"hooks": {
|
"alwaysThinkingEnabled": true,
|
||||||
"PreToolUse": [
|
"permissions": {
|
||||||
{
|
"allow": [
|
||||||
"matcher": "Edit|Write",
|
"Edit",
|
||||||
"hooks": [
|
"Bash",
|
||||||
{
|
"ReadFile(*)",
|
||||||
"type": "command",
|
"Web Search",
|
||||||
"command": "jq -r '.tool_input.file_path // empty' | { read f; for p in pnpm-lock.yaml .env dist/ dist-electron/ node_modules/; do if [ -n \"$f\" ] && echo \"$f\" | grep -q \"$p\"; then echo \"Blocked: $f matches protected pattern '$p'\" >&2; exit 2; fi; done; exit 0; }"
|
"WebSearch",
|
||||||
}
|
"WebFetch",
|
||||||
]
|
"Fetch"
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"PostToolUse": [
|
"deny": [],
|
||||||
{
|
"ask": []
|
||||||
"matcher": "Edit|Write|NotebookEdit",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' | { read file_path; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx)$'; then pnpm eslint --fix \"$file_path\" 2>/dev/null || true; fi; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx|json|css)$'; then pnpm prettier --write \"$file_path\" 2>/dev/null || true; fi; }",
|
|
||||||
"timeout": 30
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"SessionStart": [
|
|
||||||
{
|
|
||||||
"matcher": "compact",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "echo '[Post-compaction context reminder]\n- Package manager: pnpm only (not npm/yarn)\n- Path aliases: @main/*, @renderer/*, @shared/*, @preload/*\n- Electron 3-process: main/ (Node), preload/ (bridge), renderer/ (React), shared/ (cross-process)\n- isMeta: false = real user message, true = internal/system message\n- Chunk types: UserChunk, AIChunk, SystemChunk, CompactChunk\n- State: Zustand slices pattern (data, selectedId, loading, error)\n- Styling: Tailwind with CSS variables (bg-surface, text-text, border-border)\n- Naming: PascalCase services/components, camelCase utils, UPPER_SNAKE constants\n- Barrel exports: import from domain index.ts\n- New IPC: channel in preload/constants → handler in main/ipc → method in preload/index.ts\n- PostToolUse hook auto-runs eslint+prettier on edited files\n- PreToolUse hook blocks edits to pnpm-lock.yaml, .env, dist/, node_modules/'"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +97,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
private pendingReprocess = new Set<string>();
|
private pendingReprocess = new Set<string>();
|
||||||
/** Flag to prevent reuse after disposal */
|
/** Flag to prevent reuse after disposal */
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */
|
||||||
|
private readonly instanceCreatedAt = Date.now();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
dataCache: DataCache,
|
dataCache: DataCache,
|
||||||
|
|
@ -751,6 +753,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFirstRead = lastLineCount === 0 && lastSize === 0;
|
||||||
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
|
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
|
||||||
let newMessages: ParsedMessage[] = [];
|
let newMessages: ParsedMessage[] = [];
|
||||||
let currentLineCount: number;
|
let currentLineCount: number;
|
||||||
|
|
@ -777,6 +780,22 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On first read (after app restart), establish baseline without detecting errors
|
||||||
|
// for files that existed BEFORE this FileWatcher started. This prevents flooding
|
||||||
|
// notifications with historical errors from old sessions.
|
||||||
|
// Files created AFTER startup are new sessions — detect errors normally.
|
||||||
|
if (isFirstRead) {
|
||||||
|
const isPreExistingFile = fileStats.birthtimeMs < this.instanceCreatedAt;
|
||||||
|
if (isPreExistingFile) {
|
||||||
|
this.lastProcessedLineCount.set(filePath, currentLineCount);
|
||||||
|
this.lastProcessedSize.set(filePath, processedSize);
|
||||||
|
logger.info(
|
||||||
|
`FileWatcher: Baseline established for ${filePath} (${currentLineCount} lines, ${processedSize} bytes)`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect errors in new messages
|
// Detect errors in new messages
|
||||||
// Note: We pass the offset-adjusted line numbers to errorDetector
|
// Note: We pass the offset-adjusted line numbers to errorDetector
|
||||||
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
|
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,11 @@ import {
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile, spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||||
|
|
@ -63,7 +62,6 @@ const PROBE_CACHE_TTL_MS = 60_000;
|
||||||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
const KEYCHAIN_TIMEOUT_MS = 5000;
|
|
||||||
const FS_MONITOR_POLL_MS = 2000;
|
const FS_MONITOR_POLL_MS = 2000;
|
||||||
const TASK_WAIT_FALLBACK_MS = 15_000;
|
const TASK_WAIT_FALLBACK_MS = 15_000;
|
||||||
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
||||||
|
|
@ -73,8 +71,6 @@ const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else'
|
||||||
const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const;
|
const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const;
|
||||||
const PREFLIGHT_EXPECTED = 'PONG';
|
const PREFLIGHT_EXPECTED = 'PONG';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
type TeamsBaseLocation = 'configured' | 'default';
|
type TeamsBaseLocation = 'configured' | 'default';
|
||||||
|
|
||||||
type ValidConfigProbeResult =
|
type ValidConfigProbeResult =
|
||||||
|
|
@ -170,12 +166,7 @@ interface ProvisioningRun {
|
||||||
|
|
||||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||||
|
|
||||||
type ProvisioningAuthSource =
|
type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none';
|
||||||
| 'anthropic_api_key'
|
|
||||||
| 'anthropic_auth_token'
|
|
||||||
| 'claude_code_oauth_token_env'
|
|
||||||
| 'claude_code_oauth_token_credentials'
|
|
||||||
| 'none';
|
|
||||||
|
|
||||||
interface ProvisioningEnvResolution {
|
interface ProvisioningEnvResolution {
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
|
|
@ -945,8 +936,7 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
|
||||||
return (
|
return (
|
||||||
'Claude CLI reports it is not authenticated ("Please run /login"). ' +
|
'Claude CLI reports it is not authenticated ("Please run /login"). ' +
|
||||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate, then retry. ' +
|
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate, then retry. ' +
|
||||||
'For automation/headless use, prefer `claude setup-token` and export `CLAUDE_CODE_OAUTH_TOKEN`, ' +
|
'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.'
|
||||||
'or set `ANTHROPIC_API_KEY` for `-p` mode.'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return trimmed.slice(-4000);
|
return trimmed.slice(-4000);
|
||||||
|
|
@ -1096,20 +1086,10 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
if (authSource === 'none') {
|
if (authSource === 'anthropic_api_key') {
|
||||||
// No explicit auth found. Still attempt preflight — the CLI may
|
logger.info('Auth: using explicit ANTHROPIC_API_KEY');
|
||||||
// authenticate through a mechanism we don't know about (e.g. a
|
} else if (authSource === 'anthropic_auth_token') {
|
||||||
// managed apiKeyHelper, SSO, or a future auth flow).
|
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
|
||||||
warnings.push(
|
|
||||||
'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN). ' +
|
|
||||||
'Attempting preflight check to verify if CLI can authenticate on its own.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authSource === 'anthropic_auth_token') {
|
|
||||||
warnings.push(
|
|
||||||
'Using ANTHROPIC_AUTH_TOKEN (proxy) mapped to ANTHROPIC_API_KEY for `-p` mode.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv);
|
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv);
|
||||||
|
|
@ -1185,7 +1165,7 @@ export class TeamProvisioningService {
|
||||||
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
|
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
|
||||||
error:
|
error:
|
||||||
'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' +
|
'Claude CLI is not authenticated. Run `claude auth login` (or start `claude` and run `/login`) ' +
|
||||||
'to authenticate, or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
|
'to authenticate, or set ANTHROPIC_API_KEY and try again.',
|
||||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||||
});
|
});
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
|
|
@ -1235,7 +1215,7 @@ export class TeamProvisioningService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respawn
|
// Respawn with saved context — CLI handles its own auth refresh.
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
try {
|
try {
|
||||||
child = spawnCli(ctx.claudePath, ctx.args, {
|
child = spawnCli(ctx.claudePath, ctx.args, {
|
||||||
|
|
@ -1473,13 +1453,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const prompt = buildProvisioningPrompt(request);
|
const prompt = buildProvisioningPrompt(request);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||||
if (authSource === 'none') {
|
|
||||||
logger.warn(
|
|
||||||
'No explicit auth env var found for `-p` mode. ' +
|
|
||||||
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const spawnArgs = [
|
const spawnArgs = [
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
|
|
@ -1779,13 +1753,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||||
if (authSource === 'none') {
|
|
||||||
logger.warn(
|
|
||||||
'No explicit auth env var found for `-p` mode (launch). ' +
|
|
||||||
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const launchArgs = [
|
const launchArgs = [
|
||||||
'--input-format',
|
'--input-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
|
|
@ -3278,105 +3246,14 @@ export class TeamProvisioningService {
|
||||||
return { env, authSource: 'anthropic_auth_token' };
|
return { env, authSource: 'anthropic_auth_token' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. CLAUDE_CODE_OAUTH_TOKEN already in env (e.g. from `claude setup-token`)
|
// 3. No explicit API key — let the CLI handle its own OAuth auth.
|
||||||
if (
|
// Claude CLI reads credentials from its own storage and refreshes
|
||||||
typeof env.CLAUDE_CODE_OAUTH_TOKEN === 'string' &&
|
// tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the
|
||||||
env.CLAUDE_CODE_OAUTH_TOKEN.trim().length > 0
|
// credentials file causes 401 errors because the stored token is
|
||||||
) {
|
// often stale (CLI refreshes in-memory but rarely writes back).
|
||||||
return { env, authSource: 'claude_code_oauth_token_env' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Try reading OAuth token from platform credential storage.
|
|
||||||
// macOS: Keychain (service "Claude Code-credentials")
|
|
||||||
// Linux: ~/.claude/.credentials.json
|
|
||||||
// Note: keychain tokens may be stale — Claude Code refreshes in-memory
|
|
||||||
// but does not always write back. We still try as best-effort.
|
|
||||||
const oauthToken = await this.readOAuthTokenFromStorage(home);
|
|
||||||
if (oauthToken) {
|
|
||||||
env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
|
|
||||||
return { env, authSource: 'claude_code_oauth_token_credentials' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { env, authSource: 'none' };
|
return { env, authSource: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to read the OAuth access token from platform-specific storage.
|
|
||||||
*
|
|
||||||
* On macOS: reads from the encrypted Keychain (service "Claude Code-credentials").
|
|
||||||
* On Linux: reads from ~/.claude/.credentials.json.
|
|
||||||
*
|
|
||||||
* Warning: the token retrieved here may be expired. Claude Code refreshes
|
|
||||||
* tokens in-memory but does not always persist the refreshed value back to
|
|
||||||
* the credential store. A subsequent preflight check (`claude -p "ping"`)
|
|
||||||
* will detect if the token is actually usable.
|
|
||||||
*/
|
|
||||||
private async readOAuthTokenFromStorage(home: string): Promise<string | null> {
|
|
||||||
const claudeBasePath = getClaudeBasePath();
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
const keychainToken = await this.readOAuthTokenFromKeychain();
|
|
||||||
if (keychainToken) {
|
|
||||||
return keychainToken;
|
|
||||||
}
|
|
||||||
// Fallback: ~/.claude/.credentials.json (or overridden Claude root)
|
|
||||||
return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home);
|
|
||||||
}
|
|
||||||
return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readOAuthTokenFromKeychain(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync(
|
|
||||||
'security',
|
|
||||||
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
|
||||||
{ timeout: KEYCHAIN_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
const parsed = JSON.parse(stdout.trim()) as unknown;
|
|
||||||
return this.extractOAuthAccessToken(parsed);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readOAuthTokenFromCredentialsFile(
|
|
||||||
claudeBasePath: string,
|
|
||||||
homeFallback: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
// Preferred: current Claude root (supports claudeRootPath override)
|
|
||||||
const primaryPath = path.join(claudeBasePath, '.credentials.json');
|
|
||||||
// Back-compat: legacy location under HOME
|
|
||||||
const legacyPath = path.join(homeFallback, '.claude', '.credentials.json');
|
|
||||||
try {
|
|
||||||
const raw = await fs.promises.readFile(primaryPath, 'utf8');
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
return this.extractOAuthAccessToken(parsed);
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const raw = await fs.promises.readFile(legacyPath, 'utf8');
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
return this.extractOAuthAccessToken(parsed);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractOAuthAccessToken(parsed: unknown): string | null {
|
|
||||||
if (!parsed || typeof parsed !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const root = parsed as { claudeAiOauth?: unknown };
|
|
||||||
if (!root.claudeAiOauth || typeof root.claudeAiOauth !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const oauth = root.claudeAiOauth as { accessToken?: unknown };
|
|
||||||
if (typeof oauth.accessToken !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const token = oauth.accessToken.trim();
|
|
||||||
return token.length > 0 ? token : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immediately update projectPath in config.json at launch start, before CLI spawn.
|
* Immediately update projectPath in config.json at launch start, before CLI spawn.
|
||||||
* Ensures TeamDetailView shows the correct project path even if provisioning
|
* Ensures TeamDetailView shows the correct project path even if provisioning
|
||||||
|
|
@ -4076,8 +3953,7 @@ export class TeamProvisioningService {
|
||||||
const hint = isAuthFailure
|
const hint = isAuthFailure
|
||||||
? 'Claude CLI `-p` mode is not authenticated. ' +
|
? 'Claude CLI `-p` mode is not authenticated. ' +
|
||||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
||||||
'For automation/headless use, set ANTHROPIC_API_KEY or run `claude setup-token` ' +
|
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
||||||
'and export CLAUDE_CODE_OAUTH_TOKEN.' +
|
|
||||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||||
return { warning: hint };
|
return { warning: hint };
|
||||||
|
|
|
||||||
|
|
@ -429,12 +429,6 @@ describe('FileWatcher', () => {
|
||||||
const detectPromise = new Promise<void>((resolve) => {
|
const detectPromise = new Promise<void>((resolve) => {
|
||||||
detectResolve = resolve;
|
detectResolve = resolve;
|
||||||
});
|
});
|
||||||
vi.mocked(errorDetector.detectErrors).mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
detectPromise.then(() => resolve([]));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const watcherAny = watcher as unknown as {
|
const watcherAny = watcher as unknown as {
|
||||||
detectErrorsInSessionFile: (
|
detectErrorsInSessionFile: (
|
||||||
|
|
@ -444,9 +438,27 @@ describe('FileWatcher', () => {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
processingInProgress: Set<string>;
|
processingInProgress: Set<string>;
|
||||||
pendingReprocess: Set<string>;
|
pendingReprocess: Set<string>;
|
||||||
|
instanceCreatedAt: number;
|
||||||
};
|
};
|
||||||
|
// Ensure watcher treats the file as pre-existing so first call baselines
|
||||||
|
watcherAny.instanceCreatedAt = Date.now() + 60_000;
|
||||||
|
|
||||||
// Start first call (will block on detectErrors)
|
// First call establishes baseline (skips error detection on first read)
|
||||||
|
vi.mocked(errorDetector.detectErrors).mockResolvedValue([]);
|
||||||
|
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||||
|
|
||||||
|
// Append new data so subsequent calls have new lines to process
|
||||||
|
fs.appendFileSync(filePath, jsonlLine('u2', 'world'));
|
||||||
|
|
||||||
|
// Now make detectErrors slow to simulate long processing
|
||||||
|
vi.mocked(errorDetector.detectErrors).mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
detectPromise.then(() => resolve([]));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start call that will block on detectErrors (not first read anymore)
|
||||||
const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||||
|
|
||||||
// Wait a tick so the first call enters the processing block and reaches detectErrors
|
// Wait a tick so the first call enters the processing block and reaches detectErrors
|
||||||
|
|
@ -512,7 +524,10 @@ describe('FileWatcher', () => {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
lastProcessedSize: Map<string, number>;
|
lastProcessedSize: Map<string, number>;
|
||||||
lastProcessedLineCount: Map<string, number>;
|
lastProcessedLineCount: Map<string, number>;
|
||||||
|
instanceCreatedAt: number;
|
||||||
};
|
};
|
||||||
|
// Treat file as new (created after watcher) so it goes through the full parse path
|
||||||
|
watcherAny.instanceCreatedAt = 0;
|
||||||
|
|
||||||
// First call - fallback path (no lastProcessedLineCount)
|
// First call - fallback path (no lastProcessedLineCount)
|
||||||
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||||
|
|
@ -527,6 +542,166 @@ describe('FileWatcher', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// First-Read Baseline Tests (prevents old session error flooding)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('first-read baseline behavior', () => {
|
||||||
|
it('establishes baseline without detecting errors for pre-existing files', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
useRealExistsSync();
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-baseline-'));
|
||||||
|
const projectsDir = path.join(tempDir, 'projects');
|
||||||
|
const projectDir = path.join(projectsDir, 'test-project');
|
||||||
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
|
|
||||||
|
const filePath = path.join(projectDir, 'session-1.jsonl');
|
||||||
|
// Write a file with multiple lines (simulating an existing session with errors)
|
||||||
|
fs.writeFileSync(
|
||||||
|
filePath,
|
||||||
|
jsonlLine('u1', 'hello') + jsonlLine('u2', 'world') + jsonlLine('u3', 'error line'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataCache = new DataCache(50, 10, false);
|
||||||
|
const notificationManager = createMockNotificationManager();
|
||||||
|
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
|
||||||
|
watcher.setNotificationManager(notificationManager);
|
||||||
|
|
||||||
|
// Simulate watcher starting well after the file was created
|
||||||
|
const watcherAny = watcher as unknown as {
|
||||||
|
detectErrorsInSessionFile: (
|
||||||
|
projectId: string,
|
||||||
|
sessionId: string,
|
||||||
|
filePath: string
|
||||||
|
) => Promise<void>;
|
||||||
|
lastProcessedLineCount: Map<string, number>;
|
||||||
|
lastProcessedSize: Map<string, number>;
|
||||||
|
instanceCreatedAt: number;
|
||||||
|
};
|
||||||
|
watcherAny.instanceCreatedAt = Date.now() + 60_000; // watcher "started" in the future
|
||||||
|
|
||||||
|
vi.mocked(errorDetector.detectErrors).mockClear();
|
||||||
|
|
||||||
|
// First read should establish baseline, NOT detect errors
|
||||||
|
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||||
|
|
||||||
|
// errorDetector.detectErrors should NOT have been called
|
||||||
|
expect(errorDetector.detectErrors).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Baseline tracking should be established
|
||||||
|
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3);
|
||||||
|
expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size);
|
||||||
|
|
||||||
|
// notificationManager.addError should NOT have been called
|
||||||
|
expect(notificationManager.addError).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
watcher.stop();
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects errors only in new data after baseline is established', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
useRealExistsSync();
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-post-baseline-'));
|
||||||
|
const projectsDir = path.join(tempDir, 'projects');
|
||||||
|
const projectDir = path.join(projectsDir, 'test-project');
|
||||||
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
|
|
||||||
|
const filePath = path.join(projectDir, 'session-1.jsonl');
|
||||||
|
// Initial content (old session data)
|
||||||
|
fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'world'), 'utf8');
|
||||||
|
|
||||||
|
const dataCache = new DataCache(50, 10, false);
|
||||||
|
const notificationManager = createMockNotificationManager();
|
||||||
|
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
|
||||||
|
watcher.setNotificationManager(notificationManager);
|
||||||
|
|
||||||
|
// Simulate watcher starting well after the file was created
|
||||||
|
const watcherAny = watcher as unknown as {
|
||||||
|
detectErrorsInSessionFile: (
|
||||||
|
projectId: string,
|
||||||
|
sessionId: string,
|
||||||
|
filePath: string
|
||||||
|
) => Promise<void>;
|
||||||
|
lastProcessedLineCount: Map<string, number>;
|
||||||
|
instanceCreatedAt: number;
|
||||||
|
};
|
||||||
|
watcherAny.instanceCreatedAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
vi.mocked(errorDetector.detectErrors).mockClear();
|
||||||
|
vi.mocked(errorDetector.detectErrors).mockResolvedValue([]);
|
||||||
|
|
||||||
|
// First read: baseline only
|
||||||
|
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||||
|
expect(errorDetector.detectErrors).not.toHaveBeenCalled();
|
||||||
|
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2);
|
||||||
|
|
||||||
|
// Append new data
|
||||||
|
fs.appendFileSync(filePath, jsonlLine('u3', 'new error'));
|
||||||
|
|
||||||
|
// Second read: should detect errors in new data only
|
||||||
|
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||||
|
|
||||||
|
expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify only the new message was passed to detectErrors
|
||||||
|
const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0];
|
||||||
|
expect(callArgs[0]).toHaveLength(1); // only 1 new message
|
||||||
|
|
||||||
|
// Tracking should now reflect all 3 lines
|
||||||
|
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(3);
|
||||||
|
|
||||||
|
watcher.stop();
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects errors immediately for files created after watcher startup', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
useRealExistsSync();
|
||||||
|
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-newfile-'));
|
||||||
|
const projectsDir = path.join(tempDir, 'projects');
|
||||||
|
const projectDir = path.join(projectsDir, 'test-project');
|
||||||
|
fs.mkdirSync(projectDir, { recursive: true });
|
||||||
|
|
||||||
|
const dataCache = new DataCache(50, 10, false);
|
||||||
|
const notificationManager = createMockNotificationManager();
|
||||||
|
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
|
||||||
|
watcher.setNotificationManager(notificationManager);
|
||||||
|
|
||||||
|
vi.mocked(errorDetector.detectErrors).mockClear();
|
||||||
|
vi.mocked(errorDetector.detectErrors).mockResolvedValue([]);
|
||||||
|
|
||||||
|
// instanceCreatedAt is already set to "now" by the constructor,
|
||||||
|
// and the file created below will have birthtimeMs >= instanceCreatedAt,
|
||||||
|
// so it will be treated as a new file (no baseline skip)
|
||||||
|
const filePath = path.join(projectDir, 'session-new.jsonl');
|
||||||
|
fs.writeFileSync(filePath, jsonlLine('u1', 'hello') + jsonlLine('u2', 'error'), 'utf8');
|
||||||
|
|
||||||
|
const watcherAny = watcher as unknown as {
|
||||||
|
detectErrorsInSessionFile: (
|
||||||
|
projectId: string,
|
||||||
|
sessionId: string,
|
||||||
|
filePath: string
|
||||||
|
) => Promise<void>;
|
||||||
|
lastProcessedLineCount: Map<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// First read of a NEW file should detect errors (not baseline-skip)
|
||||||
|
await watcherAny.detectErrorsInSessionFile('test-project', 'session-new', filePath);
|
||||||
|
|
||||||
|
expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = vi.mocked(errorDetector.detectErrors).mock.calls[0];
|
||||||
|
expect(callArgs[0]).toHaveLength(2); // all messages scanned
|
||||||
|
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2);
|
||||||
|
|
||||||
|
watcher.stop();
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Timer Lifecycle Tests
|
// Timer Lifecycle Tests
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue