mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
115 lines
3.4 KiB
TypeScript
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;
|
|
}
|