Skip to content

Commit 623d1fe

Browse files
lewisnsmithclaude
andcommitted
fix: harden critical paths — SIGTERM test, retry state machine, PD extraction
Three reviews (admissions, Anthropic staff, CEO hold-scope) converged on the same gaps. This commit addresses them: - Add SIGTERM integration test validating log flush on signal (was untested) - Refactor retry manager to explicit discriminated union state (was two synchronized maps that could desync) - Remove unused lastLineCount variable (lint fix) - Extract schema compression and file locking into dedicated modules (pd-schema.ts, file-lock.ts) for the PD redesign - Fix proxy signal handlers to delegate cleanup through upstream close event with 5s safety timeout (was calling process.exit directly, losing PD usage data) - Add CLAUDE.md with project conventions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ca64f40 commit 623d1fe

8 files changed

Lines changed: 336 additions & 152 deletions

File tree

CLAUDE.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Flight — MCP Flight Recorder & Token Optimizer
2+
3+
## Stack
4+
5+
TypeScript, Node 20+, ESM modules, Vitest, tsup, Commander
6+
7+
## Project Structure
8+
9+
```
10+
src/
11+
cli.ts — Commander CLI entry point
12+
proxy.ts — stdio proxy: spawn upstream, bidirectional JSON-RPC forwarding
13+
json-rpc.ts — streaming JSON-RPC parser
14+
logger.ts — session logger with async write queue and alert detection
15+
progressive-disclosure.ts — PD handler: phase logic, usage tracking, tool filtering
16+
pd-schema.ts — pure schema compression utilities
17+
file-lock.ts — advisory file locking (O_CREAT|O_EXCL)
18+
retry.ts — automatic retry manager for transient MCP errors
19+
hooks.ts — Claude Code hook installation/removal
20+
init.ts — Claude/Claude Code config file management
21+
setup.ts — interactive setup wizard
22+
summary.ts — session summary computation
23+
stats.ts — usage statistics
24+
lifecycle.ts — log compression and garbage collection
25+
export.ts — CSV/JSONL export
26+
replay.ts — tool call replay from logs
27+
log-commands.ts — CLI subcommands for log inspection
28+
index.ts — public API re-exports
29+
```
30+
31+
## Commands
32+
33+
```bash
34+
npm run build # Build with tsup
35+
npm run test # Run tests (vitest)
36+
npm run lint # ESLint
37+
npm run typecheck # tsc --noEmit (src + test)
38+
npm run check # lint + typecheck + test (use before committing)
39+
```
40+
41+
## Key Patterns
42+
43+
- **Handler result objects**`PDResponseResult` carries rewritten responses, log metadata, and status messages in one return value. Callers branch on fields, not exceptions.
44+
- **Async write queue** — Logger batches writes with a flush timer; `closeSync()` drains synchronously for signal handlers.
45+
- **File locking**`acquireLock(path)` uses `O_CREAT|O_EXCL` with PID-based stale detection. Returns `""` on timeout (best-effort, never blocks).
46+
- **JSON-RPC streaming**`parseJsonRpcStream` is a newline-delimited JSON parser on Node readable streams, emitting typed `JsonRpcMessage` events.
47+
- **Progressive disclosure phases** — Phase 1 (observation), Phase 2 (schema compression), Phase 3 (compression + filtering with `discover_tools`).
48+
49+
## Testing
50+
51+
- Tests live in `test/` alongside source
52+
- Mock MCP server pattern: spawn a test server, connect via proxy, assert on JSON-RPC messages
53+
- `test/simulate/` contains validation harnesses for Claude API compatibility
54+
- Run `npm run test` — all tests should pass before any PR
55+
56+
## Git Conventions
57+
58+
- Commit format: `<type>: <description>` (feat, fix, refactor, docs, test, chore)
59+
- Messages explain WHY, not what
60+
- One logical change per commit
61+
62+
See [ARCHITECTURE.md](ARCHITECTURE.md) for deeper design details.

