ai-lens 0.8.5 → 0.8.9
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/.commithash +1 -1
- package/cli/hooks.js +33 -3
- package/cli/init.js +34 -7
- package/cli/remove.js +32 -3
- package/cli/status.js +139 -101
- package/client/capture.js +87 -73
- package/client/config.js +34 -34
- package/client/redact.js +8 -4
- package/client/sender.js +447 -221
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
e90eb88
|
package/cli/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -58,9 +58,39 @@ export function shellEscape(str) {
|
|
|
58
58
|
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// Resolve a stable node path that survives version upgrades.
|
|
62
|
+
// process.execPath often points to a versioned path (e.g. /opt/homebrew/Cellar/node/24.10.0/bin/node)
|
|
63
|
+
// that breaks after `brew upgrade node`. We prefer symlinks that point to the current version.
|
|
64
|
+
// We intentionally avoid `|| node` fallback — it was tried and reverted (1dfdd25) because
|
|
65
|
+
// it masks capture failures (re-runs with empty stdin, exits 0).
|
|
66
|
+
function findStableNodePath() {
|
|
67
|
+
// If process.execPath is already a symlink (e.g. /usr/local/bin/node), use it directly
|
|
68
|
+
try {
|
|
69
|
+
if (lstatSync(process.execPath).isSymbolicLink()) return process.execPath;
|
|
70
|
+
} catch {}
|
|
71
|
+
|
|
72
|
+
// Try stable symlinks that survive version upgrades
|
|
73
|
+
const candidates = [
|
|
74
|
+
'/opt/homebrew/bin/node', // Homebrew (macOS ARM)
|
|
75
|
+
'/usr/local/bin/node', // Homebrew (macOS x86), manual installs
|
|
76
|
+
];
|
|
77
|
+
for (const p of candidates) {
|
|
78
|
+
try {
|
|
79
|
+
if (existsSync(p)) return p;
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: rely on PATH at hook execution time
|
|
84
|
+
return '/usr/bin/env node';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function captureCommand() {
|
|
88
|
+
const nodePath = findStableNodePath();
|
|
62
89
|
const escaped = shellEscape(CAPTURE_PATH);
|
|
63
|
-
|
|
90
|
+
// /usr/bin/env doesn't need shell-escaping, but named paths do
|
|
91
|
+
return nodePath === '/usr/bin/env node'
|
|
92
|
+
? `/usr/bin/env node ${escaped}`
|
|
93
|
+
: `${shellEscape(nodePath)} ${escaped}`;
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
// ---------------------------------------------------------------------------
|
package/cli/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
-
import { execSync } from 'node:child_process';
|
|
2
|
+
import { execSync, spawn } from 'node:child_process';
|
|
3
3
|
import { existsSync, copyFileSync } from 'node:fs';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
heading, detail, blank, getLogPath,
|
|
11
11
|
} from './logger.js';
|
|
12
12
|
import { getGitIdentity } from '../client/config.js';
|
|
13
|
+
import { migrateIfNeeded } from '../client/sender.js';
|
|
13
14
|
import {
|
|
14
15
|
CAPTURE_PATH, detectInstalledTools,
|
|
15
16
|
analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan,
|
|
@@ -393,6 +394,28 @@ export default async function init() {
|
|
|
393
394
|
error(` Failed to install client files: ${err.message}`);
|
|
394
395
|
return;
|
|
395
396
|
}
|
|
397
|
+
|
|
398
|
+
// Migrate legacy queue.jsonl → spool format (idempotent)
|
|
399
|
+
try {
|
|
400
|
+
const { migrated, errors: migErrors } = migrateIfNeeded();
|
|
401
|
+
if (migrated > 0) {
|
|
402
|
+
success(` Migrated ${migrated} queued events to spool format`);
|
|
403
|
+
// Flush migrated events immediately — without this, they sit in pending/
|
|
404
|
+
// until the next capture event (which may never come if the user is done coding).
|
|
405
|
+
const senderPath = join(homedir(), '.ai-lens', 'client', 'sender.js');
|
|
406
|
+
if (existsSync(senderPath)) {
|
|
407
|
+
try {
|
|
408
|
+
const s = spawn(process.execPath, [senderPath], { detached: true, stdio: 'ignore' });
|
|
409
|
+
s.unref();
|
|
410
|
+
} catch { /* best-effort — sender will run on next capture */ }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (migErrors.length > 0) {
|
|
414
|
+
warn(` Migration warnings: ${migErrors.slice(0, 3).join('; ')}${migErrors.length > 3 ? ` (+${migErrors.length - 3} more)` : ''}`);
|
|
415
|
+
}
|
|
416
|
+
} catch (err) {
|
|
417
|
+
warn(` Migration skipped: ${err.message}`);
|
|
418
|
+
}
|
|
396
419
|
blank();
|
|
397
420
|
|
|
398
421
|
// Authentication
|
|
@@ -496,7 +519,7 @@ export default async function init() {
|
|
|
496
519
|
// Confirm
|
|
497
520
|
if (!auto) {
|
|
498
521
|
const answer = await ask('Proceed? [Y/n] ');
|
|
499
|
-
if (answer && answer.toLowerCase()
|
|
522
|
+
if (answer && !['y', 'yes'].includes(answer.toLowerCase())) {
|
|
500
523
|
info('Aborted.');
|
|
501
524
|
return;
|
|
502
525
|
}
|
|
@@ -541,10 +564,14 @@ export default async function init() {
|
|
|
541
564
|
}
|
|
542
565
|
}
|
|
543
566
|
|
|
544
|
-
// Clean up legacy hook locations only AFTER new hooks are verified
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
567
|
+
// Clean up legacy hook locations only AFTER new hooks are verified.
|
|
568
|
+
// Without this guard, a failed write would delete working legacy hooks,
|
|
569
|
+
// leaving the user with no hooks at all.
|
|
570
|
+
if (!verifyFailed) {
|
|
571
|
+
for (const { tool } of analyses) {
|
|
572
|
+
for (const lr of cleanupLegacyHooks(tool)) {
|
|
573
|
+
success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
|
|
574
|
+
}
|
|
548
575
|
}
|
|
549
576
|
}
|
|
550
577
|
blank();
|
|
@@ -566,7 +593,7 @@ export default async function init() {
|
|
|
566
593
|
|
|
567
594
|
// MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
|
|
568
595
|
const mcpUrl = `${serverUrl}/mcp`;
|
|
569
|
-
const setupMcp =
|
|
596
|
+
const setupMcp = !flags.noMcp;
|
|
570
597
|
|
|
571
598
|
// Claude Code MCP
|
|
572
599
|
const claudeDir = join(homedir(), '.claude');
|
package/cli/remove.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
-
import { unlinkSync, existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { unlinkSync, existsSync, rmSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
@@ -63,8 +63,8 @@ export default async function remove() {
|
|
|
63
63
|
blank();
|
|
64
64
|
|
|
65
65
|
// Confirm
|
|
66
|
-
const answer = await ask('Proceed? [
|
|
67
|
-
if (answer && answer.toLowerCase()
|
|
66
|
+
const answer = await ask('Proceed? [y/n] ');
|
|
67
|
+
if (answer && !['y', 'yes'].includes(answer.toLowerCase().trim())) {
|
|
68
68
|
info('Aborted.');
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
@@ -146,6 +146,7 @@ export default async function remove() {
|
|
|
146
146
|
const dataFiles = [
|
|
147
147
|
'queue.jsonl', 'queue.sending.jsonl', 'config.json',
|
|
148
148
|
'sender.log', 'capture.log', 'session-paths.json', 'git-remotes.json', 'last-events.json',
|
|
149
|
+
'storage-version',
|
|
149
150
|
];
|
|
150
151
|
for (const file of dataFiles) {
|
|
151
152
|
const filePath = join(dataDir, file);
|
|
@@ -159,6 +160,34 @@ export default async function remove() {
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
// Clean up orphaned migration files (.migrating.*)
|
|
164
|
+
try {
|
|
165
|
+
for (const f of readdirSync(dataDir)) {
|
|
166
|
+
if (f.includes('.migrating.')) {
|
|
167
|
+
const p = join(dataDir, f);
|
|
168
|
+
try {
|
|
169
|
+
unlinkSync(p);
|
|
170
|
+
success(` Deleted ~/.ai-lens/${f}`);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
error(` Failed to delete ${f}: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {}
|
|
177
|
+
|
|
178
|
+
// Clear spool directories (v1 storage format)
|
|
179
|
+
for (const dir of ['pending', 'sending', 'dedup', 'session-paths', 'git-remotes']) {
|
|
180
|
+
const dirPath = join(dataDir, dir);
|
|
181
|
+
try {
|
|
182
|
+
if (existsSync(dirPath)) {
|
|
183
|
+
rmSync(dirPath, { recursive: true });
|
|
184
|
+
success(` Deleted ~/.ai-lens/${dir}/`);
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
error(` Failed to delete ${dir}/: ${err.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
162
191
|
// Remove ~/.ai-lens/ if empty
|
|
163
192
|
try {
|
|
164
193
|
rmSync(dataDir, { recursive: false });
|
package/cli/status.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
|
|
6
6
|
import { getVersionInfo, readLensConfig, detectInstalledTools, analyzeToolHooks, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
|
|
7
|
-
import { DATA_DIR,
|
|
7
|
+
import { DATA_DIR, PENDING_DIR, SENDING_DIR, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
|
|
8
8
|
import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
|
|
9
9
|
|
|
10
10
|
// ANSI helpers
|
|
@@ -73,42 +73,80 @@ function extractHookCommand(tool) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
function checkCaptureRun(installedTools) {
|
|
76
|
-
|
|
76
|
+
// Collect commands from ALL installed tools — not just the first found (Issues 8-9).
|
|
77
|
+
// If Claude Code works but Cursor is broken, both must be tested to surface the failure.
|
|
78
|
+
const toolCommands = [];
|
|
77
79
|
for (const tool of installedTools) {
|
|
78
|
-
command = extractHookCommand(tool);
|
|
79
|
-
if (command)
|
|
80
|
+
const command = extractHookCommand(tool);
|
|
81
|
+
if (command) toolCommands.push({ name: tool.name, command });
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
if (
|
|
84
|
+
if (toolCommands.length === 0) {
|
|
83
85
|
return { ok: null, summary: 'no hook command found', detail: 'Could not find an AI Lens hook command in any tool config' };
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
const toolResults = [];
|
|
89
|
+
for (const { name, command } of toolCommands) {
|
|
90
|
+
// Unique session_id per tool run so capture.log entries can be matched precisely (Issue 10).
|
|
91
|
+
const testSessionId = 'status-check-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
|
|
87
92
|
const testEvent = JSON.stringify({
|
|
88
93
|
hook_event_name: 'Stop',
|
|
89
|
-
session_id:
|
|
94
|
+
session_id: testSessionId,
|
|
90
95
|
stop_reason: 'test',
|
|
91
96
|
});
|
|
92
97
|
const testCmd = `echo '${testEvent.replace(/'/g, "'\\''")}' | ${command}`;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
98
|
+
let exitOk = false;
|
|
99
|
+
let exitDetail = '';
|
|
100
|
+
try {
|
|
101
|
+
execSync(testCmd, {
|
|
102
|
+
encoding: 'utf-8',
|
|
103
|
+
timeout: 10_000,
|
|
104
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
105
|
+
env: { ...process.env, AI_LENS_PROJECTS: '/ai-lens-status-check-nonexistent' },
|
|
106
|
+
});
|
|
107
|
+
exitOk = true;
|
|
108
|
+
exitDetail = 'exit 0';
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const stderr = err.stderr?.trim() || err.message;
|
|
111
|
+
exitDetail = `Exit code: ${err.status}\nError: ${stderr}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check capture.log for what actually happened to this test event (Issue 10).
|
|
115
|
+
// Exit 0 only proves capture didn't crash — the log entry shows whether it was
|
|
116
|
+
// properly processed (project_filter/no_email = expected) vs silently failed.
|
|
117
|
+
let captureNote = '';
|
|
118
|
+
try {
|
|
119
|
+
if (existsSync(CAPTURE_LOG_PATH)) {
|
|
120
|
+
const logLines = readFileSync(CAPTURE_LOG_PATH, 'utf-8').split('\n').filter(Boolean);
|
|
121
|
+
for (let i = logLines.length - 1; i >= 0; i--) {
|
|
122
|
+
try {
|
|
123
|
+
const entry = JSON.parse(logLines[i]);
|
|
124
|
+
if (entry.session_id === testSessionId) {
|
|
125
|
+
captureNote = entry.reason || entry.msg || 'unknown';
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
} catch { /* skip unparseable lines */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch { /* best effort */ }
|
|
132
|
+
|
|
133
|
+
toolResults.push({ name, ok: exitOk, cmd: testCmd, exitDetail, captureNote });
|
|
111
134
|
}
|
|
135
|
+
|
|
136
|
+
const allOk = toolResults.every(r => r.ok);
|
|
137
|
+
const failedTools = toolResults.filter(r => !r.ok).map(r => r.name);
|
|
138
|
+
const summaryParts = toolResults.map(r => {
|
|
139
|
+
const status = r.ok ? 'OK' : 'FAILED';
|
|
140
|
+
return r.captureNote ? `${r.name}: ${status} (${r.captureNote})` : `${r.name}: ${status}`;
|
|
141
|
+
});
|
|
142
|
+
const summary = allOk
|
|
143
|
+
? `capture runs OK (${summaryParts.join(', ')})`
|
|
144
|
+
: `capture failed for: ${failedTools.join(', ')}`;
|
|
145
|
+
const detail = toolResults.map(r =>
|
|
146
|
+
`[${r.name}]\n Ran: ${r.cmd}\n Result: ${r.exitDetail}${r.captureNote ? `\n Capture log: ${r.captureNote}` : ''}`
|
|
147
|
+
).join('\n\n');
|
|
148
|
+
|
|
149
|
+
return { ok: allOk, summary, detail };
|
|
112
150
|
}
|
|
113
151
|
|
|
114
152
|
function checkVersion() {
|
|
@@ -211,72 +249,45 @@ function checkHooks(tool) {
|
|
|
211
249
|
}
|
|
212
250
|
|
|
213
251
|
function checkQueue() {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
252
|
+
let pendingCount = 0;
|
|
253
|
+
let sendingCount = 0;
|
|
217
254
|
|
|
218
|
-
let lines;
|
|
219
255
|
try {
|
|
220
|
-
|
|
256
|
+
if (existsSync(PENDING_DIR)) {
|
|
257
|
+
pendingCount = readdirSync(PENDING_DIR).filter(f => f.endsWith('.json')).length;
|
|
258
|
+
}
|
|
221
259
|
} catch (err) {
|
|
222
|
-
return { ok: false, summary: `error reading
|
|
260
|
+
return { ok: false, summary: `error reading pending dir: ${err.message}`, detail: `Error: ${err.message}` };
|
|
223
261
|
}
|
|
224
262
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
let detail = `Queue: ${QUEUE_PATH}\nEvents: ${count}\nSize: ${sizeStr}`;
|
|
230
|
-
|
|
231
|
-
// Show last 3 events (truncated)
|
|
232
|
-
const last3 = lines.slice(-3);
|
|
233
|
-
if (last3.length > 0) {
|
|
234
|
-
detail += '\n\nLast events:';
|
|
235
|
-
for (const line of last3) {
|
|
236
|
-
try {
|
|
237
|
-
const evt = JSON.parse(line);
|
|
238
|
-
detail += `\n ${evt.type || 'unknown'} @ ${evt.timestamp || '?'} [${evt.session_id?.slice(0, 8) || '?'}]`;
|
|
239
|
-
} catch {
|
|
240
|
-
detail += `\n (unparseable line, ${line.length} chars)`;
|
|
241
|
-
}
|
|
263
|
+
try {
|
|
264
|
+
if (existsSync(SENDING_DIR)) {
|
|
265
|
+
sendingCount = readdirSync(SENDING_DIR).filter(f => f.endsWith('.json')).length;
|
|
242
266
|
}
|
|
243
|
-
}
|
|
267
|
+
} catch { /* best effort */ }
|
|
244
268
|
|
|
245
|
-
//
|
|
246
|
-
let
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (diffMs < 60_000) return `${Math.round(diffMs / 1000)}s ago`;
|
|
259
|
-
if (diffMs < 3600_000) return `${Math.round(diffMs / 60_000)}m ago`;
|
|
260
|
-
if (diffMs < 86400_000) return `${Math.round(diffMs / 3600_000)}h ago`;
|
|
261
|
-
return `${Math.round(diffMs / 86400_000)}d ago`;
|
|
262
|
-
};
|
|
263
|
-
timestamps.sort();
|
|
264
|
-
if (timestamps.length === 1 || timestamps[0] === timestamps[timestamps.length - 1]) {
|
|
265
|
-
timeRange = `, ${fmt(timestamps[0])}`;
|
|
266
|
-
} else {
|
|
267
|
-
timeRange = `, ${fmt(timestamps[0])}–${fmt(timestamps[timestamps.length - 1])}`;
|
|
269
|
+
// Show last 3 pending events (read individual files)
|
|
270
|
+
let detail = `Pending dir: ${PENDING_DIR}\nPending: ${pendingCount} events\nSending: ${sendingCount} events (in-flight)`;
|
|
271
|
+
if (pendingCount > 0) {
|
|
272
|
+
try {
|
|
273
|
+
const files = readdirSync(PENDING_DIR).filter(f => f.endsWith('.json')).sort().slice(-3);
|
|
274
|
+
detail += '\n\nLast pending events:';
|
|
275
|
+
for (const file of files) {
|
|
276
|
+
try {
|
|
277
|
+
const evt = JSON.parse(readFileSync(join(PENDING_DIR, file), 'utf-8'));
|
|
278
|
+
detail += `\n ${evt.type || 'unknown'} @ ${evt.timestamp || '?'} [${evt.session_id?.slice(0, 8) || '?'}]`;
|
|
279
|
+
} catch {
|
|
280
|
+
detail += `\n (unreadable: ${file})`;
|
|
281
|
+
}
|
|
268
282
|
}
|
|
269
|
-
}
|
|
283
|
+
} catch { /* best effort */ }
|
|
270
284
|
}
|
|
271
285
|
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
detail,
|
|
278
|
-
lineCount: count,
|
|
279
|
-
};
|
|
286
|
+
const ok = pendingCount < 100; // warn if many events are stuck
|
|
287
|
+
const summary = pendingCount === 0 && sendingCount === 0
|
|
288
|
+
? 'empty (0 events)'
|
|
289
|
+
: `${pendingCount} pending, ${sendingCount} sending`;
|
|
290
|
+
return { ok, summary, detail, lineCount: pendingCount };
|
|
280
291
|
}
|
|
281
292
|
|
|
282
293
|
function checkSenderLog() {
|
|
@@ -291,32 +302,51 @@ function checkSenderLog() {
|
|
|
291
302
|
return { ok: false, summary: `error reading log: ${err.message}`, detail: `Error: ${err.message}` };
|
|
292
303
|
}
|
|
293
304
|
|
|
294
|
-
|
|
305
|
+
// Aggregate across all log entries for observability
|
|
306
|
+
let sentOk = 0; // total events successfully delivered to server
|
|
307
|
+
let failedCount = 0; // total failed send attempts (connection/auth errors)
|
|
308
|
+
let rollbackCount = 0; // total rollbacks (partial + full) — events re-queued for retry
|
|
295
309
|
let lastSend = null;
|
|
296
310
|
let hasErrors = false;
|
|
297
311
|
|
|
298
|
-
for (const line of
|
|
312
|
+
for (const line of lines) {
|
|
299
313
|
try {
|
|
300
314
|
const entry = JSON.parse(line);
|
|
301
|
-
if (entry.msg === 'sent')
|
|
302
|
-
|
|
315
|
+
if (entry.msg === 'sent') {
|
|
316
|
+
lastSend = entry.ts;
|
|
317
|
+
sentOk += (entry.events || 0);
|
|
318
|
+
}
|
|
319
|
+
if (entry.msg === 'failed' || entry.msg === 'error' || entry.msg === 'auth-failed') {
|
|
320
|
+
hasErrors = true;
|
|
321
|
+
if (entry.msg === 'failed') failedCount++;
|
|
322
|
+
}
|
|
323
|
+
if (entry.msg === 'rollback' || entry.msg === 'partial-rollback') rollbackCount++;
|
|
303
324
|
} catch { /* non-JSON line */ }
|
|
304
325
|
}
|
|
305
326
|
|
|
327
|
+
const stats = [];
|
|
328
|
+
if (sentOk > 0) stats.push(`${sentOk} events sent`);
|
|
329
|
+
if (failedCount > 0) stats.push(`${failedCount} failed`);
|
|
330
|
+
if (rollbackCount > 0) stats.push(`${rollbackCount} rollbacks`);
|
|
331
|
+
|
|
306
332
|
let summary;
|
|
307
333
|
if (lastSend) {
|
|
308
334
|
summary = `last send ${relativeTime(lastSend)}`;
|
|
309
|
-
if (
|
|
335
|
+
if (stats.length > 0) summary += ` (${stats.join(', ')})`;
|
|
336
|
+
if (hasErrors) summary += ', has errors';
|
|
310
337
|
} else if (hasErrors) {
|
|
311
|
-
summary = '
|
|
338
|
+
summary = 'errors in log';
|
|
339
|
+
if (stats.length > 0) summary += ` (${stats.join(', ')})`;
|
|
312
340
|
} else {
|
|
313
341
|
summary = `${lines.length} entries`;
|
|
342
|
+
if (stats.length > 0) summary += ` (${stats.join(', ')})`;
|
|
314
343
|
}
|
|
315
344
|
|
|
345
|
+
const last20 = lines.slice(-20);
|
|
316
346
|
return {
|
|
317
347
|
ok: !hasErrors,
|
|
318
348
|
summary,
|
|
319
|
-
detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nLast 20 entries:\n${last20.join('\n')}`,
|
|
349
|
+
detail: `Log: ${LOG_PATH}\nTotal entries: ${lines.length}\nCounters: sent_ok=${sentOk}, failed=${failedCount}, rollbacks=${rollbackCount}\nLast 20 entries:\n${last20.join('\n')}`,
|
|
320
350
|
};
|
|
321
351
|
}
|
|
322
352
|
|
|
@@ -370,7 +400,7 @@ async function checkServer(serverUrl) {
|
|
|
370
400
|
const url = `${serverUrl}/api/health`;
|
|
371
401
|
const start = Date.now();
|
|
372
402
|
try {
|
|
373
|
-
const res = await fetch(url);
|
|
403
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
374
404
|
const latency = Date.now() - start;
|
|
375
405
|
const body = await res.text();
|
|
376
406
|
if (res.ok) {
|
|
@@ -403,7 +433,7 @@ async function checkToken(serverUrl, authToken) {
|
|
|
403
433
|
|
|
404
434
|
const url = `${serverUrl}/api/dashboard/overview`;
|
|
405
435
|
try {
|
|
406
|
-
const res = await fetch(url, { headers: { 'X-Auth-Token': authToken } });
|
|
436
|
+
const res = await fetch(url, { headers: { 'X-Auth-Token': authToken }, signal: AbortSignal.timeout(8000) });
|
|
407
437
|
if (res.ok) {
|
|
408
438
|
return { ok: true, summary: 'valid', detail: `Token validation: ${res.status} OK` };
|
|
409
439
|
}
|
|
@@ -443,6 +473,7 @@ async function checkE2e(serverUrl, authToken) {
|
|
|
443
473
|
...(name && { 'X-Developer-Name': name }),
|
|
444
474
|
},
|
|
445
475
|
body: JSON.stringify(testEvent),
|
|
476
|
+
signal: AbortSignal.timeout(8000),
|
|
446
477
|
});
|
|
447
478
|
const body = await res.json();
|
|
448
479
|
if (res.ok && body.received >= 1) {
|
|
@@ -508,16 +539,23 @@ function buildReport(results, timestamp) {
|
|
|
508
539
|
lines.push('');
|
|
509
540
|
}
|
|
510
541
|
|
|
511
|
-
// Queue content (
|
|
542
|
+
// Queue content (spool: list pending/ files)
|
|
512
543
|
lines.push(`${'='.repeat(60)}`);
|
|
513
|
-
lines.push(`
|
|
544
|
+
lines.push(`Pending queue (${PENDING_DIR}):`);
|
|
514
545
|
try {
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
546
|
+
const pendingFiles = existsSync(PENDING_DIR)
|
|
547
|
+
? readdirSync(PENDING_DIR).filter(f => f.endsWith('.json')).sort()
|
|
548
|
+
: [];
|
|
549
|
+
lines.push(`Total: ${pendingFiles.length} events`);
|
|
550
|
+
for (const file of pendingFiles.slice(0, 100)) {
|
|
551
|
+
try {
|
|
552
|
+
const evt = JSON.parse(readFileSync(join(PENDING_DIR, file), 'utf-8'));
|
|
553
|
+
lines.push(` ${file}: ${evt.type || 'unknown'} [${evt.session_id?.slice(0, 8) || '?'}]`);
|
|
554
|
+
} catch {
|
|
555
|
+
lines.push(` ${file}: (unreadable)`);
|
|
556
|
+
}
|
|
519
557
|
}
|
|
520
|
-
if (
|
|
558
|
+
if (pendingFiles.length > 100) lines.push(`... (${pendingFiles.length - 100} more)`);
|
|
521
559
|
} catch {
|
|
522
560
|
lines.push('(empty or not found)');
|
|
523
561
|
}
|
|
@@ -617,12 +655,12 @@ export default async function status() {
|
|
|
617
655
|
}
|
|
618
656
|
}
|
|
619
657
|
|
|
658
|
+
// 7. Queue (before capture test so test event doesn't show as pending)
|
|
659
|
+
printLine('Queue', checkQueue());
|
|
660
|
+
|
|
620
661
|
// 6b. Smoke-test the hook command
|
|
621
662
|
printLine('Capture test', checkCaptureRun(installedTools));
|
|
622
663
|
|
|
623
|
-
// 7. Queue
|
|
624
|
-
printLine('Queue', checkQueue());
|
|
625
|
-
|
|
626
664
|
// 8. Sender log
|
|
627
665
|
printLine('Sender log', checkSenderLog());
|
|
628
666
|
|