@hellcoder/companion 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, appendFileSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { BackendType } from "./session-types.js";
|
|
5
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
6
|
+
import { countFileLines } from "./fs-utils.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_LINES = 1_000_000;
|
|
9
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
10
|
+
|
|
11
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface RecordingHeader {
|
|
14
|
+
_header: true;
|
|
15
|
+
version: 1;
|
|
16
|
+
session_id: string;
|
|
17
|
+
backend_type: BackendType;
|
|
18
|
+
started_at: number;
|
|
19
|
+
cwd: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RecordingDirection = "in" | "out";
|
|
23
|
+
export type RecordingChannel = "cli" | "browser";
|
|
24
|
+
|
|
25
|
+
export type RecordingLifecycleEvent =
|
|
26
|
+
| "ws_open"
|
|
27
|
+
| "ws_close"
|
|
28
|
+
| "ws_error"
|
|
29
|
+
| "reconnect_attempt"
|
|
30
|
+
| "reconnect_success";
|
|
31
|
+
|
|
32
|
+
export interface RecordingEntry {
|
|
33
|
+
ts: number;
|
|
34
|
+
dir: RecordingDirection;
|
|
35
|
+
raw: string;
|
|
36
|
+
ch: RecordingChannel;
|
|
37
|
+
/** Optional connection lifecycle event (for disconnection diagnostics). */
|
|
38
|
+
event?: RecordingLifecycleEvent;
|
|
39
|
+
/** Optional metadata for lifecycle events (e.g. close code, error message). */
|
|
40
|
+
meta?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RecordingFileMeta {
|
|
44
|
+
filename: string;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
backendType: string;
|
|
47
|
+
startedAt: string;
|
|
48
|
+
/** Number of lines in the file (header + entries). */
|
|
49
|
+
lines: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── SessionRecorder ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Writes raw messages for a single session to a JSONL file.
|
|
56
|
+
* First line is a header with session metadata; subsequent lines are entries.
|
|
57
|
+
* Tracks its own line count so the manager can enforce the global limit.
|
|
58
|
+
*/
|
|
59
|
+
export class SessionRecorder {
|
|
60
|
+
readonly filePath: string;
|
|
61
|
+
private closed = false;
|
|
62
|
+
private _recordWriteErrorLogged = false;
|
|
63
|
+
/** Number of lines written (1 for the header at construction). */
|
|
64
|
+
lineCount = 1;
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
sessionId: string,
|
|
68
|
+
backendType: BackendType,
|
|
69
|
+
cwd: string,
|
|
70
|
+
outputDir: string,
|
|
71
|
+
) {
|
|
72
|
+
const ts = new Date().toISOString().replace(/:/g, "-");
|
|
73
|
+
const suffix = randomBytes(3).toString("hex");
|
|
74
|
+
const filename = `${sessionId}_${backendType}_${ts}_${suffix}.jsonl`;
|
|
75
|
+
this.filePath = join(outputDir, filename);
|
|
76
|
+
|
|
77
|
+
const header: RecordingHeader = {
|
|
78
|
+
_header: true,
|
|
79
|
+
version: 1,
|
|
80
|
+
session_id: sessionId,
|
|
81
|
+
backend_type: backendType,
|
|
82
|
+
started_at: Date.now(),
|
|
83
|
+
cwd,
|
|
84
|
+
};
|
|
85
|
+
appendFileSync(this.filePath, JSON.stringify(header) + "\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
record(dir: RecordingDirection, raw: string, channel: RecordingChannel): void {
|
|
89
|
+
if (this.closed) return;
|
|
90
|
+
const entry: RecordingEntry = {
|
|
91
|
+
ts: Date.now(),
|
|
92
|
+
dir,
|
|
93
|
+
raw,
|
|
94
|
+
ch: channel,
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
98
|
+
this.lineCount++;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Never throw — recording must not disrupt normal operation.
|
|
101
|
+
// But log once so operators can diagnose disk/permission issues.
|
|
102
|
+
if (!this._recordWriteErrorLogged) {
|
|
103
|
+
this._recordWriteErrorLogged = true;
|
|
104
|
+
console.warn(`[recorder] Write failed for ${this.filePath}: ${err instanceof Error ? err.message : err}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Record a connection lifecycle event (open, close, error, reconnect). */
|
|
110
|
+
recordEvent(
|
|
111
|
+
event: RecordingLifecycleEvent,
|
|
112
|
+
channel: RecordingChannel,
|
|
113
|
+
meta?: Record<string, unknown>,
|
|
114
|
+
): void {
|
|
115
|
+
if (this.closed) return;
|
|
116
|
+
const entry: RecordingEntry = {
|
|
117
|
+
ts: Date.now(),
|
|
118
|
+
dir: "in",
|
|
119
|
+
raw: "",
|
|
120
|
+
ch: channel,
|
|
121
|
+
event,
|
|
122
|
+
...(meta ? { meta } : {}),
|
|
123
|
+
};
|
|
124
|
+
try {
|
|
125
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
|
|
126
|
+
this.lineCount++;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (!this._recordWriteErrorLogged) {
|
|
129
|
+
this._recordWriteErrorLogged = true;
|
|
130
|
+
console.warn(`[recorder] Write failed for ${this.filePath}: ${err instanceof Error ? err.message : err}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
close(): void {
|
|
136
|
+
this.closed = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── RecorderManager ─────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Manages recording for all sessions.
|
|
144
|
+
*
|
|
145
|
+
* Always enabled by default. Disable explicitly with COMPANION_RECORD=0.
|
|
146
|
+
*
|
|
147
|
+
* Automatic rotation: when total lines across all recording files exceed
|
|
148
|
+
* maxLines (default 1 000 000, override with COMPANION_RECORDINGS_MAX_LINES),
|
|
149
|
+
* the oldest files are deleted until we're back under the limit.
|
|
150
|
+
*/
|
|
151
|
+
export class RecorderManager {
|
|
152
|
+
private globalEnabled: boolean;
|
|
153
|
+
private recordingsDir: string;
|
|
154
|
+
private maxLines: number;
|
|
155
|
+
private perSessionEnabled = new Set<string>();
|
|
156
|
+
private perSessionDisabled = new Set<string>();
|
|
157
|
+
private recorders = new Map<string, SessionRecorder>();
|
|
158
|
+
private dirCreated = false;
|
|
159
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
160
|
+
|
|
161
|
+
constructor(options?: {
|
|
162
|
+
globalEnabled?: boolean;
|
|
163
|
+
recordingsDir?: string;
|
|
164
|
+
maxLines?: number;
|
|
165
|
+
}) {
|
|
166
|
+
this.globalEnabled = options?.globalEnabled ?? RecorderManager.resolveEnabled();
|
|
167
|
+
this.recordingsDir =
|
|
168
|
+
options?.recordingsDir ??
|
|
169
|
+
process.env.COMPANION_RECORDINGS_DIR ??
|
|
170
|
+
join(COMPANION_HOME, "recordings");
|
|
171
|
+
this.maxLines =
|
|
172
|
+
options?.maxLines ??
|
|
173
|
+
(Number(process.env.COMPANION_RECORDINGS_MAX_LINES) || DEFAULT_MAX_LINES);
|
|
174
|
+
|
|
175
|
+
if (this.globalEnabled) {
|
|
176
|
+
// Run cleanup at startup (async, non-blocking) and periodically
|
|
177
|
+
this.cleanup();
|
|
178
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
179
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Always on unless explicitly disabled with COMPANION_RECORD=0|false.
|
|
185
|
+
*/
|
|
186
|
+
private static resolveEnabled(): boolean {
|
|
187
|
+
const env = process.env.COMPANION_RECORD;
|
|
188
|
+
if (env === "0" || env === "false") return false;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
isGloballyEnabled(): boolean {
|
|
193
|
+
return this.globalEnabled;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getRecordingsDir(): string {
|
|
197
|
+
return this.recordingsDir;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
getMaxLines(): number {
|
|
201
|
+
return this.maxLines;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
isRecording(sessionId: string): boolean {
|
|
205
|
+
if (this.perSessionDisabled.has(sessionId)) return false;
|
|
206
|
+
return this.globalEnabled || this.perSessionEnabled.has(sessionId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
enableForSession(sessionId: string): void {
|
|
210
|
+
this.perSessionDisabled.delete(sessionId);
|
|
211
|
+
this.perSessionEnabled.add(sessionId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
disableForSession(sessionId: string): void {
|
|
215
|
+
this.perSessionEnabled.delete(sessionId);
|
|
216
|
+
this.perSessionDisabled.add(sessionId);
|
|
217
|
+
this.stopRecording(sessionId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Record a raw message. No-op if recording is disabled for this session.
|
|
222
|
+
* Lazily creates the SessionRecorder on first call.
|
|
223
|
+
*/
|
|
224
|
+
record(
|
|
225
|
+
sessionId: string,
|
|
226
|
+
dir: RecordingDirection,
|
|
227
|
+
raw: string,
|
|
228
|
+
channel: RecordingChannel,
|
|
229
|
+
backendType: BackendType,
|
|
230
|
+
cwd: string,
|
|
231
|
+
): void {
|
|
232
|
+
if (!this.isRecording(sessionId)) return;
|
|
233
|
+
|
|
234
|
+
let recorder = this.recorders.get(sessionId);
|
|
235
|
+
if (!recorder) {
|
|
236
|
+
this.ensureDir();
|
|
237
|
+
recorder = new SessionRecorder(sessionId, backendType, cwd, this.recordingsDir);
|
|
238
|
+
this.recorders.set(sessionId, recorder);
|
|
239
|
+
}
|
|
240
|
+
recorder.record(dir, raw, channel);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Record a connection lifecycle event for diagnostics. */
|
|
244
|
+
recordEvent(
|
|
245
|
+
sessionId: string,
|
|
246
|
+
event: RecordingLifecycleEvent,
|
|
247
|
+
channel: RecordingChannel,
|
|
248
|
+
meta?: Record<string, unknown>,
|
|
249
|
+
): void {
|
|
250
|
+
const recorder = this.recorders.get(sessionId);
|
|
251
|
+
if (recorder) {
|
|
252
|
+
recorder.recordEvent(event, channel, meta);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
stopRecording(sessionId: string): void {
|
|
257
|
+
const recorder = this.recorders.get(sessionId);
|
|
258
|
+
if (recorder) {
|
|
259
|
+
recorder.close();
|
|
260
|
+
this.recorders.delete(sessionId);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getRecordingStatus(sessionId: string): { filePath?: string } {
|
|
265
|
+
const recorder = this.recorders.get(sessionId);
|
|
266
|
+
return recorder ? { filePath: recorder.filePath } : {};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
listRecordings(): RecordingFileMeta[] {
|
|
270
|
+
try {
|
|
271
|
+
const files = readdirSync(this.recordingsDir);
|
|
272
|
+
return files
|
|
273
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
274
|
+
.map((filename) => {
|
|
275
|
+
// Format: {sessionId}_{backendType}_{ISO-timestamp}_{suffix}.jsonl
|
|
276
|
+
const withoutExt = filename.replace(/\.jsonl$/, "");
|
|
277
|
+
const firstUnderscore = withoutExt.indexOf("_");
|
|
278
|
+
const secondUnderscore = withoutExt.indexOf("_", firstUnderscore + 1);
|
|
279
|
+
if (firstUnderscore === -1 || secondUnderscore === -1) {
|
|
280
|
+
return { filename, sessionId: "", backendType: "", startedAt: "", lines: 0 };
|
|
281
|
+
}
|
|
282
|
+
// Count lines — fast: just count newlines
|
|
283
|
+
const lines = countFileLines(join(this.recordingsDir, filename));
|
|
284
|
+
return {
|
|
285
|
+
filename,
|
|
286
|
+
sessionId: withoutExt.substring(0, firstUnderscore),
|
|
287
|
+
backendType: withoutExt.substring(firstUnderscore + 1, secondUnderscore),
|
|
288
|
+
startedAt: withoutExt.substring(secondUnderscore + 1),
|
|
289
|
+
lines,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
} catch {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
closeAll(): void {
|
|
298
|
+
if (this.cleanupTimer) {
|
|
299
|
+
clearInterval(this.cleanupTimer);
|
|
300
|
+
this.cleanupTimer = null;
|
|
301
|
+
}
|
|
302
|
+
for (const [, recorder] of this.recorders) {
|
|
303
|
+
recorder.close();
|
|
304
|
+
}
|
|
305
|
+
this.recorders.clear();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Delete oldest recording files until total lines are under maxLines.
|
|
310
|
+
* Skips files that belong to active (currently recording) sessions.
|
|
311
|
+
*/
|
|
312
|
+
cleanup(): number {
|
|
313
|
+
try {
|
|
314
|
+
this.ensureDir();
|
|
315
|
+
const files = readdirSync(this.recordingsDir).filter((f) => f.endsWith(".jsonl"));
|
|
316
|
+
if (files.length === 0) return 0;
|
|
317
|
+
|
|
318
|
+
// Build list with line counts and mtime, sorted oldest-first
|
|
319
|
+
const activeFiles = new Set<string>();
|
|
320
|
+
for (const rec of this.recorders.values()) {
|
|
321
|
+
activeFiles.add(rec.filePath);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const entries: { filename: string; path: string; lines: number; mtimeMs: number }[] = [];
|
|
325
|
+
let totalLines = 0;
|
|
326
|
+
|
|
327
|
+
for (const filename of files) {
|
|
328
|
+
const fullPath = join(this.recordingsDir, filename);
|
|
329
|
+
const lines = countFileLines(fullPath);
|
|
330
|
+
let mtimeMs = 0;
|
|
331
|
+
try {
|
|
332
|
+
mtimeMs = statSync(fullPath).mtimeMs;
|
|
333
|
+
} catch {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
entries.push({ filename, path: fullPath, lines, mtimeMs });
|
|
337
|
+
totalLines += lines;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (totalLines <= this.maxLines) return 0;
|
|
341
|
+
|
|
342
|
+
// Sort oldest first (lowest mtime = oldest)
|
|
343
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
344
|
+
|
|
345
|
+
let deleted = 0;
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (totalLines <= this.maxLines) break;
|
|
348
|
+
// Don't delete files that are actively being written to
|
|
349
|
+
if (activeFiles.has(entry.path)) continue;
|
|
350
|
+
try {
|
|
351
|
+
unlinkSync(entry.path);
|
|
352
|
+
totalLines -= entry.lines;
|
|
353
|
+
deleted++;
|
|
354
|
+
} catch {
|
|
355
|
+
// File may have been removed concurrently
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (deleted > 0) {
|
|
360
|
+
console.log(`[recorder] Cleanup: deleted ${deleted} old recording(s), ${totalLines} lines remaining`);
|
|
361
|
+
}
|
|
362
|
+
return deleted;
|
|
363
|
+
} catch {
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private ensureDir(): void {
|
|
369
|
+
if (this.dirCreated) return;
|
|
370
|
+
mkdirSync(this.recordingsDir, { recursive: true });
|
|
371
|
+
this.dirCreated = true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateRecording, compareRecordings } from "./compat-validator.js";
|
|
3
|
+
import type { Recording } from "../replay.js";
|
|
4
|
+
|
|
5
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function makeRecording(browserMessages: Record<string, unknown>[]): Recording {
|
|
8
|
+
return {
|
|
9
|
+
header: {
|
|
10
|
+
_header: true as const,
|
|
11
|
+
version: 1 as const,
|
|
12
|
+
session_id: "test",
|
|
13
|
+
backend_type: "claude" as const,
|
|
14
|
+
started_at: 0,
|
|
15
|
+
cwd: "/",
|
|
16
|
+
},
|
|
17
|
+
entries: browserMessages.map((msg, i) => ({
|
|
18
|
+
ts: i * 100,
|
|
19
|
+
dir: "out" as const,
|
|
20
|
+
raw: JSON.stringify(msg),
|
|
21
|
+
ch: "browser" as const,
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("compat-validator", () => {
|
|
29
|
+
describe("validateRecording", () => {
|
|
30
|
+
it("reports compatible for well-formed messages", () => {
|
|
31
|
+
const recording = makeRecording([
|
|
32
|
+
{ type: "session_init", session: { session_id: "s", cwd: "/", model: "claude" } },
|
|
33
|
+
{ type: "assistant", text: "Hello" },
|
|
34
|
+
{ type: "result", subtype: "success" },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const result = validateRecording(recording);
|
|
38
|
+
expect(result.compatible).toBe(true);
|
|
39
|
+
expect(result.diffs).toHaveLength(0);
|
|
40
|
+
expect(result.totalMessages).toBe(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("detects missing type field", () => {
|
|
44
|
+
const recording = makeRecording([{ text: "no type" }]);
|
|
45
|
+
const result = validateRecording(recording);
|
|
46
|
+
expect(result.compatible).toBe(false);
|
|
47
|
+
expect(result.diffs[0].kind).toBe("field_mismatch");
|
|
48
|
+
expect(result.diffs[0].details).toContain("missing 'type'");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("detects missing session_init session object", () => {
|
|
52
|
+
const recording = makeRecording([{ type: "session_init" }]);
|
|
53
|
+
const result = validateRecording(recording);
|
|
54
|
+
expect(result.compatible).toBe(false);
|
|
55
|
+
expect(result.diffs[0].details).toContain("missing 'session' object");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("detects missing permission_request tool_name", () => {
|
|
59
|
+
const recording = makeRecording([{ type: "permission_request", input: {} }]);
|
|
60
|
+
const result = validateRecording(recording);
|
|
61
|
+
expect(result.compatible).toBe(false);
|
|
62
|
+
expect(result.diffs[0].details).toContain("missing 'tool_name'");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("detects missing result subtype", () => {
|
|
66
|
+
const recording = makeRecording([{ type: "result" }]);
|
|
67
|
+
const result = validateRecording(recording);
|
|
68
|
+
expect(result.compatible).toBe(false);
|
|
69
|
+
expect(result.diffs[0].details).toContain("missing 'subtype'");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("provides message type breakdown", () => {
|
|
73
|
+
const recording = makeRecording([
|
|
74
|
+
{ type: "assistant", text: "a" },
|
|
75
|
+
{ type: "assistant", text: "b" },
|
|
76
|
+
{ type: "result", subtype: "success" },
|
|
77
|
+
]);
|
|
78
|
+
const result = validateRecording(recording);
|
|
79
|
+
expect(result.messageTypeBreakdown.assistant.count).toBe(2);
|
|
80
|
+
expect(result.messageTypeBreakdown.result.count).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("reports backendType from header", () => {
|
|
84
|
+
const recording = makeRecording([{ type: "assistant" }]);
|
|
85
|
+
const result = validateRecording(recording);
|
|
86
|
+
expect(result.backendType).toBe("claude");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("compareRecordings", () => {
|
|
91
|
+
it("returns empty diffs for matching recordings", () => {
|
|
92
|
+
const recording = makeRecording([
|
|
93
|
+
{ type: "assistant", text: "Hello" },
|
|
94
|
+
{ type: "result", subtype: "success" },
|
|
95
|
+
]);
|
|
96
|
+
const actual = [
|
|
97
|
+
{ type: "assistant", text: "Hello" },
|
|
98
|
+
{ type: "result", subtype: "success" },
|
|
99
|
+
];
|
|
100
|
+
const diffs = compareRecordings(recording, actual);
|
|
101
|
+
expect(diffs).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("detects type mismatch", () => {
|
|
105
|
+
const recording = makeRecording([{ type: "assistant", text: "Hi" }]);
|
|
106
|
+
const actual = [{ type: "result", subtype: "success" }];
|
|
107
|
+
const diffs = compareRecordings(recording, actual);
|
|
108
|
+
expect(diffs).toHaveLength(1);
|
|
109
|
+
expect(diffs[0].kind).toBe("type_mismatch");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("detects missing messages in actual", () => {
|
|
113
|
+
const recording = makeRecording([
|
|
114
|
+
{ type: "assistant", text: "a" },
|
|
115
|
+
{ type: "assistant", text: "b" },
|
|
116
|
+
]);
|
|
117
|
+
const actual = [{ type: "assistant", text: "a" }];
|
|
118
|
+
const diffs = compareRecordings(recording, actual);
|
|
119
|
+
expect(diffs.some((d) => d.kind === "missing")).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("detects extra messages in actual", () => {
|
|
123
|
+
const recording = makeRecording([{ type: "assistant", text: "a" }]);
|
|
124
|
+
const actual = [
|
|
125
|
+
{ type: "assistant", text: "a" },
|
|
126
|
+
{ type: "result", subtype: "success" },
|
|
127
|
+
];
|
|
128
|
+
const diffs = compareRecordings(recording, actual);
|
|
129
|
+
expect(diffs.some((d) => d.kind === "extra")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("detects missing fields in actual", () => {
|
|
133
|
+
const recording = makeRecording([{ type: "assistant", text: "Hi", content: [] }]);
|
|
134
|
+
const actual = [{ type: "assistant", text: "Hi" }];
|
|
135
|
+
const diffs = compareRecordings(recording, actual);
|
|
136
|
+
expect(diffs.some((d) => d.kind === "field_mismatch" && d.details.includes("missing field"))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores timestamp and cost fields", () => {
|
|
140
|
+
const recording = makeRecording([
|
|
141
|
+
{ type: "result", subtype: "success", timestamp: 123, cost_usd: 0.01, data: "x" },
|
|
142
|
+
]);
|
|
143
|
+
const actual = [
|
|
144
|
+
{ type: "result", subtype: "success", timestamp: 999, cost_usd: 0.99, data: "x" },
|
|
145
|
+
];
|
|
146
|
+
const diffs = compareRecordings(recording, actual);
|
|
147
|
+
expect(diffs).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|