claude-code-session-manager 0.83.0 → 0.85.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.
Files changed (92) hide show
  1. package/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-Bkv-HcP1.js} +1 -1
  2. package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-DRH-Ty20.js} +1 -1
  3. package/dist/assets/{History-D3Qw2BWn.js → History-CfRhT1Im.js} +2 -2
  4. package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-wEmh_U6c.js} +1 -1
  5. package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-D_t7Rbi7.js} +1 -1
  6. package/dist/assets/{Library-DAMyrUtY.js → Library-CpArQ-OJ.js} +1 -1
  7. package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-pjaKYs84.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-Xc141kjj.js} +1 -1
  9. package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-ftqaV3kn.js} +1 -1
  10. package/dist/assets/{Memory-CgYJ_QqD.js → Memory-ChMWkNd0.js} +6 -6
  11. package/dist/assets/{Panel-dTNWC59Q.js → Panel-D9Kr40Ai.js} +1 -1
  12. package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-DKoNVgzj.js} +1 -1
  13. package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-BtChISho.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
  15. package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-I0_dWNTX.js} +1 -1
  16. package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-CbES7MC8.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-5GTEveb2.js} +1 -1
  18. package/dist/assets/Settings-BX3FElXk.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
  20. package/dist/assets/{Skills-DHb4INtF.js → Skills-DJB6-bBM.js} +2 -2
  21. package/dist/assets/SystemPrompt-BiDDrJUA.js +1 -0
  22. package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-_Wrevtop.js} +1 -1
  23. package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-OWWXdLRy.js} +1 -1
  24. package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-B122N0HL.js} +1 -1
  25. package/dist/assets/{index-CYhdtisq.css → index-CDo9xBR9.css} +1 -1
  26. package/dist/assets/{index-CzAxC432.js → index-DhvuQL4C.js} +316 -314
  27. package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-sGoCTd7J.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +149 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +242 -1
  51. package/src/main/__tests__/telemetryContract.test.cjs +930 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +103 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +626 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +123 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +136 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +95 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +241 -22
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +76 -0
  83. package/src/main/lib/telemetryClient.cjs +150 -5
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-DXVgKLUx.js +0 -3
  92. package/dist/assets/SystemPrompt-BjqFOHSk.js +0 -1
