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": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"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; }"
|
||||
}
|
||||
]
|
||||
}
|
||||
"alwaysThinkingEnabled": true,
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Edit",
|
||||
"Bash",
|
||||
"ReadFile(*)",
|
||||
"Web Search",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
"Fetch"
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"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/'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +97,8 @@ export class FileWatcher extends EventEmitter {
|
|||
private pendingReprocess = new Set<string>();
|
||||
/** Flag to prevent reuse after disposal */
|
||||
private disposed = false;
|
||||
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */
|
||||
private readonly instanceCreatedAt = Date.now();
|
||||
|
||||
constructor(
|
||||
dataCache: DataCache,
|
||||
|
|
@ -751,6 +753,7 @@ export class FileWatcher extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
const isFirstRead = lastLineCount === 0 && lastSize === 0;
|
||||
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
|
||||
let newMessages: ParsedMessage[] = [];
|
||||
let currentLineCount: number;
|
||||
|
|
@ -777,6 +780,22 @@ export class FileWatcher extends EventEmitter {
|
|||
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
|
||||
// Note: We pass the offset-adjusted line numbers to errorDetector
|
||||
const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath);
|
||||
|
|
|
|||
|
|
@ -20,12 +20,11 @@ import {
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile, spawn } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
|
|
@ -63,7 +62,6 @@ const PROBE_CACHE_TTL_MS = 60_000;
|
|||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const KEYCHAIN_TIMEOUT_MS = 5000;
|
||||
const FS_MONITOR_POLL_MS = 2000;
|
||||
const TASK_WAIT_FALLBACK_MS = 15_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_EXPECTED = 'PONG';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type TeamsBaseLocation = 'configured' | 'default';
|
||||
|
||||
type ValidConfigProbeResult =
|
||||
|
|
@ -170,12 +166,7 @@ interface ProvisioningRun {
|
|||
|
||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
type ProvisioningAuthSource =
|
||||
| 'anthropic_api_key'
|
||||
| 'anthropic_auth_token'
|
||||
| 'claude_code_oauth_token_env'
|
||||
| 'claude_code_oauth_token_credentials'
|
||||
| 'none';
|
||||
type ProvisioningAuthSource = 'anthropic_api_key' | 'anthropic_auth_token' | 'none';
|
||||
|
||||
interface ProvisioningEnvResolution {
|
||||
env: NodeJS.ProcessEnv;
|
||||
|
|
@ -945,8 +936,7 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText:
|
|||
return (
|
||||
'Claude CLI reports it is not authenticated ("Please run /login"). ' +
|
||||
'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`, ' +
|
||||
'or set `ANTHROPIC_API_KEY` for `-p` mode.'
|
||||
'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.'
|
||||
);
|
||||
}
|
||||
return trimmed.slice(-4000);
|
||||
|
|
@ -1096,20 +1086,10 @@ export class TeamProvisioningService {
|
|||
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (authSource === 'none') {
|
||||
// No explicit auth found. Still attempt preflight — the CLI may
|
||||
// authenticate through a mechanism we don't know about (e.g. a
|
||||
// managed apiKeyHelper, SSO, or a future auth flow).
|
||||
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.'
|
||||
);
|
||||
if (authSource === 'anthropic_api_key') {
|
||||
logger.info('Auth: using explicit ANTHROPIC_API_KEY');
|
||||
} else if (authSource === 'anthropic_auth_token') {
|
||||
logger.info('Auth: using ANTHROPIC_AUTH_TOKEN mapped to ANTHROPIC_API_KEY');
|
||||
}
|
||||
|
||||
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', {
|
||||
error:
|
||||
'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),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
|
|
@ -1235,7 +1215,7 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Respawn
|
||||
// Respawn with saved context — CLI handles its own auth refresh.
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawnCli(ctx.claudePath, ctx.args, {
|
||||
|
|
@ -1473,13 +1453,7 @@ export class TeamProvisioningService {
|
|||
|
||||
const prompt = buildProvisioningPrompt(request);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv, authSource } = 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 { env: shellEnv } = await this.buildProvisioningEnv();
|
||||
const spawnArgs = [
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
|
|
@ -1779,13 +1753,7 @@ export class TeamProvisioningService {
|
|||
|
||||
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv, authSource } = 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 { env: shellEnv } = await this.buildProvisioningEnv();
|
||||
const launchArgs = [
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
|
|
@ -3278,105 +3246,14 @@ export class TeamProvisioningService {
|
|||
return { env, authSource: 'anthropic_auth_token' };
|
||||
}
|
||||
|
||||
// 3. CLAUDE_CODE_OAUTH_TOKEN already in env (e.g. from `claude setup-token`)
|
||||
if (
|
||||
typeof env.CLAUDE_CODE_OAUTH_TOKEN === 'string' &&
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN.trim().length > 0
|
||||
) {
|
||||
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' };
|
||||
}
|
||||
|
||||
// 3. No explicit API key — let the CLI handle its own OAuth auth.
|
||||
// Claude CLI reads credentials from its own storage and refreshes
|
||||
// tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the
|
||||
// credentials file causes 401 errors because the stored token is
|
||||
// often stale (CLI refreshes in-memory but rarely writes back).
|
||||
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.
|
||||
* Ensures TeamDetailView shows the correct project path even if provisioning
|
||||
|
|
@ -4076,8 +3953,7 @@ export class TeamProvisioningService {
|
|||
const hint = isAuthFailure
|
||||
? 'Claude CLI `-p` mode is not authenticated. ' +
|
||||
'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` ' +
|
||||
'and export CLAUDE_CODE_OAUTH_TOKEN.' +
|
||||
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
return { warning: hint };
|
||||
|
|
|
|||
|
|
@ -429,12 +429,6 @@ describe('FileWatcher', () => {
|
|||
const detectPromise = new Promise<void>((resolve) => {
|
||||
detectResolve = resolve;
|
||||
});
|
||||
vi.mocked(errorDetector.detectErrors).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
detectPromise.then(() => resolve([]));
|
||||
})
|
||||
);
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
detectErrorsInSessionFile: (
|
||||
|
|
@ -444,9 +438,27 @@ describe('FileWatcher', () => {
|
|||
) => Promise<void>;
|
||||
processingInProgress: 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);
|
||||
|
||||
// Wait a tick so the first call enters the processing block and reaches detectErrors
|
||||
|
|
@ -512,7 +524,10 @@ describe('FileWatcher', () => {
|
|||
) => Promise<void>;
|
||||
lastProcessedSize: 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)
|
||||
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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue