Files
Codeman/src/session-lifecycle-log.ts
T
arkonandClaude Opus 4.8 b00a680d42 feat(web): session detach/undock + beta instance isolation (port 5000)
Detach a session tab into its own browser window and back.

Detach/undock:
- GET /session/:id serves the SPA in "solo mode", reusing the existing
  client (terminal, local-echo overlay, reconnect) so no terminal code is
  duplicated. One PTY already fans out to N SSE/WS clients, so a detached
  window is just another live client — no server fan-out work was needed.
- A pop-out icon per tab; detached tabs show a badge and focus the popup on
  click; closing the popup re-docks. Cross-window state via BroadcastChannel
  plus a WindowProxy poll, and survives a dashboard reload (roll-call).
  app.detachSession(id) is a single idempotent entry point (future gesture
  hook). <base href="/"> so relative assets resolve under /session/:id.

Beta-branch isolation (so it can run alongside a prod Codeman):
- Default port 3000 -> 5000.
- New src/config/instance.ts derives the data dir and tmux socket from
  CODEMAN_INSTANCE (default "beta"): ~/.codeman-beta + tmux -L codeman-beta.
  Every ~/.codeman path now goes through dataPath()/getDataDir() (state,
  mux-sessions, settings, push keys, lifecycle log, screenshots, certs,
  linked-cases, subagent window state). Overridable via CODEMAN_INSTANCE /
  CODEMAN_DATA_DIR / CODEMAN_TMUX_SOCKET. Prevents a second instance from
  discovering and attaching PTYs to the first instance's live tmux sessions.

Verified: tsc / eslint / prettier / lockfile clean; Playwright E2E (27 checks)
for detach/solo/redock; default isolation confirmed to see zero real sessions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 03:53:54 +02: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 } from 'node:path';
import type { LifecycleEventType, LifecycleEntry } from './types.js';
import { dataPath } from './config/instance.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 || dataPath('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;
}