Files
Codeman/src/session-lifecycle-log.ts
T
480584de63 chore: project setup and tooling cleanup (#26)
* add project setup

* add tools to eslint

* remove contributing.md

* update claude md

* chore: simplify CI to single Node.js version (22)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* use node 20

* adjust formatting

* fix linting

* format

* fix layout

* fix: align CI node version to .nvmrc (22), remove redundant gotcha

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
2026-02-27 13:16:23 +01:00

115 lines
3.4 KiB
TypeScript

/**
* @fileoverview Append-only JSONL audit log for session lifecycle events.
*
* Records every session creation, start, exit, deletion, recovery, and server
* start/stop to ~/.codeman/session-lifecycle.jsonl. Survives server restarts
* (unlike RunSummary which is in-memory only).
*
* @module session-lifecycle-log
*/
import { appendFile, readFile, writeFile } from 'node:fs/promises';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import type { LifecycleEventType, LifecycleEntry } from './types.js';
const MAX_LINES = 10_000;
const TRIM_TO = 8_000;
export class SessionLifecycleLog {
private filePath: string;
private writeQueue: Promise<void> = Promise.resolve();
constructor(filePath?: string) {
this.filePath = filePath || join(homedir(), '.codeman', 'session-lifecycle.jsonl');
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
/**
* Append a lifecycle event. Fire-and-forget — errors are logged but never thrown.
*/
log(entry: Omit<LifecycleEntry, 'ts'> & { ts?: number }): void {
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
// Chain writes to prevent interleaving
this.writeQueue = this.writeQueue
.then(() => appendFile(this.filePath, line, 'utf-8'))
.catch((err) => {
console.error('[LifecycleLog] Failed to write:', err);
});
}
/**
* Query the log file with optional filters.
*/
async query(opts?: {
sessionId?: string;
event?: LifecycleEventType;
since?: number;
limit?: number;
}): Promise<LifecycleEntry[]> {
const limit = opts?.limit ?? 200;
let raw: string;
try {
raw = await readFile(this.filePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
const lines = raw.trim().split('\n').filter(Boolean);
const entries: LifecycleEntry[] = [];
// Parse in reverse (newest first) for efficiency with limit
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
try {
const entry = JSON.parse(lines[i]) as LifecycleEntry;
if (opts?.sessionId && entry.sessionId !== opts.sessionId) continue;
if (opts?.event && entry.event !== opts.event) continue;
if (opts?.since && entry.ts < opts.since) continue;
entries.push(entry);
} catch {
// Skip malformed lines
}
}
return entries;
}
/**
* Trim the log file if it exceeds MAX_LINES. Called on server start.
*/
async trimIfNeeded(): Promise<void> {
let raw: string;
try {
raw = await readFile(this.filePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
throw err;
}
const lines = raw.trim().split('\n').filter(Boolean);
if (lines.length <= MAX_LINES) return;
const trimmed = lines.slice(-TRIM_TO);
await writeFile(this.filePath, trimmed.join('\n') + '\n', 'utf-8');
console.log(`[LifecycleLog] Trimmed from ${lines.length} to ${trimmed.length} entries`);
}
}
// Singleton
let instance: SessionLifecycleLog | null = null;
export function getLifecycleLog(filePath?: string): SessionLifecycleLog {
if (!instance) {
instance = new SessionLifecycleLog(filePath);
}
return instance;
}