src/file-lock.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Advisory file locking using atomic O_CREAT|O_EXCL.
3+
* Caller provides the full lock file path, making this module reusable.
4+
*/
5+
6+
import { mkdir, open, readFile, stat, unlink } from "node:fs/promises";
7+
import { dirname } from "node:path";
8+
9+
const LOCK_RETRY_MS = 50;
10+
const LOCK_MAX_WAIT_MS = 2000;
11+
const LOCK_STALE_MS = 10_000;
12+
13+
/**
14+
* Acquire an advisory file lock at `lockPath`.
15+
* Returns the lock path on success, or "" on timeout (best-effort — don't block the caller).
16+
*/
17+
export async function acquireLock(lockPath: string): Promise<string> {
18+
await mkdir(dirname(lockPath), { recursive: true });
19+
20+
const deadline = Date.now() + LOCK_MAX_WAIT_MS;
21+
while (Date.now() < deadline) {
22+
try {
23+
// O_CREAT | O_EXCL — atomic creation, fails if file exists
24+
const handle = await open(lockPath, "wx");
25+
await handle.writeFile(String(process.pid));
26+
await handle.close();
27+
return lockPath;
28+
} catch (err: unknown) {
29+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
30+
// Check for stale lock
31+
try {
32+
const content = await readFile(lockPath, "utf-8");
33+
const lockStat = await stat(lockPath);
34+
const age = Date.now() - lockStat.mtimeMs;
35+
if (age > LOCK_STALE_MS) {
36+
// Stale lock — remove and retry
37+
try { await unlink(lockPath); } catch { /* race with another cleaner */ }
38+
continue;
39+
}
40+
// Check if the PID is still alive
41+
const pid = parseInt(content, 10);
42+
if (pid && !isNaN(pid)) {
43+
try { process.kill(pid, 0); } catch {
44+
// Process is dead — stale lock
45+
try { await unlink(lockPath); } catch { /* race */ }
46+
continue;
47+
}
48+
}
49+
} catch {
50+
// Can't read lock file, will retry
51+
}
52+
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
53+
continue;
54+
}
55+
throw err;
56+
}
57+
}
58+
// Timeout — proceed without lock (best-effort, don't block the session)
59+
return "";
60+
}
61+
62+
/**
63+
* Release an advisory file lock. No-op if lockPath is empty (lock was never acquired).
64+
*/
65+
export async function releaseLock(lockPath: string): Promise<void> {
66+
if (!lockPath) return;
67+
try { await unlink(lockPath); } catch { /* already removed */ }
68+
}

src/log-commands.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,11 @@ export async function tailSession(sessionId?: string): Promise<void> {
187187
console.log(`${C.dim} Press Ctrl+C to stop${C.reset}\n`);
188188

189189
// Print existing entries
190-
let lastLineCount = 0;
191190
try {
192191
const entries = await readLogEntries(file);
193192
for (const entry of entries) {
194193
console.log(formatEntryLine(entry));
195194
}
196-
lastLineCount = entries.length;
197195
} catch {
198196
// File might be empty
199197
}

src/pd-schema.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Schema compression utilities for progressive disclosure.
3+
* Pure functions — no I/O, no side effects.
4+
*/
5+
6+
export function estimateTokens(obj: unknown): number {
7+
return Math.ceil(JSON.stringify(obj).length / 4);
8+
}
9+
10+
/**
11+
* Compress a JSON Schema by stripping property descriptions, examples,
12+
* defaults, $comment, and redundant additionalProperties on nested objects.
13+
* Preserves: property names, type, required, enum, oneOf/anyOf/allOf.
14+
*/
15+
export function compressSchema(schema: Record<string, unknown>): Record<string, unknown> {
16+
return compressNode(schema, /* isRoot */ true) as Record<string, unknown>;
17+
}
18+
19+
function compressNode(node: unknown, isRoot: boolean): unknown {
20+
if (node === null || node === undefined || typeof node !== "object") {
21+
return node;
22+
}
23+
24+
if (Array.isArray(node)) {
25+
return node.map((item) => compressNode(item, false));
26+
}
27+
28+
const obj = node as Record<string, unknown>;
29+
const result: Record<string, unknown> = {};
30+
31+
for (const [key, value] of Object.entries(obj)) {
32+
// Strip fields on non-root nodes
33+
if (!isRoot) {
34+
if (key === "description") continue;
35+
if (key === "examples") continue;
36+
if (key === "default") continue;
37+
if (key === "$comment") continue;
38+
if (key === "additionalProperties" && value === false) continue;
39+
}
40+
41+
// Recurse into nested structures
42+
if (key === "properties" && typeof value === "object" && value !== null) {
43+
const props = value as Record<string, unknown>;
44+
const compressed: Record<string, unknown> = {};
45+
for (const [propName, propSchema] of Object.entries(props)) {
46+
compressed[propName] = compressNode(propSchema, false);
47+
}
48+
result[key] = compressed;
49+
} else if (key === "items" && typeof value === "object") {
50+
result[key] = compressNode(value, false);
51+
} else if ((key === "oneOf" || key === "anyOf" || key === "allOf") && Array.isArray(value)) {
52+
result[key] = value.map((item) => compressNode(item, false));
53+
} else {
54+
result[key] = value;
55+
}
56+
}
57+
58+
return result;
59+
}

0 commit comments

Comments
 (0)