@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
package/server/logger.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Lightweight structured logger for the Companion server.
|
|
2
|
+
// Provides JSON-structured log output for operational events while
|
|
3
|
+
// keeping the familiar console.log interface for human-readable logs.
|
|
4
|
+
//
|
|
5
|
+
// Log file persistence:
|
|
6
|
+
// By default, all log output is also written to ~/.companion/logs/ with
|
|
7
|
+
// automatic rotation (oldest files deleted when total lines exceed 2M).
|
|
8
|
+
// Disable with COMPANION_LOG_FILE=0, override dir with COMPANION_LOG_DIR,
|
|
9
|
+
// and configure rotation with COMPANION_LOG_MAX_LINES.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// import { log } from "./logger.js";
|
|
13
|
+
// log.info("ws-bridge", "Browser connected", { sessionId, browsers: 3 });
|
|
14
|
+
// log.warn("orchestrator", "Git fetch failed", { sessionId, error: "..." });
|
|
15
|
+
// log.error("cli-launcher", "Process crashed", { sessionId, exitCode: 1 });
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
mkdirSync,
|
|
19
|
+
openSync,
|
|
20
|
+
writeSync,
|
|
21
|
+
closeSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
statSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { COMPANION_HOME } from "./paths.js";
|
|
28
|
+
import { countFileLines } from "./fs-utils.js";
|
|
29
|
+
|
|
30
|
+
type LogLevel = "info" | "warn" | "error";
|
|
31
|
+
|
|
32
|
+
interface LogEntry {
|
|
33
|
+
ts: string;
|
|
34
|
+
level: LogLevel;
|
|
35
|
+
module: string;
|
|
36
|
+
msg: string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STRUCTURED = process.env.COMPANION_LOG_FORMAT === "json";
|
|
41
|
+
|
|
42
|
+
function formatEntry(level: LogLevel, module: string, msg: string, data?: Record<string, unknown>): string {
|
|
43
|
+
if (STRUCTURED) {
|
|
44
|
+
const entry: LogEntry = {
|
|
45
|
+
...data,
|
|
46
|
+
ts: new Date().toISOString(),
|
|
47
|
+
level,
|
|
48
|
+
module,
|
|
49
|
+
msg,
|
|
50
|
+
};
|
|
51
|
+
return JSON.stringify(entry);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Human-readable format (default): [module] msg key=value key=value
|
|
55
|
+
let line = `[${module}] ${msg}`;
|
|
56
|
+
if (data) {
|
|
57
|
+
const pairs = Object.entries(data)
|
|
58
|
+
.map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : v}`)
|
|
59
|
+
.join(" ");
|
|
60
|
+
if (pairs) line += ` | ${pairs}`;
|
|
61
|
+
}
|
|
62
|
+
return line;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Log File Writer ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const DEFAULT_LOG_MAX_LINES = 2_000_000;
|
|
68
|
+
const LOG_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Writes log lines to a file under ~/.companion/logs/ with automatic rotation.
|
|
72
|
+
* A new log file is created each time the server starts. When total lines across
|
|
73
|
+
* all log files exceed maxLines (default 2M), the oldest files are deleted.
|
|
74
|
+
*
|
|
75
|
+
* Follows the same pattern as RecorderManager for recordings.
|
|
76
|
+
*/
|
|
77
|
+
export class LogFileWriter {
|
|
78
|
+
readonly filePath: string;
|
|
79
|
+
private logsDir: string;
|
|
80
|
+
private maxLines: number;
|
|
81
|
+
private fd: number;
|
|
82
|
+
private closed = false;
|
|
83
|
+
private dirCreated = false;
|
|
84
|
+
private initialCleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
86
|
+
|
|
87
|
+
constructor(options?: { logsDir?: string; maxLines?: number }) {
|
|
88
|
+
this.logsDir = options?.logsDir ?? LogFileWriter.resolveDir();
|
|
89
|
+
this.maxLines =
|
|
90
|
+
options?.maxLines ??
|
|
91
|
+
(Number(process.env.COMPANION_LOG_MAX_LINES) || DEFAULT_LOG_MAX_LINES);
|
|
92
|
+
|
|
93
|
+
this.ensureDir();
|
|
94
|
+
|
|
95
|
+
// Create a new log file for this server run and keep the fd open
|
|
96
|
+
const ts = new Date().toISOString().replace(/:/g, "-");
|
|
97
|
+
const pid = process.pid;
|
|
98
|
+
this.filePath = join(this.logsDir, `companion_${ts}_${pid}.log`);
|
|
99
|
+
this.fd = openSync(this.filePath, "a");
|
|
100
|
+
|
|
101
|
+
// Defer initial cleanup so it doesn't block the event loop at startup
|
|
102
|
+
this.initialCleanupTimer = setTimeout(() => {
|
|
103
|
+
this.initialCleanupTimer = null;
|
|
104
|
+
this.cleanup();
|
|
105
|
+
}, 2000);
|
|
106
|
+
if (this.initialCleanupTimer.unref) this.initialCleanupTimer.unref();
|
|
107
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), LOG_CLEANUP_INTERVAL_MS);
|
|
108
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private static resolveDir(): string {
|
|
112
|
+
return process.env.COMPANION_LOG_DIR ?? join(COMPANION_HOME, "logs");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether log file writing is enabled. Disable with COMPANION_LOG_FILE=0|false. */
|
|
116
|
+
static isEnabled(): boolean {
|
|
117
|
+
const env = process.env.COMPANION_LOG_FILE;
|
|
118
|
+
if (env === "0" || env === "false") return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getLogsDir(): string {
|
|
123
|
+
return this.logsDir;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getMaxLines(): number {
|
|
127
|
+
return this.maxLines;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
write(line: string): void {
|
|
131
|
+
if (this.closed) return;
|
|
132
|
+
try {
|
|
133
|
+
writeSync(this.fd, line + "\n");
|
|
134
|
+
} catch {
|
|
135
|
+
// Never throw — logging must not disrupt normal operation
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Delete oldest log files until total lines are under maxLines.
|
|
141
|
+
* Skips the current log file (still being written to).
|
|
142
|
+
*/
|
|
143
|
+
cleanup(): number {
|
|
144
|
+
try {
|
|
145
|
+
this.ensureDir();
|
|
146
|
+
const files = readdirSync(this.logsDir).filter((f) => f.endsWith(".log"));
|
|
147
|
+
if (files.length === 0) return 0;
|
|
148
|
+
|
|
149
|
+
const entries: { filename: string; path: string; lines: number; mtimeMs: number }[] = [];
|
|
150
|
+
let totalLines = 0;
|
|
151
|
+
|
|
152
|
+
for (const filename of files) {
|
|
153
|
+
const fullPath = join(this.logsDir, filename);
|
|
154
|
+
const lines = countFileLines(fullPath);
|
|
155
|
+
let mtimeMs = 0;
|
|
156
|
+
try {
|
|
157
|
+
mtimeMs = statSync(fullPath).mtimeMs;
|
|
158
|
+
} catch {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
entries.push({ filename, path: fullPath, lines, mtimeMs });
|
|
162
|
+
totalLines += lines;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (totalLines <= this.maxLines) return 0;
|
|
166
|
+
|
|
167
|
+
// Sort oldest first
|
|
168
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
169
|
+
|
|
170
|
+
let deleted = 0;
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (totalLines <= this.maxLines) break;
|
|
173
|
+
// Don't delete the current log file
|
|
174
|
+
if (entry.path === this.filePath) continue;
|
|
175
|
+
try {
|
|
176
|
+
unlinkSync(entry.path);
|
|
177
|
+
totalLines -= entry.lines;
|
|
178
|
+
deleted++;
|
|
179
|
+
} catch {
|
|
180
|
+
// File may have been removed concurrently
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (deleted > 0) {
|
|
185
|
+
// Log to console only (avoid recursion)
|
|
186
|
+
console.log(`[logger] Cleanup: deleted ${deleted} old log file(s), ${totalLines} lines remaining`);
|
|
187
|
+
}
|
|
188
|
+
return deleted;
|
|
189
|
+
} catch {
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
close(): void {
|
|
195
|
+
this.closed = true;
|
|
196
|
+
if (this.initialCleanupTimer) {
|
|
197
|
+
clearTimeout(this.initialCleanupTimer);
|
|
198
|
+
this.initialCleanupTimer = null;
|
|
199
|
+
}
|
|
200
|
+
if (this.cleanupTimer) {
|
|
201
|
+
clearInterval(this.cleanupTimer);
|
|
202
|
+
this.cleanupTimer = null;
|
|
203
|
+
}
|
|
204
|
+
try { closeSync(this.fd); } catch { /* ignore */ }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private ensureDir(): void {
|
|
208
|
+
if (this.dirCreated) return;
|
|
209
|
+
mkdirSync(this.logsDir, { recursive: true });
|
|
210
|
+
this.dirCreated = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Singleton file writer (initialized lazily) ─────────────────────────────
|
|
215
|
+
|
|
216
|
+
let fileWriter: LogFileWriter | null = null;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Initialize the log file writer. Call once at server startup.
|
|
220
|
+
* Returns the writer instance for status reporting, or null if disabled.
|
|
221
|
+
*/
|
|
222
|
+
export function initLogFile(options?: { logsDir?: string; maxLines?: number }): LogFileWriter | null {
|
|
223
|
+
if (!LogFileWriter.isEnabled()) return null;
|
|
224
|
+
if (fileWriter) {
|
|
225
|
+
fileWriter.close();
|
|
226
|
+
}
|
|
227
|
+
fileWriter = new LogFileWriter(options);
|
|
228
|
+
return fileWriter;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Shut down the log file writer (clears cleanup timer). */
|
|
232
|
+
export function closeLogFile(): void {
|
|
233
|
+
if (fileWriter) {
|
|
234
|
+
fileWriter.close();
|
|
235
|
+
fileWriter = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Public logger ──────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export const log = {
|
|
242
|
+
info(module: string, msg: string, data?: Record<string, unknown>): void {
|
|
243
|
+
const line = formatEntry("info", module, msg, data);
|
|
244
|
+
console.log(line);
|
|
245
|
+
fileWriter?.write(line);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
warn(module: string, msg: string, data?: Record<string, unknown>): void {
|
|
249
|
+
const line = formatEntry("warn", module, msg, data);
|
|
250
|
+
console.warn(line);
|
|
251
|
+
fileWriter?.write(line);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
error(module: string, msg: string, data?: Record<string, unknown>): void {
|
|
255
|
+
const line = formatEntry("error", module, msg, data);
|
|
256
|
+
console.error(line);
|
|
257
|
+
fileWriter?.write(line);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { MetricsCollector } from "./metrics-collector.js";
|
|
3
|
+
import type { GaugeDataProvider } from "./metrics-collector.js";
|
|
4
|
+
import type { SessionPhase } from "./session-state-machine.js";
|
|
5
|
+
import { companionBus } from "./event-bus.js";
|
|
6
|
+
|
|
7
|
+
// Fresh collector per test (avoids singleton pollution)
|
|
8
|
+
let collector: MetricsCollector;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
collector = new MetricsCollector();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
collector.destroy();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// ── Helper: mock gauge provider ───────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function createMockGaugeProvider(overrides?: {
|
|
21
|
+
phases?: Map<string, SessionPhase>;
|
|
22
|
+
stats?: { id: string; browsers: number; historyLen: number; eventBufferLen: number; pendingMsgs: number }[];
|
|
23
|
+
}): GaugeDataProvider {
|
|
24
|
+
return {
|
|
25
|
+
getSessionPhases: vi.fn(() => overrides?.phases ?? new Map()),
|
|
26
|
+
getSessionMemoryStats: vi.fn(() => overrides?.stats ?? []),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Counters ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("counters", () => {
|
|
33
|
+
it("tracks sessions created by backend type", () => {
|
|
34
|
+
collector.recordSessionCreated("claude");
|
|
35
|
+
collector.recordSessionCreated("claude");
|
|
36
|
+
collector.recordSessionCreated("codex");
|
|
37
|
+
|
|
38
|
+
const snap = collector.getSnapshot();
|
|
39
|
+
expect(snap.counters.sessionsCreated).toEqual({ claude: 2, codex: 1 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("tracks sessions terminated by exit code via event bus", () => {
|
|
43
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 0 });
|
|
44
|
+
companionBus.emit("session:exited", { sessionId: "s2", exitCode: 1 });
|
|
45
|
+
companionBus.emit("session:exited", { sessionId: "s3", exitCode: null });
|
|
46
|
+
companionBus.emit("session:exited", { sessionId: "s4", exitCode: 0 });
|
|
47
|
+
|
|
48
|
+
const snap = collector.getSnapshot();
|
|
49
|
+
expect(snap.counters.sessionsTerminated).toEqual({ "0": 2, "1": 1, "null": 1 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("tracks auto-relaunch counters", () => {
|
|
53
|
+
collector.recordRelaunchAttempted();
|
|
54
|
+
collector.recordRelaunchAttempted();
|
|
55
|
+
collector.recordRelaunchSucceeded();
|
|
56
|
+
collector.recordRelaunchExhausted();
|
|
57
|
+
|
|
58
|
+
const snap = collector.getSnapshot();
|
|
59
|
+
expect(snap.counters.autoRelaunches).toEqual({
|
|
60
|
+
attempted: 2,
|
|
61
|
+
succeeded: 1,
|
|
62
|
+
exhausted: 1,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("tracks messages processed by type", () => {
|
|
67
|
+
collector.recordMessageProcessed("assistant");
|
|
68
|
+
collector.recordMessageProcessed("assistant");
|
|
69
|
+
collector.recordMessageProcessed("result");
|
|
70
|
+
collector.recordMessageProcessed("stream_event");
|
|
71
|
+
|
|
72
|
+
const snap = collector.getSnapshot();
|
|
73
|
+
expect(snap.counters.messagesProcessed).toEqual({
|
|
74
|
+
assistant: 2,
|
|
75
|
+
result: 1,
|
|
76
|
+
stream_event: 1,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tracks permission requests", () => {
|
|
81
|
+
// 2 auto-approved, 1 auto-denied, 1 user-approved, 1 user-denied
|
|
82
|
+
collector.recordPermissionRequested("r1");
|
|
83
|
+
collector.recordPermissionResolved("r1", "allow", true);
|
|
84
|
+
collector.recordPermissionRequested("r2");
|
|
85
|
+
collector.recordPermissionResolved("r2", "allow", true);
|
|
86
|
+
collector.recordPermissionRequested("r3");
|
|
87
|
+
collector.recordPermissionResolved("r3", "deny", true);
|
|
88
|
+
collector.recordPermissionRequested("r4");
|
|
89
|
+
collector.recordPermissionResolved("r4", "allow", false);
|
|
90
|
+
collector.recordPermissionRequested("r5");
|
|
91
|
+
collector.recordPermissionResolved("r5", "deny", false);
|
|
92
|
+
|
|
93
|
+
const snap = collector.getSnapshot();
|
|
94
|
+
expect(snap.counters.permissionRequests).toEqual({
|
|
95
|
+
total: 5,
|
|
96
|
+
autoApproved: 2,
|
|
97
|
+
autoDenied: 1,
|
|
98
|
+
userApproved: 1,
|
|
99
|
+
userDenied: 1,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("tracks errors by category", () => {
|
|
104
|
+
collector.recordError("invalid_state_transition");
|
|
105
|
+
collector.recordError("invalid_state_transition");
|
|
106
|
+
collector.recordError("parse_error");
|
|
107
|
+
|
|
108
|
+
const snap = collector.getSnapshot();
|
|
109
|
+
expect(snap.counters.errors).toEqual({
|
|
110
|
+
invalid_state_transition: 2,
|
|
111
|
+
parse_error: 1,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("tracks state transitions via event bus", () => {
|
|
116
|
+
companionBus.emit("session:phase-changed", {
|
|
117
|
+
sessionId: "s1",
|
|
118
|
+
from: "starting" as SessionPhase,
|
|
119
|
+
to: "initializing" as SessionPhase,
|
|
120
|
+
trigger: "cli_ws_open",
|
|
121
|
+
});
|
|
122
|
+
companionBus.emit("session:phase-changed", {
|
|
123
|
+
sessionId: "s1",
|
|
124
|
+
from: "initializing" as SessionPhase,
|
|
125
|
+
to: "ready" as SessionPhase,
|
|
126
|
+
trigger: "system_init",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const snap = collector.getSnapshot();
|
|
130
|
+
expect(snap.counters.stateTransitions).toEqual({
|
|
131
|
+
"starting→initializing": 1,
|
|
132
|
+
"initializing→ready": 1,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("tracks WebSocket connections", () => {
|
|
137
|
+
collector.recordWsConnection("cli", "open");
|
|
138
|
+
collector.recordWsConnection("cli", "open");
|
|
139
|
+
collector.recordWsConnection("cli", "close");
|
|
140
|
+
collector.recordWsConnection("browser", "open");
|
|
141
|
+
collector.recordWsConnection("browser", "close");
|
|
142
|
+
|
|
143
|
+
const snap = collector.getSnapshot();
|
|
144
|
+
expect(snap.counters.wsConnections).toEqual({
|
|
145
|
+
cliOpened: 2,
|
|
146
|
+
cliClosed: 1,
|
|
147
|
+
browserOpened: 1,
|
|
148
|
+
browserClosed: 1,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── Histograms ────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe("histograms", () => {
|
|
156
|
+
it("records session init time (spawn → ready via event bus)", () => {
|
|
157
|
+
// Simulate spawn + phase transition with controlled timing
|
|
158
|
+
vi.useFakeTimers();
|
|
159
|
+
|
|
160
|
+
collector.recordSessionSpawned("s1");
|
|
161
|
+
vi.advanceTimersByTime(2000);
|
|
162
|
+
|
|
163
|
+
// Simulate initializing → ready transition
|
|
164
|
+
companionBus.emit("session:phase-changed", {
|
|
165
|
+
sessionId: "s1",
|
|
166
|
+
from: "initializing" as SessionPhase,
|
|
167
|
+
to: "ready" as SessionPhase,
|
|
168
|
+
trigger: "system_init",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
vi.useRealTimers();
|
|
172
|
+
|
|
173
|
+
const snap = collector.getSnapshot();
|
|
174
|
+
expect(snap.histograms.sessionInitTimeMs.count).toBe(1);
|
|
175
|
+
expect(snap.histograms.sessionInitTimeMs.sum).toBe(2000);
|
|
176
|
+
expect(snap.histograms.sessionInitTimeMs.min).toBe(2000);
|
|
177
|
+
expect(snap.histograms.sessionInitTimeMs.max).toBe(2000);
|
|
178
|
+
expect(snap.histograms.sessionInitTimeMs.avg).toBe(2000);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("records turn duration (user message → result via event bus)", () => {
|
|
182
|
+
vi.useFakeTimers();
|
|
183
|
+
|
|
184
|
+
collector.recordTurnStarted("s1");
|
|
185
|
+
vi.advanceTimersByTime(3000);
|
|
186
|
+
|
|
187
|
+
companionBus.emit("message:result", {
|
|
188
|
+
sessionId: "s1",
|
|
189
|
+
message: { type: "result", data: {} } as any,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
vi.useRealTimers();
|
|
193
|
+
|
|
194
|
+
const snap = collector.getSnapshot();
|
|
195
|
+
expect(snap.histograms.turnDurationMs.count).toBe(1);
|
|
196
|
+
expect(snap.histograms.turnDurationMs.sum).toBe(3000);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("records permission duration (request → resolve)", () => {
|
|
200
|
+
vi.useFakeTimers();
|
|
201
|
+
|
|
202
|
+
collector.recordPermissionRequested("r1");
|
|
203
|
+
vi.advanceTimersByTime(1500);
|
|
204
|
+
collector.recordPermissionResolved("r1", "allow", false);
|
|
205
|
+
|
|
206
|
+
vi.useRealTimers();
|
|
207
|
+
|
|
208
|
+
const snap = collector.getSnapshot();
|
|
209
|
+
expect(snap.histograms.permissionDurationMs.count).toBe(1);
|
|
210
|
+
expect(snap.histograms.permissionDurationMs.sum).toBe(1500);
|
|
211
|
+
// 1500ms falls in the 2500 bucket
|
|
212
|
+
expect(snap.histograms.permissionDurationMs.p50Bucket).toBe(2500);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns zeros for empty histograms", () => {
|
|
216
|
+
const snap = collector.getSnapshot();
|
|
217
|
+
expect(snap.histograms.sessionInitTimeMs.count).toBe(0);
|
|
218
|
+
expect(snap.histograms.sessionInitTimeMs.min).toBe(0);
|
|
219
|
+
expect(snap.histograms.sessionInitTimeMs.max).toBe(0);
|
|
220
|
+
expect(snap.histograms.sessionInitTimeMs.avg).toBe(0);
|
|
221
|
+
expect(snap.histograms.sessionInitTimeMs.p50Bucket).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("distributes values across correct buckets", () => {
|
|
225
|
+
vi.useFakeTimers();
|
|
226
|
+
|
|
227
|
+
// Record values in different buckets: 30ms, 75ms, 200ms, 800ms, 5000ms
|
|
228
|
+
const durations = [30, 75, 200, 800, 5000];
|
|
229
|
+
for (const d of durations) {
|
|
230
|
+
collector.recordTurnStarted(`s-${d}`);
|
|
231
|
+
vi.advanceTimersByTime(d);
|
|
232
|
+
companionBus.emit("message:result", {
|
|
233
|
+
sessionId: `s-${d}`,
|
|
234
|
+
message: { type: "result", data: {} } as any,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
vi.useRealTimers();
|
|
239
|
+
|
|
240
|
+
const snap = collector.getSnapshot();
|
|
241
|
+
const h = snap.histograms.turnDurationMs;
|
|
242
|
+
expect(h.count).toBe(5);
|
|
243
|
+
expect(h.min).toBe(30);
|
|
244
|
+
expect(h.max).toBe(5000);
|
|
245
|
+
// 30ms → bucket 50, 75ms → bucket 100, 200ms → bucket 250,
|
|
246
|
+
// 800ms → bucket 1000, 5000ms → bucket 5000
|
|
247
|
+
expect(h.buckets["50"]).toBe(1);
|
|
248
|
+
expect(h.buckets["100"]).toBe(1);
|
|
249
|
+
expect(h.buckets["250"]).toBe(1);
|
|
250
|
+
expect(h.buckets["1000"]).toBe(1);
|
|
251
|
+
expect(h.buckets["5000"]).toBe(1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── Gauges ─────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe("gauges", () => {
|
|
258
|
+
it("computes session phase distribution from gauge provider", () => {
|
|
259
|
+
const phases = new Map<string, SessionPhase>([
|
|
260
|
+
["s1", "ready"],
|
|
261
|
+
["s2", "streaming"],
|
|
262
|
+
["s3", "ready"],
|
|
263
|
+
["s4", "terminated"],
|
|
264
|
+
]);
|
|
265
|
+
const provider = createMockGaugeProvider({ phases });
|
|
266
|
+
|
|
267
|
+
const snap = collector.getSnapshot(provider);
|
|
268
|
+
expect(snap.gauges.activeSessions).toEqual({
|
|
269
|
+
ready: 2,
|
|
270
|
+
streaming: 1,
|
|
271
|
+
terminated: 1,
|
|
272
|
+
});
|
|
273
|
+
expect(snap.gauges.totalActiveSessions).toBe(3); // excludes terminated
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("aggregates memory stats from gauge provider", () => {
|
|
277
|
+
const stats = [
|
|
278
|
+
{ id: "s1", browsers: 2, historyLen: 100, eventBufferLen: 5, pendingMsgs: 1 },
|
|
279
|
+
{ id: "s2", browsers: 1, historyLen: 50, eventBufferLen: 3, pendingMsgs: 0 },
|
|
280
|
+
];
|
|
281
|
+
const provider = createMockGaugeProvider({ stats });
|
|
282
|
+
|
|
283
|
+
const snap = collector.getSnapshot(provider);
|
|
284
|
+
expect(snap.gauges.connectedBrowsers).toBe(3);
|
|
285
|
+
expect(snap.gauges.totalPendingMessages).toBe(1);
|
|
286
|
+
expect(snap.gauges.totalEventBufferSize).toBe(8);
|
|
287
|
+
expect(snap.gauges.totalHistoryMessages).toBe(150);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("includes process memory info", () => {
|
|
291
|
+
const snap = collector.getSnapshot();
|
|
292
|
+
expect(snap.gauges.memory.rss).toBeGreaterThan(0);
|
|
293
|
+
expect(snap.gauges.memory.heapUsed).toBeGreaterThan(0);
|
|
294
|
+
expect(snap.gauges.memory.heapTotal).toBeGreaterThan(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("returns empty gauges when no provider is given", () => {
|
|
298
|
+
const snap = collector.getSnapshot();
|
|
299
|
+
expect(snap.gauges.activeSessions).toEqual({});
|
|
300
|
+
expect(snap.gauges.totalActiveSessions).toBe(0);
|
|
301
|
+
expect(snap.gauges.connectedBrowsers).toBe(0);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── Snapshot shape ────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe("snapshot shape", () => {
|
|
308
|
+
it("returns well-formed MetricsSnapshot", () => {
|
|
309
|
+
const snap = collector.getSnapshot();
|
|
310
|
+
expect(snap.serverUptimeMs).toBeGreaterThanOrEqual(0);
|
|
311
|
+
expect(snap.snapshotAt).toBeGreaterThan(0);
|
|
312
|
+
expect(snap.counters).toBeDefined();
|
|
313
|
+
expect(snap.gauges).toBeDefined();
|
|
314
|
+
expect(snap.histograms).toBeDefined();
|
|
315
|
+
expect(snap.histograms.sessionInitTimeMs).toBeDefined();
|
|
316
|
+
expect(snap.histograms.turnDurationMs).toBeDefined();
|
|
317
|
+
expect(snap.histograms.permissionDurationMs).toBeDefined();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── Reset ─────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
describe("reset", () => {
|
|
324
|
+
it("zeros all counters and histograms", () => {
|
|
325
|
+
// Record some data
|
|
326
|
+
collector.recordSessionCreated("claude");
|
|
327
|
+
collector.recordRelaunchAttempted();
|
|
328
|
+
collector.recordMessageProcessed("assistant");
|
|
329
|
+
collector.recordError("parse_error");
|
|
330
|
+
collector.recordWsConnection("cli", "open");
|
|
331
|
+
|
|
332
|
+
collector.reset();
|
|
333
|
+
|
|
334
|
+
const snap = collector.getSnapshot();
|
|
335
|
+
expect(snap.counters.sessionsCreated).toEqual({});
|
|
336
|
+
expect(snap.counters.autoRelaunches).toEqual({ attempted: 0, succeeded: 0, exhausted: 0 });
|
|
337
|
+
expect(snap.counters.messagesProcessed).toEqual({});
|
|
338
|
+
expect(snap.counters.errors).toEqual({});
|
|
339
|
+
expect(snap.counters.wsConnections).toEqual({
|
|
340
|
+
cliOpened: 0, cliClosed: 0, browserOpened: 0, browserClosed: 0,
|
|
341
|
+
});
|
|
342
|
+
expect(snap.histograms.sessionInitTimeMs.count).toBe(0);
|
|
343
|
+
expect(snap.histograms.turnDurationMs.count).toBe(0);
|
|
344
|
+
expect(snap.histograms.permissionDurationMs.count).toBe(0);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ── Edge cases ────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe("edge cases", () => {
|
|
351
|
+
it("handles permission resolve for unknown requestId without crashing", () => {
|
|
352
|
+
// Should not throw — just increments the counter without recording duration
|
|
353
|
+
collector.recordPermissionResolved("unknown-id", "allow", false);
|
|
354
|
+
|
|
355
|
+
const snap = collector.getSnapshot();
|
|
356
|
+
expect(snap.counters.permissionRequests.userApproved).toBe(1);
|
|
357
|
+
expect(snap.histograms.permissionDurationMs.count).toBe(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("cleans up timing state on session exit", () => {
|
|
361
|
+
collector.recordSessionSpawned("s1");
|
|
362
|
+
collector.recordTurnStarted("s1");
|
|
363
|
+
|
|
364
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 0 });
|
|
365
|
+
|
|
366
|
+
// Subsequent result should not record a turn duration (timing state was cleaned up)
|
|
367
|
+
companionBus.emit("message:result", {
|
|
368
|
+
sessionId: "s1",
|
|
369
|
+
message: { type: "result", data: {} } as any,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const snap = collector.getSnapshot();
|
|
373
|
+
expect(snap.histograms.turnDurationMs.count).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("cleans up orphaned permission timers on session exit", () => {
|
|
377
|
+
// Record a permission request tied to a session, then exit without resolving
|
|
378
|
+
collector.recordPermissionRequested("perm-1", "s1");
|
|
379
|
+
collector.recordPermissionRequested("perm-2", "s1");
|
|
380
|
+
collector.recordPermissionRequested("perm-3", "s2"); // different session
|
|
381
|
+
|
|
382
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 0 });
|
|
383
|
+
|
|
384
|
+
// Resolving perm-1 and perm-2 should NOT record a duration (cleaned up on exit)
|
|
385
|
+
collector.recordPermissionResolved("perm-1", "allow", false);
|
|
386
|
+
collector.recordPermissionResolved("perm-2", "deny", false);
|
|
387
|
+
|
|
388
|
+
const snap = collector.getSnapshot();
|
|
389
|
+
// No duration should have been recorded for perm-1/perm-2
|
|
390
|
+
expect(snap.histograms.permissionDurationMs.count).toBe(0);
|
|
391
|
+
|
|
392
|
+
// perm-3 from s2 should still be resolvable
|
|
393
|
+
collector.recordPermissionResolved("perm-3", "allow", false);
|
|
394
|
+
const snap2 = collector.getSnapshot();
|
|
395
|
+
expect(snap2.histograms.permissionDurationMs.count).toBe(1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("handles very large histogram values (overflow bucket)", () => {
|
|
399
|
+
vi.useFakeTimers();
|
|
400
|
+
|
|
401
|
+
collector.recordTurnStarted("s1");
|
|
402
|
+
vi.advanceTimersByTime(120_000); // 2 minutes
|
|
403
|
+
companionBus.emit("message:result", {
|
|
404
|
+
sessionId: "s1",
|
|
405
|
+
message: { type: "result", data: {} } as any,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
vi.useRealTimers();
|
|
409
|
+
|
|
410
|
+
const snap = collector.getSnapshot();
|
|
411
|
+
expect(snap.histograms.turnDurationMs.buckets["Infinity"]).toBe(1);
|
|
412
|
+
});
|
|
413
|
+
});
|