@@ -0,0 +1,601 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * telemetryBacklog.cjs — drains the PRE-EXISTING backlog of structured error
5
+ * logs (`<project>/session-manager-operations/logs/errors-<date>.jsonl`,
6
+ * written by opsErrorLog.cjs) into telemetryClient, and marks exactly what
7
+ * was CONFIRMED delivered so nothing is ever sent twice and nothing is
8
+ * silently lost.
9
+ *
10
+ * This is the "existing errors sent and marked done" half of the product
11
+ * instruction telemetryBoot.cjs documents ("on installation of a new version
12
+ * and loadup, existing errors are sent and marked done; new errors are
13
+ * accumulated and periodically sent (daily)"). telemetryBoot/telemetryClient
14
+ * own the daily-accumulate half; this module owns the backlog half. It does
15
+ * NOT send anything itself — telemetryClient.reportError/logLine/flush are
16
+ * the sole sender + the sole idempotency authority (recordId dedup,
17
+ * telemetry-sent.json). This module is a SECOND, independent bookkeeper over
18
+ * the same records (a byte-offset watermark), because a single writer can't
19
+ * safely satisfy both "resume where we left off" (needs an optimistic,
20
+ * fast-advancing offset) and "never lose a record to queue eviction" (needs
21
+ * a conservative, delivery-confirmed offset) at once — see the two-phase
22
+ * design below.
23
+ *
24
+ * TWO-PHASE WATERMARK. Per file, ~/.config/session-manager/telemetry-
25
+ * watermarks.json tracks:
26
+ * - bytesEnqueued: how far we've read + handed lines to telemetryClient.
27
+ * Advances the instant a line has been processed
28
+ * (whether telemetryClient's queue accepted it, rejected
29
+ * it as a duplicate, or dropped it via its own
30
+ * dedup window) — malformed/duplicate/deduped lines must
31
+ * never be able to wedge a file's forward progress, the
32
+ * same principle opsErrorLog-adjacent lines document for
33
+ * malformed JSON.
34
+ * - bytesConfirmed: only advances for the CONTIGUOUS prefix of lines whose
35
+ * recordId telemetryClient's flush() reports in its
36
+ * `sent` array. A failed flush leaves this untouched.
37
+ * - linesConfirmed / completedAt: bookkeeping for the retention-floor and
38
+ * already-fully-delivered fast paths below.
39
+ *
40
+ * DETERMINISTIC recordId = sha256(`${projectHash}|${filename}|${byteOffset}`)
41
+ * .slice(0, 16). Because it's a pure function of WHERE the line sits in the
42
+ * file (never a random id, never file content), re-deriving it after a
43
+ * watermark rewind reproduces the exact same id telemetryClient already
44
+ * holds/held — so a rewind is harmless (the client's own recordId dedup
45
+ * absorbs the resend) instead of duplicating records.
46
+ *
47
+ * BOOT RECONCILIATION (reconcileWatermarks, the anti-loss half): for any
48
+ * file where bytesConfirmed < bytesEnqueued, re-derive the recordIds for the
49
+ * bytes in that gap (cheap: recordId is a pure function of byte offset) and
50
+ * ask telemetryClient whether each is still pending delivery or already in
51
+ * its durable sent-set. Only when a record is in NEITHER (lost to queue
52
+ * eviction, or a corrupt queue file) do we rewind bytesEnqueued back down to
53
+ * bytesConfirmed so the next drain re-sends it.
54
+ *
55
+ * projectHash = sha256(normalizedProjectRoot).slice(0, 12) — same formula as
56
+ * telemetryClient.cjs's internal hashCwd (kept as an independent literal
57
+ * here, not a shared require, for the same reason opsErrorLog.cjs keeps its
58
+ * own REDACT_KEY copy: this module must have no dependency on telemetry
59
+ * client internals beyond its public ingest/egress contract). Keying the
60
+ * watermark file by hash rather than raw path means the one on-disk artifact
61
+ * this feature adds never itself stores a user's directory layout.
62
+ *
63
+ * Every entry point here is wrapped so it can never throw and never blocks
64
+ * app startup — telemetry failing must stay completely invisible to the user.
65
+ */
66
+
67
+ const fs = require('node:fs');
68
+ const fsp = require('node:fs/promises');
69
+ const os = require('node:os');
70
+ const path = require('node:path');
71
+ const crypto = require('node:crypto');
72
+
73
+ const config = require('../config.cjs');
74
+
75
+ const DEFAULT_LIMIT = 500;
76
+ const RETENTION_DAYS = 30;
77
+ const RETENTION_MS = RETENTION_DAYS * 24 * 60 * 60 * 1000;
78
+ const FILE_DATE_RE = /^errors-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
79
+ const NEWLINE = 0x0a;
80
+
81
+ /** Module-singleton cache of the most recent drainBacklog() summary, for the Settings inspector. */
82
+ let lastSummary = null;
83
+
84
+ function lastRunSummary() {
85
+ return lastSummary ? { ...lastSummary } : null;
86
+ }
87
+
88
+ function watermarksPath() {
89
+ return path.join(os.homedir(), '.config', 'session-manager', 'telemetry-watermarks.json');
90
+ }
91
+
92
+ function resolveDeps(deps = {}) {
93
+ return {
94
+ sessionsStore: deps.sessionsStore || require('../sessionsStore.cjs'),
95
+ isEphemeralCwd: deps.isEphemeralCwd || require('./ephemeralCwd.cjs').isEphemeralCwd,
96
+ resolveProjectContext: deps.resolveProjectContext || require('./projectRootResolve.cjs').resolveProjectContext,
97
+ telemetryClient: deps.telemetryClient || require('./telemetryClient.cjs'),
98
+ opsErrorLog: deps.opsErrorLog || require('./opsErrorLog.cjs'),
99
+ config: deps.config || config,
100
+ fs: deps.fs || fs,
101
+ fsp: deps.fsp || fsp,
102
+ };
103
+ }
104
+
105
+ function projectHashOf(normalizedCwd) {
106
+ return crypto.createHash('sha256').update(String(normalizedCwd)).digest('hex').slice(0, 12);
107
+ }
108
+
109
+ function recordIdFor(projectHash, filename, byteOffset) {
110
+ return crypto.createHash('sha256').update(`${projectHash}|${filename}|${byteOffset}`).digest('hex').slice(0, 16);
111
+ }
112
+
113
+ function watermarkKey(projectHash, filename) {
114
+ return `${projectHash}/${filename}`;
115
+ }
116
+
117
+ // ─── watermark persistence ─────────────────────────────────────────────
118
+
119
+ async function loadWatermarks(deps) {
120
+ try {
121
+ const raw = await deps.fsp.readFile(watermarksPath(), 'utf8');
122
+ const data = JSON.parse(raw);
123
+ if (data && typeof data === 'object' && !Array.isArray(data)) return data;
124
+ return {};
125
+ } catch {
126
+ // Missing or corrupt watermarks file is treated as empty, never fatal —
127
+ // worst case is a full (idempotent, deduped-by-recordId) re-drain.
128
+ return {};
129
+ }
130
+ }
131
+
132
+ async function saveWatermarks(deps, wm) {
133
+ try {
134
+ await deps.config.writeJson(watermarksPath(), wm);
135
+ } catch {
136
+ /* best-effort — a failed watermark write must never throw out of a boot path */
137
+ }
138
+ }
139
+
140
+ function entryFor(wm, key) {
141
+ const e = wm[key];
142
+ if (e && typeof e === 'object') {
143
+ return {
144
+ bytesEnqueued: Number.isFinite(e.bytesEnqueued) ? e.bytesEnqueued : 0,
145
+ bytesConfirmed: Number.isFinite(e.bytesConfirmed) ? e.bytesConfirmed : 0,
146
+ linesConfirmed: Number.isFinite(e.linesConfirmed) ? e.linesConfirmed : 0,
147
+ completedAt: typeof e.completedAt === 'string' ? e.completedAt : null,
148
+ };
149
+ }
150
+ return { bytesEnqueued: 0, bytesConfirmed: 0, linesConfirmed: 0, completedAt: null };
151
+ }
152
+
153
+ // ─── project + file enumeration ────────────────────────────────────────
154
+
155
+ /**
156
+ * Known projects come from the same source of truth the renderer restores
157
+ * tabs from (sessionsStore.cjs's ~/.config/session-manager/tabs.json — TAB =
158
+ * cwd = Main Project), deduped by normalized cwd, with ephemeral (worktree /
159
+ * tmpdir) cwds excluded before normalization even runs, since a worktree cwd
160
+ * would otherwise resolve back to its real project and be scanned twice
161
+ * under two identities. A cwd that no longer exists on disk is skipped, not
162
+ * thrown on.
163
+ */
164
+ async function enumerateProjectCwds(deps) {
165
+ const cwds = [];
166
+ const seen = new Set();
167
+ let tabs = [];
168
+ try {
169
+ const loaded = await deps.sessionsStore.load();
170
+ tabs = Array.isArray(loaded?.tabs) ? loaded.tabs : [];
171
+ } catch {
172
+ return cwds;
173
+ }
174
+ for (const tab of tabs) {
175
+ const rawCwd = tab && typeof tab.cwd === 'string' ? tab.cwd : '';
176
+ if (!rawCwd || !path.isAbsolute(rawCwd)) continue;
177
+ if (deps.isEphemeralCwd(rawCwd)) continue;
178
+ let normalized;
179
+ try {
180
+ normalized = deps.resolveProjectContext({ cwd: rawCwd }).cwd || rawCwd;
181
+ } catch {
182
+ normalized = rawCwd;
183
+ }
184
+ if (!normalized || deps.isEphemeralCwd(normalized)) continue;
185
+ if (seen.has(normalized)) continue;
186
+ let exists = false;
187
+ try {
188
+ exists = deps.fs.existsSync(normalized);
189
+ } catch {
190
+ exists = false;
191
+ }
192
+ if (!exists) continue;
193
+ seen.add(normalized);
194
+ cwds.push(normalized);
195
+ }
196
+ return cwds;
197
+ }
198
+
199
+ /** Oldest-first errors-YYYY-MM-DD.jsonl listing for one project's logs dir. */
200
+ function listLogFiles(deps, cwd) {
201
+ const dir = deps.opsErrorLog.logsDir(cwd);
202
+ let names;
203
+ try {
204
+ names = deps.fs.readdirSync(dir);
205
+ } catch {
206
+ return [];
207
+ }
208
+ const files = [];
209
+ for (const name of names) {
210
+ const m = FILE_DATE_RE.exec(name);
211
+ if (!m) continue;
212
+ const dateMs = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
213
+ files.push({ name, dateMs, fullPath: path.join(dir, name) });
214
+ }
215
+ files.sort((a, b) => a.dateMs - b.dateMs);
216
+ return files;
217
+ }
218
+
219
+ // ─── line parsing (byte-accurate, read-only) ───────────────────────────
220
+
221
+ /**
222
+ * Splits `buffer` into complete ('\n'-terminated) lines starting at
223
+ * `fromByte`. A trailing partial line (no newline yet — an in-progress
224
+ * write to today's file) is left unconsumed: neither counted nor advanced
225
+ * past, so the next drain picks it up once it's complete. O(n) in the
226
+ * scanned byte range.
227
+ */
228
+ function splitCompleteLines(buffer, fromByte) {
229
+ const lines = [];
230
+ let searchStart = fromByte;
231
+ while (true) {
232
+ const idx = buffer.indexOf(NEWLINE, searchStart);
233
+ if (idx === -1) break;
234
+ lines.push({
235
+ text: buffer.toString('utf8', searchStart, idx),
236
+ startByte: searchStart,
237
+ endByte: idx + 1,
238
+ });
239
+ searchStart = idx + 1;
240
+ }
241
+ return lines;
242
+ }
243
+
244
+ function levelFor(parsed) {
245
+ return parsed && parsed.level === 'warn' ? 'warn' : 'error';
246
+ }
247
+
248
+ async function reportLine(deps, { cwd, filename, recordId, parsed }) {
249
+ const level = levelFor(parsed);
250
+ const scope = (parsed && parsed.scope) || 'unknown';
251
+ const tags = Array.isArray(parsed && parsed.tags) ? parsed.tags : [];
252
+ const context = {
253
+ scope,
254
+ tags,
255
+ cwd,
256
+ tabId: parsed.tabId || null,
257
+ epicId: parsed.epicId || null,
258
+ backlogFile: filename,
259
+ };
260
+ if (level === 'warn') {
261
+ return deps.telemetryClient.logLine({ recordId, level: 'warn', msg: `backlog:${scope}`, fields: context });
262
+ }
263
+ return deps.telemetryClient.reportError({ recordId, name: scope || 'backlogError', msg: `backlog:${scope}`, context });
264
+ }
265
+
266
+ // ─── drain ──────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * drainBacklog({ now, limit, deps, reason }) — the main entry point.
270
+ *
271
+ * For every known, non-ephemeral project (oldest log file first within each
272
+ * project), reads lines from the current bytesEnqueued watermark forward,
273
+ * hands each to telemetryClient, then flushes once and advances
274
+ * bytesConfirmed for the contiguous prefix of lines flush() reports as
275
+ * `sent`. Bounded by `limit` lines total per call (default 500) so months of
276
+ * backlog can't emit one enormous burst — the remainder is picked up by the
277
+ * next call. Files older than the 30-day retention floor are stamped
278
+ * completed WITHOUT ever being opened for read. Never throws.
279
+ */
280
+ async function drainBacklog({ now = Date.now(), limit = DEFAULT_LIMIT, deps: depsOverride, reason = 'boot' } = {}) {
281
+ const deps = resolveDeps(depsOverride);
282
+ const summary = {
283
+ projectsScanned: 0,
284
+ filesScanned: 0,
285
+ linesEnqueued: 0,
286
+ linesConfirmed: 0,
287
+ linesSkipped: 0,
288
+ filesCompleted: 0,
289
+ watermarksRewound: 0,
290
+ reason,
291
+ };
292
+
293
+ try {
294
+ const reconcileSummary = await reconcileWatermarks({ now, deps: depsOverride });
295
+ summary.watermarksRewound += reconcileSummary.watermarksRewound;
296
+ } catch {
297
+ /* reconciliation must never block a drain attempt */
298
+ }
299
+
300
+ let wm;
301
+ try {
302
+ wm = await loadWatermarks(deps);
303
+ } catch {
304
+ wm = {};
305
+ }
306
+
307
+ let budget = Number.isFinite(limit) && limit > 0 ? limit : DEFAULT_LIMIT;
308
+ const cutoffMs = now - RETENTION_MS;
309
+
310
+ let cwds = [];
311
+ try {
312
+ cwds = await enumerateProjectCwds(deps);
313
+ } catch {
314
+ cwds = [];
315
+ }
316
+
317
+ for (const cwd of cwds) {
318
+ summary.projectsScanned += 1;
319
+ const projectHash = projectHashOf(cwd);
320
+ let files = [];
321
+ try {
322
+ files = listLogFiles(deps, cwd);
323
+ } catch {
324
+ files = [];
325
+ }
326
+
327
+ for (const file of files) {
328
+ if (budget <= 0) break;
329
+ const key = watermarkKey(projectHash, file.name);
330
+ const entry = entryFor(wm, key);
331
+
332
+ if (file.dateMs < cutoffMs) {
333
+ // Retention floor: adoption never backfills ancient history, and the
334
+ // file is never even stat'd to compute this.
335
+ if (!entry.completedAt) {
336
+ wm[key] = { ...entry, completedAt: new Date(now).toISOString() };
337
+ summary.filesCompleted += 1;
338
+ }
339
+ continue;
340
+ }
341
+
342
+ let stat;
343
+ try {
344
+ stat = deps.fs.statSync(file.fullPath);
345
+ } catch {
346
+ continue; // file vanished under us — skip, don't throw
347
+ }
348
+ const size = stat.size;
349
+
350
+ if (entry.completedAt && size === entry.bytesEnqueued) {
351
+ // Fully confirmed and unchanged since — skipped without being
352
+ // re-opened (only a cheap stat, never a content read).
353
+ continue;
354
+ }
355
+
356
+ summary.filesScanned += 1;
357
+
358
+ // Truncation/rotation safety: current size smaller than what we'd
359
+ // already claimed to have enqueued means the file was truncated —
360
+ // re-read from zero rather than seeking past EOF. A completed file
361
+ // whose size has since changed (append past completion, or
362
+ // truncation) is no longer "done" — it needs to be re-examined.
363
+ let bytesEnqueued = entry.bytesEnqueued;
364
+ let bytesConfirmed = entry.bytesConfirmed;
365
+ if (size < bytesEnqueued) {
366
+ bytesEnqueued = 0;
367
+ bytesConfirmed = 0;
368
+ }
369
+
370
+ if (bytesConfirmed >= size && size > 0) {
371
+ wm[key] = { ...entry, bytesEnqueued: size, bytesConfirmed: size, completedAt: new Date(now).toISOString() };
372
+ summary.filesCompleted += 1;
373
+ continue;
374
+ }
375
+
376
+ if (bytesEnqueued >= size) {
377
+ // Nothing new since last drain.
378
+ wm[key] = { ...entry, bytesEnqueued, bytesConfirmed, completedAt: null };
379
+ continue;
380
+ }
381
+
382
+ let buffer;
383
+ try {
384
+ buffer = await deps.fsp.readFile(file.fullPath);
385
+ } catch {
386
+ continue;
387
+ }
388
+
389
+ const lines = splitCompleteLines(buffer, bytesEnqueued);
390
+ const enqueuedThisFile = []; // { startByte, endByte, recordId }
391
+ let cursor = bytesEnqueued;
392
+
393
+ for (const line of lines) {
394
+ if (budget <= 0) break;
395
+ budget -= 1;
396
+ cursor = line.endByte;
397
+
398
+ let parsed = null;
399
+ try {
400
+ parsed = line.text.trim() ? JSON.parse(line.text) : null;
401
+ } catch {
402
+ parsed = null;
403
+ }
404
+ if (!parsed || typeof parsed !== 'object') {
405
+ summary.linesSkipped += 1;
406
+ continue;
407
+ }
408
+
409
+ const recordId = recordIdFor(projectHash, file.name, line.startByte);
410
+ try {
411
+ const result = await reportLine(deps, { cwd, filename: file.name, recordId, parsed });
412
+ if (result && result.accepted) summary.linesEnqueued += 1;
413
+ // Tracked for confirmation-prefix checking regardless of `accepted`
414
+ // — a 'duplicate'/dedup rejection just means telemetryClient
415
+ // already has (or already sent) this exact recordId, which the
416
+ // sentIds check below verifies independently.
417
+ enqueuedThisFile.push({ startByte: line.startByte, endByte: line.endByte, recordId });
418
+ } catch {
419
+ summary.linesSkipped += 1;
420
+ }
421
+ }
422
+
423
+ bytesEnqueued = cursor;
424
+ wm[key] = { ...entry, bytesEnqueued, bytesConfirmed, completedAt: null };
425
+ await saveWatermarks(deps, wm);
426
+
427
+ if (enqueuedThisFile.length > 0) {
428
+ let flushResult = { sent: [], failed: [] };
429
+ try {
430
+ flushResult = await deps.telemetryClient.flush('manual');
431
+ } catch {
432
+ flushResult = { sent: [], failed: [] };
433
+ }
434
+ const sentIds = new Set(flushResult.sent || []);
435
+
436
+ // Advance bytesConfirmed only across the CONTIGUOUS prefix (by byte
437
+ // offset) of lines this round that were actually confirmed sent —
438
+ // a gap (e.g. a later record's channel flushed but an earlier
439
+ // record's channel didn't) must stop the advance right there.
440
+ let confirmedThrough = bytesConfirmed;
441
+ for (const rec of enqueuedThisFile) {
442
+ if (rec.startByte !== confirmedThrough) break; // gap from a prior unconfirmed record
443
+ if (!sentIds.has(rec.recordId)) break;
444
+ confirmedThrough = rec.endByte;
445
+ summary.linesConfirmed += 1;
446
+ }
447
+ bytesConfirmed = confirmedThrough;
448
+
449
+ const finalEntry = { ...entry, bytesEnqueued, bytesConfirmed, completedAt: null };
450
+ if (bytesConfirmed >= size) {
451
+ finalEntry.completedAt = new Date(now).toISOString();
452
+ summary.filesCompleted += 1;
453
+ }
454
+ wm[key] = finalEntry;
455
+ await saveWatermarks(deps, wm);
456
+ }
457
+
458
+ // Yield between files so a large backlog never blocks the event loop.
459
+ await new Promise((resolve) => setImmediate(resolve));
460
+ }
461
+ }
462
+
463
+ lastSummary = { ...summary, ranAt: new Date(now).toISOString() };
464
+ return summary;
465
+ }
466
+
467
+ // ─── boot reconciliation (anti-loss) ───────────────────────────────────
468
+
469
+ /**
470
+ * reconcileWatermarks({ now, deps }) — for any file whose watermark shows
471
+ * bytesConfirmed < bytesEnqueued, re-derives the recordIds for the bytes in
472
+ * that gap and checks whether telemetryClient still has each one pending, or
473
+ * already recorded as sent. Only when a record is in NEITHER (queue
474
+ * eviction, or a corrupt queue/sent file) is bytesEnqueued rewound back down
475
+ * to bytesConfirmed so the next drainBacklog() call re-sends it. Never
476
+ * throws.
477
+ */
478
+ async function reconcileWatermarks({ now = Date.now(), deps: depsOverride } = {}) {
479
+ const deps = resolveDeps(depsOverride);
480
+ const summary = { watermarksRewound: 0 };
481
+
482
+ let wm;
483
+ try {
484
+ wm = await loadWatermarks(deps);
485
+ } catch {
486
+ return summary;
487
+ }
488
+
489
+ let sentIds = new Set();
490
+ try {
491
+ const raw = await deps.fsp.readFile(deps.telemetryClient.sentPath(), 'utf8');
492
+ const data = JSON.parse(raw);
493
+ if (data && Array.isArray(data.ids)) sentIds = new Set(data.ids);
494
+ } catch {
495
+ sentIds = new Set();
496
+ }
497
+
498
+ let cwds = [];
499
+ try {
500
+ cwds = await enumerateProjectCwds(deps);
501
+ } catch {
502
+ cwds = [];
503
+ }
504
+
505
+ const cwdByHash = new Map();
506
+ for (const cwd of cwds) cwdByHash.set(projectHashOf(cwd), cwd);
507
+
508
+ let changed = false;
509
+ for (const key of Object.keys(wm)) {
510
+ const slash = key.indexOf('/');
511
+ if (slash === -1) continue;
512
+ const projectHash = key.slice(0, slash);
513
+ const filename = key.slice(slash + 1);
514
+ const cwd = cwdByHash.get(projectHash);
515
+ if (!cwd) continue; // project no longer known — leave watermark as-is
516
+
517
+ const entry = entryFor(wm, key);
518
+ if (entry.bytesConfirmed >= entry.bytesEnqueued) continue;
519
+
520
+ const filePath = path.join(deps.opsErrorLog.logsDir(cwd), filename);
521
+ let buffer;
522
+ try {
523
+ buffer = await deps.fsp.readFile(filePath);
524
+ } catch {
525
+ continue; // can't verify without the file — leave as-is rather than guess
526
+ }
527
+
528
+ const gapEnd = Math.min(entry.bytesEnqueued, buffer.length);
529
+ const lines = splitCompleteLines(buffer.subarray(0, gapEnd), entry.bytesConfirmed);
530
+ if (lines.length === 0) continue;
531
+
532
+ let allAccountedFor = true;
533
+ for (const line of lines) {
534
+ const recordId = recordIdFor(projectHash, filename, line.startByte);
535
+ let pending = false;
536
+ try {
537
+ pending = await deps.telemetryClient.isPending(recordId);
538
+ } catch {
539
+ pending = false;
540
+ }
541
+ if (pending || sentIds.has(recordId)) continue;
542
+ allAccountedFor = false;
543
+ break;
544
+ }
545
+
546
+ if (!allAccountedFor) {
547
+ wm[key] = { ...entry, bytesEnqueued: entry.bytesConfirmed };
548
+ summary.watermarksRewound += 1;
549
+ changed = true;
550
+ }
551
+ }
552
+
553
+ if (changed) await saveWatermarks(deps, wm);
554
+ return summary;
555
+ }
556
+
557
+ // ─── boot wiring (the WHEN, mirroring telemetryBoot.cjs's own split) ────
558
+
559
+ /**
560
+ * bootDrain({ now, appVersion, deps }) — the call site index.cjs's
561
+ * `ready-to-show` handler invokes: always drains once with reason 'boot',
562
+ * and — the FIRST time this appVersion has ever been seen by the drainer
563
+ * (tracked in the watermarks file's own `_meta.lastDrainVersion`, so this
564
+ * module owns its own version-change bookkeeping rather than reaching into
565
+ * telemetrySettings.json's strict, unrelated schema) — drains a second time
566
+ * tagged reason 'version-change'. The second pass is cheap even when
567
+ * nothing new exists to send: every file it would touch was already
568
+ * advanced by the first pass in this same call, so it's a no-op scan.
569
+ * Never throws.
570
+ */
571
+ async function bootDrain({ now = Date.now(), appVersion, deps: depsOverride } = {}) {
572
+ const deps = resolveDeps(depsOverride);
573
+ const summary = await drainBacklog({ now, deps: depsOverride, reason: 'boot' });
574
+
575
+ try {
576
+ const wm = await loadWatermarks(deps);
577
+ const meta = (wm && typeof wm._meta === 'object' && wm._meta) || {};
578
+ if (appVersion && meta.lastDrainVersion !== appVersion) {
579
+ await drainBacklog({ now, deps: depsOverride, reason: 'version-change' });
580
+ const wm2 = await loadWatermarks(deps);
581
+ wm2._meta = { ...(wm2._meta && typeof wm2._meta === 'object' ? wm2._meta : {}), lastDrainVersion: appVersion };
582
+ await saveWatermarks(deps, wm2);
583
+ }
584
+ } catch {
585
+ /* version-change bookkeeping must never block the ordinary boot drain above */
586
+ }
587
+
588
+ return summary;
589
+ }
590
+
591
+ module.exports = {
592
+ drainBacklog,
593
+ reconcileWatermarks,
594
+ bootDrain,
595
+ watermarksPath,
596
+ lastRunSummary,
597
+ // exported for unit tests only
598
+ _projectHashOf: projectHashOf,
599
+ _recordIdFor: recordIdFor,
600
+ _enumerateProjectCwds: enumerateProjectCwds,
601
+ };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * telemetryBoot.cjs — the WHEN, not the WHAT, for telemetry egress: decides
5
+ * when the once-per-machine profile goes out and when an accumulated batch
6
+ * gets a delivery attempt. Called once from index.cjs's app.whenReady.
7
+ *
8
+ * Cadence (product owner's explicit instruction): existing errors go out on
9
+ * load and on a new version; new errors accumulate and are sent daily. This
10
+ * module owns exactly the two non-daily send triggers — boot and
11
+ * version-change — plus the periodic machine-profile heartbeat
12
+ * (telemetrySettings.isMachineReportDue) that turns "ever installed" into
13
+ * "actively installed". It never calls flush() outside those two triggers —
14
+ * the daily cadence is telemetryClient's own internal timer.
15
+ *
16
+ * Pure orchestration: every dependency is injectable via `deps` (mirrors
17
+ * telemetryClient.cjs's own test-hook style) so this runs under plain vitest
18
+ * with no Electron runtime and no real network/CLI probes.
19
+ */
20
+
21
+ function resolveDeps(deps = {}) {
22
+ return {
23
+ telemetrySettings: deps.telemetrySettings || require('./telemetrySettings.cjs'),
24
+ telemetryClient: deps.telemetryClient || require('./telemetryClient.cjs'),
25
+ buildMachineProfile: deps.buildMachineProfile || require('./machineProfile.cjs').buildMachineProfile,
26
+ telemetryCounters: deps.telemetryCounters || require('./telemetryCounters.cjs'),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * bootSequence({ now, appVersion, installChannel, deps }) — call once per
32
+ * process from app.whenReady.
33
+ *
34
+ * 1. flush('boot') — always, so a freshly-installed version delivers
35
+ * everything the previous version accumulated.
36
+ * 2. flush('version-change') — only when this install's persisted
37
+ * lastMachineReportVersion differs from the running appVersion (the same
38
+ * signal that also gates the machine-profile heartbeat below).
39
+ * 3. install upsert — sent once via telemetryClient.reportInstall(profile)
40
+ * when telemetrySettings.isMachineReportDue() is true (a version bump, OR
41
+ * the 30-day liveness heartbeat), then lastMachineReportAt/Version are
42
+ * persisted so a same-version reboot within the window sends nothing.
43
+ * This lands in bilko.run's app_installs table (the sole source for
44
+ * every install-shaped number on the site). It replaces the former
45
+ * track('install.machine', profile) call, which duplicated the same
46
+ * facts into funnel_events for no reader — app_installs is now the only
47
+ * destination for a machine profile.
48
+ * 4. app.launch — one counter event per boot, via telemetryCounters so the
49
+ * shape lives in exactly one place across every counter this PRD adds.
50
+ */
51
+ async function bootSequence({ now = Date.now(), appVersion, installChannel, deps: depsOverride } = {}) {
52
+ const deps = resolveDeps(depsOverride);
53
+ const { telemetrySettings, telemetryClient, buildMachineProfile, telemetryCounters } = deps;
54
+
55
+ const settings = await telemetrySettings.load();
56
+ const versionChanged = settings.lastMachineReportVersion !== appVersion;
57
+
58
+ await telemetryClient.flush('boot');
59
+ if (versionChanged) {
60
+ await telemetryClient.flush('version-change');
61
+ }
62
+
63
+ if (telemetrySettings.isMachineReportDue(settings, { now, appVersion })) {
64
+ const profile = await buildMachineProfile();
65
+ await telemetryClient.reportInstall(profile);
66
+ await telemetrySettings.save({
67
+ ...settings,
68
+ lastMachineReportAt: new Date(now).toISOString(),
69
+ lastMachineReportVersion: appVersion,
70
+ });
71
+ }
72
+
73
+ telemetryCounters.trackAppLaunch({ installChannel, appVersion });
74
+ }
75
+
76
+ module.exports = { bootSequence };