claude-code-session-manager 0.84.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 (41) hide show
  1. package/dist/assets/{AgentLibrary-CCVIpoSz.js → AgentLibrary-Bkv-HcP1.js} +1 -1
  2. package/dist/assets/{DataModel-BltREYde.js → DataModel-DRH-Ty20.js} +1 -1
  3. package/dist/assets/{History-BZxFkOp6.js → History-CfRhT1Im.js} +1 -1
  4. package/dist/assets/{Hooks-Bznlfaaa.js → Hooks-wEmh_U6c.js} +1 -1
  5. package/dist/assets/{HostBilko-DUG5YHA_.js → HostBilko-D_t7Rbi7.js} +1 -1
  6. package/dist/assets/{Library-Bi2Fn3w9.js → Library-CpArQ-OJ.js} +1 -1
  7. package/dist/assets/{ListDetail-4VBvXKrz.js → ListDetail-pjaKYs84.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-D2ft_v5j.js → MarkdownEditor-Xc141kjj.js} +1 -1
  9. package/dist/assets/{McpServers-FDekKkyE.js → McpServers-ftqaV3kn.js} +1 -1
  10. package/dist/assets/{Memory-Dkl80uj_.js → Memory-ChMWkNd0.js} +1 -1
  11. package/dist/assets/{Panel-LurG5VfD.js → Panel-D9Kr40Ai.js} +1 -1
  12. package/dist/assets/{Permissions-D2wHBCFA.js → Permissions-DKoNVgzj.js} +1 -1
  13. package/dist/assets/{Plugins-CTw_wwbI.js → Plugins-BtChISho.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-CW6HNUv6.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
  15. package/dist/assets/{SaveBar-alDHG6cP.js → SaveBar-I0_dWNTX.js} +1 -1
  16. package/dist/assets/{Scheduler-DxiPcaiW.js → Scheduler-CbES7MC8.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-DzFXUKLZ.js → ScopeSwitcher-5GTEveb2.js} +1 -1
  18. package/dist/assets/{Settings-B6H3v2am.js → Settings-BX3FElXk.js} +3 -3
  19. package/dist/assets/{SkillReferenceGraph-B8EZalpN.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
  20. package/dist/assets/{Skills-DwuHuA08.js → Skills-DJB6-bBM.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CMMqpGYn.js → SystemPrompt-BiDDrJUA.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2N5C1n9.js → TagLibrary-_Wrevtop.js} +1 -1
  23. package/dist/assets/{TiptapBody-btlID-dQ.js → TiptapBody-OWWXdLRy.js} +1 -1
  24. package/dist/assets/{Toggle-BGOn3DZj.js → Toggle-B122N0HL.js} +1 -1
  25. package/dist/assets/{index-mnjNDpb1.css → index-CDo9xBR9.css} +1 -1
  26. package/dist/assets/{index-QLRf0epp.js → index-DhvuQL4C.js} +3 -3
  27. package/dist/assets/{settingsSchema-DA3N2Up3.js → settingsSchema-sGoCTd7J.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +6 -0
  31. package/src/main/__tests__/telemetryClient.test.cjs +176 -1
  32. package/src/main/__tests__/telemetryContract.test.cjs +74 -27
  33. package/src/main/index.cjs +2 -2
  34. package/src/main/lib/__tests__/crashTelemetry.test.cjs +6 -0
  35. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +6 -0
  36. package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -13
  37. package/src/main/lib/__tests__/telemetryConsent.test.cjs +6 -0
  38. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +6 -0
  39. package/src/main/lib/queueStore.cjs +8 -2
  40. package/src/main/lib/telemetryBoot.cjs +9 -4
  41. package/src/main/lib/telemetryClient.cjs +119 -5
@@ -78,8 +78,9 @@ async function createStubServer() {
78
78
  events: [], // { event, tool, metadata, session_id, visitor_id, path, version }
79
79
  logs: [], // { app, version, level, msg, visitor_id, session_id, fields_json, created_at }
80
80
  errors: [], // { app, version, name, msg, stack, url, ua, visitor_id, session_id, context_json, created_at }
81
+ installsById: new Map(), // install_id -> materialised app_installs row (upsert, mirrors the deployed route)
81
82
  allRequests: [], // { path, headers, rawBody, parsedBody, channel }
82
- overrides: { event: [], log: [], error: [] },
83
+ overrides: { event: [], log: [], error: [], install: [] },
83
84
  };
84
85
 
85
86
  function nextOverride(channel) {
@@ -95,6 +96,7 @@ async function createStubServer() {
95
96
  const channel = urlPath.endsWith('/event') ? 'event'
96
97
  : urlPath.endsWith('/log') ? 'log'
97
98
  : urlPath.endsWith('/error') ? 'error'
99
+ : urlPath.endsWith('/install') ? 'install'
98
100
  : null;
99
101
  state.allRequests.push({ path: urlPath, headers: req.headers, rawBody, parsedBody, channel });
100
102
 
@@ -111,6 +113,41 @@ async function createStubServer() {
111
113
  return;
112
114
  }
113
115
 
116
+ if (channel === 'install') {
117
+ // Flat body (never {batch:[...]}) — mirrors the real route's upsert
118
+ // keyed on install_id: first_seen_at preserved, everything else overwritten.
119
+ const b = parsedBody;
120
+ const installId = clamp(b.install_id, 80);
121
+ if (!installId) {
122
+ res.writeHead(400, { 'content-type': 'application/json' });
123
+ res.end(JSON.stringify({ error: 'install_id_required' }));
124
+ return;
125
+ }
126
+ const now = Math.floor(Date.now() / 1000);
127
+ const existing = state.installsById.get(installId);
128
+ state.installsById.set(installId, {
129
+ install_id: installId,
130
+ app: clamp(b.app, 60),
131
+ app_version: clamp(b.app_version, 20),
132
+ platform: clamp(b.platform, 20),
133
+ os_release: clamp(b.os_release, 80),
134
+ arch: clamp(b.arch, 20),
135
+ cpu_count: Number(b.cpu_count) || 0,
136
+ total_mem_mb: Number(b.total_mem_mb) || 0,
137
+ node_version: clamp(b.node_version, 20),
138
+ electron_version: clamp(b.electron_version, 20),
139
+ install_channel: clamp(b.install_channel, 20),
140
+ locale: clamp(b.locale, 20),
141
+ timezone: clamp(b.timezone, 20),
142
+ first_seen_at: existing ? existing.first_seen_at : now,
143
+ last_seen_at: now,
144
+ seen_count: existing ? existing.seen_count + 1 : 1,
145
+ });
146
+ res.writeHead(200, { 'content-type': 'application/json' });
147
+ res.end(JSON.stringify({ ok: true }));
148
+ return;
149
+ }
150
+
114
151
  const batchRaw = Array.isArray(parsedBody.batch) ? parsedBody.batch : [];
115
152
  const batch = batchRaw.slice(0, MAX_BATCH); // MAX_BATCH truncation, exactly as the deployed route does
116
153
 
@@ -228,6 +265,7 @@ let originalHome;
228
265
  let originalSmTelemetry;
229
266
  let originalSmTelemetryEndpoint;
230
267
  let originalSmBeaconKey;
268
+ let originalSmTelemetrySpool;
231
269
  let stubServer;
232
270
 
233
271
  beforeAll(async () => {
@@ -247,6 +285,7 @@ beforeEach(() => {
247
285
  originalSmTelemetry = process.env.SM_TELEMETRY;
248
286
  originalSmTelemetryEndpoint = process.env.SM_TELEMETRY_ENDPOINT;
249
287
  originalSmBeaconKey = process.env.SM_BEACON_KEY;
288
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
250
289
  process.env.SM_TELEMETRY_ENDPOINT = stubServer.url;
251
290
  delete process.env.SM_TELEMETRY;
252
291
  });
@@ -256,6 +295,7 @@ afterEach(async () => {
256
295
  if (originalSmTelemetry === undefined) delete process.env.SM_TELEMETRY; else process.env.SM_TELEMETRY = originalSmTelemetry;
257
296
  if (originalSmTelemetryEndpoint === undefined) delete process.env.SM_TELEMETRY_ENDPOINT; else process.env.SM_TELEMETRY_ENDPOINT = originalSmTelemetryEndpoint;
258
297
  if (originalSmBeaconKey === undefined) delete process.env.SM_BEACON_KEY; else process.env.SM_BEACON_KEY = originalSmBeaconKey;
298
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
259
299
  vi.useRealTimers();
260
300
  while (tmpDirs.length) {
261
301
  const d = tmpDirs.pop();
@@ -306,6 +346,9 @@ const HOME_DEPENDENT_MODULES = ['../lib/telemetryClient.cjs', '../config.cjs', '
306
346
  /** A fresh telemetryClient bound to `home`, simulating a real relaunch: only on-disk state carries over. */
307
347
  function freshClient(home, profileOverrides) {
308
348
  process.env.HOME = home;
349
+ // Explicit opt-in: overrides the test-environment no-op guard so this
350
+ // suite's real telemetryClient calls actually persist into an isolated dir.
351
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
309
352
  for (const p of HOME_DEPENDENT_MODULES) {
310
353
  const resolved = require.resolve(p);
311
354
  delete require.cache[resolved];
@@ -490,30 +533,30 @@ test('an emitted event, log, and error each map to non-NULL values for every col
490
533
  // AC: machine-profile record survives redaction + clamping intact
491
534
  // ═══════════════════════════════════════════════════════════════════════
492
535
 
493
- test('the machine-profile record arrives with every buildMachineProfile() field present after redaction/clamping, cpuModel verbatim', async () => {
536
+ test('the install record arrives at app_installs with every mapped field present, snake_case, cpu_count/total_mem_mb numeric', async () => {
494
537
  const home = await mkHome();
495
538
  const client = freshClient(home);
496
539
  const profile = fakeProfile();
497
- const before = stubServer.state.events.length;
498
540
 
499
- await client.track('install.machine', profile);
541
+ await client.reportInstall(profile);
500
542
  await client.flush('manual');
501
543
 
502
- const row = stubServer.state.events[before];
503
- expect(row.event).toBe('install.machine');
504
- const meta = JSON.parse(row.metadata);
505
-
506
- const expectedFields = [
507
- 'appVersion', 'installChannel', 'machineDigest', 'platform', 'osRelease', 'arch',
508
- 'cpuModel', 'cpuCount', 'cpuSpeedMhz', 'totalMemMb', 'nodeVersion', 'electronVersion',
509
- 'chromeVersion', 'v8Version', 'claudeCliVersion', 'locale', 'timezoneOffsetMinutes', 'firstSeenAt',
510
- ];
511
- for (const f of expectedFields) {
512
- expect(meta[f]).not.toBeUndefined();
513
- expect(meta[f]).not.toBeNull();
514
- }
515
- // The over-eager-sanitiser casualty: a CPU model string containing spaces and '@'.
516
- expect(meta.cpuModel).toBe('Intel(R) Xeon(R) CPU @ 2.20GHz');
544
+ const settings = require('../lib/telemetrySettings.cjs');
545
+ const persisted = await settings.load();
546
+ const row = stubServer.state.installsById.get(persisted.installId);
547
+ expect(row).toBeDefined();
548
+ expect(row.app).toBe('session-manager');
549
+ expect(row.app_version).toBe(profile.appVersion);
550
+ expect(row.platform).toBe(profile.platform);
551
+ expect(row.os_release).toBe(profile.osRelease);
552
+ expect(row.arch).toBe(profile.arch);
553
+ expect(row.cpu_count).toBe(profile.cpuCount);
554
+ expect(row.total_mem_mb).toBe(profile.totalMemMb);
555
+ expect(row.node_version).toBe(profile.nodeVersion);
556
+ expect(row.electron_version).toBe(profile.electronVersion);
557
+ expect(row.install_channel).toBe(profile.installChannel);
558
+ expect(row.locale).toBe(profile.locale);
559
+ expect(row.timezone).toBe(String(profile.timezoneOffsetMinutes));
517
560
 
518
561
  client.shutdown();
519
562
  });
@@ -522,23 +565,27 @@ test('the machine-profile record arrives with every buildMachineProfile() field
522
565
  // AC: machine profile sent once per version + once per 30-day heartbeat
523
566
  // ═══════════════════════════════════════════════════════════════════════
524
567
 
525
- test('install.machine fires once per app version and once per 30-day heartbeat, not once per launch', async () => {
568
+ test('the install upsert fires once per app version and once per 30-day heartbeat, not once per launch', async () => {
526
569
  const home = await mkHome();
527
- const before = stubServer.state.events.length;
570
+ let installId = null;
528
571
 
529
- function countInstallMachine() {
530
- return stubServer.state.events.slice(before).filter((r) => r.event === 'install.machine').length;
572
+ function seenCount() {
573
+ if (!installId) return 0;
574
+ const row = stubServer.state.installsById.get(installId);
575
+ return row ? row.seen_count : 0;
531
576
  }
532
577
 
533
578
  async function simulatedLaunch({ now, appVersion }) {
534
579
  const client = freshClient(home, { appVersion });
535
580
  const boot = freshBoot();
581
+ const telemetrySettings = require('../lib/telemetrySettings.cjs');
582
+ if (!installId) installId = (await telemetrySettings.load()).installId;
536
583
  await boot.bootSequence({
537
584
  now,
538
585
  appVersion,
539
586
  installChannel: 'dev',
540
587
  deps: {
541
- telemetrySettings: require('../lib/telemetrySettings.cjs'),
588
+ telemetrySettings,
542
589
  telemetryClient: client,
543
590
  buildMachineProfile: async () => fakeProfile({ appVersion }),
544
591
  telemetryCounters: { trackAppLaunch: () => {} },
@@ -557,15 +604,15 @@ test('install.machine fires once per app version and once per 30-day heartbeat,
557
604
  await simulatedLaunch({ now: t0 + 2 * ONE_DAY, appVersion: '1.0.0' });
558
605
  await simulatedLaunch({ now: t0 + 3 * ONE_DAY, appVersion: '1.0.0' });
559
606
  await simulatedLaunch({ now: t0 + 4 * ONE_DAY, appVersion: '1.0.0' });
560
- expect(countInstallMachine()).toBe(1);
607
+ expect(seenCount()).toBe(1);
561
608
 
562
609
  // Launch 6: version bump.
563
610
  await simulatedLaunch({ now: t0 + 5 * ONE_DAY, appVersion: '1.1.0' });
564
- expect(countInstallMachine()).toBe(2);
611
+ expect(seenCount()).toBe(2);
565
612
 
566
613
  // Launch 7: 31 days after the version-bump report, same version -> heartbeat due.
567
614
  await simulatedLaunch({ now: t0 + 5 * ONE_DAY + 31 * ONE_DAY, appVersion: '1.1.0' });
568
- expect(countInstallMachine()).toBe(3);
615
+ expect(seenCount()).toBe(3);
569
616
  });
570
617
 
571
618
  // ═══════════════════════════════════════════════════════════════════════
@@ -1070,8 +1070,8 @@ app.whenReady().then(async () => {
1070
1070
  });
1071
1071
 
1072
1072
  // Telemetry boot sequence: flush('boot') always, flush('version-change')
1073
- // when detected, and the once-per-machine install.machine profile when due
1074
- // (version bump or 30-day liveness heartbeat). See telemetryBoot.cjs.
1073
+ // when detected, and the once-per-machine install upsert (app_installs) when
1074
+ // due (version bump or 30-day liveness heartbeat). See telemetryBoot.cjs.
1075
1075
  require('./lib/telemetryBoot.cjs')
1076
1076
  .bootSequence({
1077
1077
  appVersion: app.getVersion(),
@@ -16,9 +16,11 @@ const { reportCrash } = require('../crashTelemetry.cjs');
16
16
 
17
17
  const tmpDirs = [];
18
18
  let originalHome;
19
+ let originalSmTelemetrySpool;
19
20
 
20
21
  afterEach(async () => {
21
22
  if (originalHome !== undefined) process.env.HOME = originalHome;
23
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
22
24
  while (tmpDirs.length) {
23
25
  const d = tmpDirs.pop();
24
26
  await fsp.rm(d, { recursive: true, force: true });
@@ -27,9 +29,13 @@ afterEach(async () => {
27
29
 
28
30
  async function mkHome() {
29
31
  originalHome = process.env.HOME;
32
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
30
33
  const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-crash-telemetry-home-'));
31
34
  tmpDirs.push(dir);
32
35
  process.env.HOME = dir;
36
+ // Explicit opt-in: overrides the test-environment no-op guard so this
37
+ // suite's real telemetryClient calls actually persist into an isolated dir.
38
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
33
39
  return dir;
34
40
  }
35
41
 
@@ -18,13 +18,16 @@ const { resolveProjectContext } = require('../projectRootResolve.cjs');
18
18
 
19
19
  const tmpDirs = [];
20
20
  let originalHome;
21
+ let originalSmTelemetrySpool;
21
22
 
22
23
  beforeEach(() => {
23
24
  originalHome = process.env.HOME;
25
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
24
26
  });
25
27
 
26
28
  afterEach(async () => {
27
29
  if (originalHome !== undefined) process.env.HOME = originalHome;
30
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
28
31
  vi.unstubAllGlobals();
29
32
  while (tmpDirs.length) {
30
33
  const d = tmpDirs.pop();
@@ -51,6 +54,9 @@ function fakeProfile(overrides = {}) {
51
54
  /** Reloads the HOME-dependent modules (config.cjs computes allowedRoots from os.homedir() at require time). */
52
55
  function freshTelemetry(home) {
53
56
  process.env.HOME = home;
57
+ // Explicit opt-in: overrides the test-environment no-op guard so this
58
+ // suite's real telemetryClient calls actually persist into an isolated dir.
59
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
54
60
  for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
55
61
  const resolved = require.resolve(p);
56
62
  delete require.cache[resolved];
@@ -27,7 +27,7 @@ function fakeDeps({ settings } = {}) {
27
27
  },
28
28
  telemetryClient: {
29
29
  flush: async (reason) => { flushCalls.push(reason); return { sent: [], failed: [], reason }; },
30
- track: async (name, props) => ({ accepted: true, name, props }),
30
+ reportInstall: async (profile) => ({ accepted: true, profile }),
31
31
  },
32
32
  buildMachineProfile: async () => ({ appVersion: '1.0.0', platform: 'linux', arch: 'x64', machineDigest: 'abc123' }),
33
33
  telemetryCounters: { trackAppLaunch: () => {} },
@@ -42,11 +42,11 @@ test('a second boot at the same version within 30 days sends nothing', async ()
42
42
  settings: { lastMachineReportAt: priorReportAt, lastMachineReportVersion: '1.0.0' },
43
43
  });
44
44
  const seen = [];
45
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
45
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
46
46
 
47
47
  await bootSequence({ now, appVersion: '1.0.0', deps });
48
48
 
49
- expect(seen.filter((e) => e.name === 'install.machine')).toHaveLength(0);
49
+ expect(seen).toHaveLength(0);
50
50
  expect(state().lastMachineReportAt).toBe(priorReportAt);
51
51
  });
52
52
 
@@ -56,12 +56,11 @@ test('a version bump sends the machine profile exactly once', async () => {
56
56
  settings: { lastMachineReportAt: new Date(now - DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
57
57
  });
58
58
  const seen = [];
59
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
59
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
60
60
 
61
61
  await bootSequence({ now, appVersion: '2.0.0', deps });
62
62
 
63
- const installEvents = seen.filter((e) => e.name === 'install.machine');
64
- expect(installEvents.length).toBe(1);
63
+ expect(seen.length).toBe(1);
65
64
  expect(state().lastMachineReportVersion).toBe('2.0.0');
66
65
  });
67
66
 
@@ -71,27 +70,26 @@ test('31 days on an unchanged version sends the machine profile exactly once (li
71
70
  settings: { lastMachineReportAt: new Date(now - 31 * DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
72
71
  });
73
72
  const seen = [];
74
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
73
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
75
74
 
76
75
  await bootSequence({ now, appVersion: '1.0.0', deps });
77
76
 
78
- const installEvents = seen.filter((e) => e.name === 'install.machine');
79
- expect(installEvents.length).toBe(1);
77
+ expect(seen.length).toBe(1);
80
78
  expect(state().lastMachineReportAt).toBe(new Date(now).toISOString());
81
79
  });
82
80
 
83
- test('the install.machine event appVersion prop equals the appVersion stamped on a sibling error record', async () => {
81
+ test('the install report appVersion equals the appVersion stamped on a sibling error record', async () => {
84
82
  const now = Date.now();
85
83
  const { deps } = fakeDeps({ settings: { lastMachineReportVersion: '', lastMachineReportAt: null } });
86
84
  const seen = [];
87
- deps.telemetryClient.track = async (name, props) => { seen.push({ name, props }); return { accepted: true }; };
85
+ deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
88
86
  // Sibling error record stamped by the SAME process's telemetryClient with the same appVersion.
89
87
  const siblingErrorStampAppVersion = '1.0.0';
90
88
 
91
89
  await bootSequence({ now, appVersion: '1.0.0', deps });
92
90
 
93
- const [install] = seen.filter((e) => e.name === 'install.machine');
94
- expect(install.props.appVersion).toBe(siblingErrorStampAppVersion);
91
+ const [install] = seen;
92
+ expect(install.appVersion).toBe(siblingErrorStampAppVersion);
95
93
  });
96
94
 
97
95
  test('on app ready, flush("boot") always runs', async () => {
@@ -13,13 +13,16 @@ const path = require('node:path');
13
13
 
14
14
  const tmpDirs = [];
15
15
  let originalHome;
16
+ let originalSmTelemetrySpool;
16
17
 
17
18
  beforeEach(() => {
18
19
  originalHome = process.env.HOME;
20
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
19
21
  });
20
22
 
21
23
  afterEach(async () => {
22
24
  if (originalHome !== undefined) process.env.HOME = originalHome; else delete process.env.HOME;
25
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
23
26
  vi.unstubAllGlobals();
24
27
  while (tmpDirs.length) {
25
28
  const d = tmpDirs.pop();
@@ -35,6 +38,9 @@ async function mkHome() {
35
38
 
36
39
  function freshModules(home) {
37
40
  process.env.HOME = home;
41
+ // Explicit opt-in: overrides the test-environment no-op guard so this
42
+ // suite's real telemetryClient calls actually persist into an isolated dir.
43
+ process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
38
44
  for (const p of ['../telemetryConsent.cjs', '../telemetryClient.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs', '../../config.cjs']) {
39
45
  const resolved = require.resolve(p);
40
46
  delete require.cache[resolved];
@@ -18,9 +18,11 @@ const counters = require('../telemetryCounters.cjs');
18
18
 
19
19
  const tmpDirs = [];
20
20
  let originalHome;
21
+ let originalSmTelemetrySpool;
21
22
 
22
23
  afterEach(async () => {
23
24
  if (originalHome !== undefined) process.env.HOME = originalHome;
25
+ if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
24
26
  while (tmpDirs.length) {
25
27
  const d = tmpDirs.pop();
26
28
  await fsp.rm(d, { recursive: true, force: true });
@@ -29,9 +31,13 @@ afterEach(async () => {
29
31
 
30
32
  async function mkHome() {
31
33
  originalHome = process.env.HOME;
34
+ originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
32
35
  const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-telemetry-counters-home-'));
33
36
  tmpDirs.push(dir);
34
37
  process.env.HOME = dir;
38
+ // Explicit opt-in: overrides the test-environment no-op guard so this
39
+ // suite's real telemetryClient calls actually persist into an isolated dir.
40
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
35
41
  return dir;
36
42
  }
37
43
 
@@ -220,6 +220,11 @@ function shapeMachine(data) {
220
220
  config: data.config || {},
221
221
  scheduledFor: data.scheduledFor ?? null,
222
222
  lastRunAt: data.lastRunAt ?? null,
223
+ // Distinct from lastRunAt (stamped only when tickQueue actually launches a
224
+ // job): this is stamped every time tickQueue gets far enough to evaluate
225
+ // the queue at all, whether or not that evaluation ends in a launch. See
226
+ // classifyQueueStarvation's header for why the two must never merge.
227
+ lastDispatchAttemptAt: data.lastDispatchAttemptAt ?? null,
223
228
  paused: data.paused ?? null,
224
229
  // Launch circuit breaker (lib/launchFailure.cjs): per-persona blocks and
225
230
  // the degraded-mode env a persona is currently launching with. Machine
@@ -440,7 +445,7 @@ function shapeJobs(raw, file) {
440
445
  * consulted so writeSplit can persist "this project now has zero jobs".
441
446
  */
442
447
  function readMergedSync(opts) {
443
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
448
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
444
449
  const sourceCwds = [];
445
450
  const machine = loadMachineStateSync();
446
451
  if (machine.shaped) {
@@ -473,7 +478,7 @@ function readMergedSync(opts) {
473
478
 
474
479
  /** Async twin of readMergedSync for IPC hot paths. */
475
480
  async function readMerged(opts) {
476
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
481
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
477
482
  const sourceCwds = [];
478
483
  const machine = await loadMachineState();
479
484
  if (machine.shaped) {
@@ -525,6 +530,7 @@ async function writeSplit(state, defaultCwd) {
525
530
  config: state.config,
526
531
  scheduledFor: state.scheduledFor ?? null,
527
532
  lastRunAt: state.lastRunAt ?? null,
533
+ lastDispatchAttemptAt: state.lastDispatchAttemptAt ?? null,
528
534
  paused: state.paused ?? null,
529
535
  launchBlocks: state.launchBlocks ?? {},
530
536
  launchMitigations: state.launchMitigations ?? {},
@@ -36,10 +36,15 @@ function resolveDeps(deps = {}) {
36
36
  * 2. flush('version-change') — only when this install's persisted
37
37
  * lastMachineReportVersion differs from the running appVersion (the same
38
38
  * signal that also gates the machine-profile heartbeat below).
39
- * 3. install.machine — sent once as track('install.machine', profile) when
40
- * telemetrySettings.isMachineReportDue() is true (a version bump, OR the
41
- * 30-day liveness heartbeat), then lastMachineReportAt/Version are
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
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.
43
48
  * 4. app.launch — one counter event per boot, via telemetryCounters so the
44
49
  * shape lives in exactly one place across every counter this PRD adds.
45
50
  */
@@ -57,7 +62,7 @@ async function bootSequence({ now = Date.now(), appVersion, installChannel, deps
57
62
 
58
63
  if (telemetrySettings.isMachineReportDue(settings, { now, appVersion })) {
59
64
  const profile = await buildMachineProfile();
60
- await telemetryClient.track('install.machine', profile);
65
+ await telemetryClient.reportInstall(profile);
61
66
  await telemetrySettings.save({
62
67
  ...settings,
63
68
  lastMachineReportAt: new Date(now).toISOString(),
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * telemetryClient — the single egress module that ships telemetry to
3
- * bilko.run's beacon endpoints (POST /api/telemetry/event, /log, /error).
3
+ * bilko.run's beacon endpoints (POST /api/telemetry/event, /log, /error,
4
+ * /install). The `install` channel is a durable upsert keyed on install_id
5
+ * (app_installs), not an append like the other three — its wire body is a
6
+ * single flat snake_case object, never `{batch:[...]}` — so it is routed
7
+ * through the same queue/dedup/backoff machinery but sent one record at a
8
+ * time via sendSingle() rather than sendBatch().
4
9
  *
5
10
  * Records accumulate durably in ~/.config/session-manager/telemetry-queue.jsonl
6
11
  * the instant they're accepted, and are only ever sent by flush(reason) — on
@@ -88,12 +93,37 @@ const S = {
88
93
  lastFlushReason: null,
89
94
  };
90
95
 
96
+ /**
97
+ * SM_TELEMETRY_SPOOL, when set, overrides the spool directory in place of
98
+ * `~/.config/session-manager` — the explicit opt-in a test that genuinely
99
+ * needs to exercise real queue/sent-file I/O uses to prove it isn't about to
100
+ * write into a real user's spool (see isTestEnvironment() below).
101
+ */
102
+ function spoolDir() {
103
+ return process.env.SM_TELEMETRY_SPOOL || path.join(os.homedir(), '.config', 'session-manager');
104
+ }
105
+
91
106
  function queuePath() {
92
- return path.join(os.homedir(), '.config', 'session-manager', 'telemetry-queue.jsonl');
107
+ return path.join(spoolDir(), 'telemetry-queue.jsonl');
93
108
  }
94
109
 
95
110
  function sentPath() {
96
- return path.join(os.homedir(), '.config', 'session-manager', 'telemetry-sent.json');
111
+ return path.join(spoolDir(), 'telemetry-sent.json');
112
+ }
113
+
114
+ /**
115
+ * True when running under a test runner (VITEST, NODE_ENV=test) with no
116
+ * explicit SM_TELEMETRY_SPOOL override. Gates appendRecord() below so a test
117
+ * that merely exercises epicMint/scheduler/etc. code paths — without itself
118
+ * setting up an isolated spool — can never accumulate fake records into a
119
+ * real user's telemetry-queue.jsonl. A test that deliberately wants to
120
+ * exercise real persistence (telemetryClient's own suite) sets
121
+ * SM_TELEMETRY_SPOOL to an isolated directory, which counts as an explicit
122
+ * opt-in and disables this guard.
123
+ */
124
+ function isTestEnvironment() {
125
+ if (process.env.SM_TELEMETRY_SPOOL) return false;
126
+ return !!process.env.VITEST || process.env.NODE_ENV === 'test';
97
127
  }
98
128
 
99
129
  function getBeaconToken() {
@@ -368,6 +398,9 @@ function pushRecent(rec) {
368
398
  }
369
399
 
370
400
  async function appendRecord(channel, wire, recordId) {
401
+ if (isTestEnvironment()) {
402
+ return { accepted: false, reason: 'test-environment', recordId };
403
+ }
371
404
  if (S.queueIds.has(recordId) || S.sentIds.has(recordId)) {
372
405
  S.dedupedAppends += 1;
373
406
  return { accepted: false, reason: 'duplicate', recordId };
@@ -475,6 +508,47 @@ async function reportError(opts) {
475
508
  }
476
509
  }
477
510
 
511
+ function buildInstallWire(profile, installId) {
512
+ const p = safeObj(profile);
513
+ return {
514
+ install_id: safeStr(installId, 80),
515
+ app: 'session-manager',
516
+ app_version: safeStr(p.appVersion, 20),
517
+ platform: safeStr(p.platform, 20),
518
+ os_release: safeStr(p.osRelease, 80),
519
+ arch: safeStr(p.arch, 20),
520
+ cpu_count: typeof p.cpuCount === 'number' ? p.cpuCount : 0,
521
+ total_mem_mb: typeof p.totalMemMb === 'number' ? p.totalMemMb : 0,
522
+ node_version: safeStr(p.nodeVersion, 20),
523
+ electron_version: safeStr(p.electronVersion, 20),
524
+ install_channel: safeStr(p.installChannel, 20),
525
+ locale: safeStr(p.locale, 20),
526
+ // machineProfile deliberately never carries an IANA timezone name — only
527
+ // the numeric UTC offset (see machineProfile.cjs) — so that is what maps
528
+ // onto the server's `timezone` column.
529
+ timezone: safeStr(p.timezoneOffsetMinutes, 20),
530
+ };
531
+ }
532
+
533
+ /**
534
+ * Reports the once-per-cadence install/liveness record. Unlike track/logLine/
535
+ * reportError this isn't arbitrary user content, so it skips redactDeep() —
536
+ * every field already comes from machineProfile's own anonymity contract —
537
+ * and it never merges an attribution block onto the body, since the server's
538
+ * app_installs upsert doesn't read a recordId.
539
+ */
540
+ async function reportInstall(profile) {
541
+ try {
542
+ await ensureInit();
543
+ if (!telemetrySettings.isEnabled(S.settings)) return { accepted: false, reason: 'disabled' };
544
+ const recordId = crypto.randomUUID();
545
+ const wire = buildInstallWire(profile, S.settings.installId);
546
+ return await appendRecord('install', wire, recordId);
547
+ } catch {
548
+ return { accepted: false, reason: 'error' };
549
+ }
550
+ }
551
+
478
552
  // ─── egress ────────────────────────────────────────────────────────────
479
553
 
480
554
  async function sendBatch(channel, wireBatch) {
@@ -496,6 +570,26 @@ async function sendBatch(channel, wireBatch) {
496
570
  }
497
571
  }
498
572
 
573
+ /** Like sendBatch(), but posts `wire` as a flat body — the install route takes one upsert, never a `{batch:[...]}` envelope. */
574
+ async function sendSingle(channel, wire) {
575
+ const fetchFn = S.fetchImpl || (typeof fetch === 'function' ? fetch : null);
576
+ if (typeof fetchFn !== 'function') return { ok: false, status: 0 };
577
+ const base = telemetrySettings.resolveEndpoint(S.settings);
578
+ const url = `${base}/api/telemetry/${channel}`;
579
+ const headers = {
580
+ 'Content-Type': 'application/json',
581
+ 'X-SM-Beacon': `session-manager/${S.profile.appVersion}`,
582
+ 'X-SM-Beacon-Key': getBeaconToken(),
583
+ };
584
+ try {
585
+ const res = await fetchFn(url, { method: 'POST', headers, body: JSON.stringify(wire) });
586
+ if (res && res.ok) return { ok: true };
587
+ return { ok: false, status: res ? res.status : 0 };
588
+ } catch {
589
+ return { ok: false, status: 0 };
590
+ }
591
+ }
592
+
499
593
  function describeFailureStatus(status) {
500
594
  return status === 0 ? 'network error' : `HTTP ${status}`;
501
595
  }
@@ -537,15 +631,34 @@ async function flushImpl(reason) {
537
631
  if (S.disabledForProcess) return result;
538
632
  if (Date.now() < S.backoffUntil) return result;
539
633
 
540
- const byChannel = { event: [], log: [], error: [] };
634
+ const byChannel = { event: [], log: [], error: [], install: [] };
541
635
  for (const r of S.queue) {
542
636
  if (byChannel[r.channel]) byChannel[r.channel].push(r);
543
637
  }
544
638
 
545
639
  let sawFailure = false;
546
- for (const channel of ['event', 'log', 'error']) {
640
+ for (const channel of ['event', 'log', 'error', 'install']) {
547
641
  if (sawFailure) break;
548
642
  const records = byChannel[channel];
643
+ if (channel === 'install') {
644
+ for (const r of records) {
645
+ const res = await sendSingle('install', r.wire);
646
+ if (res.ok) {
647
+ S.consecutiveFailures = 0;
648
+ S.backoffUntil = 0;
649
+ S.lastError = null;
650
+ removeFromQueue(r.recordId);
651
+ addToSent(r.recordId);
652
+ result.sent.push(r.recordId);
653
+ } else {
654
+ result.failed.push(r.recordId);
655
+ applyFailureBackoff(res.status);
656
+ sawFailure = true;
657
+ break;
658
+ }
659
+ }
660
+ continue;
661
+ }
549
662
  for (let i = 0; i < records.length; i += MAX_BATCH) {
550
663
  const batch = records.slice(i, i + MAX_BATCH);
551
664
  const res = await sendBatch(channel, batch.map((r) => r.wire));
@@ -640,6 +753,7 @@ module.exports = {
640
753
  track,
641
754
  logLine,
642
755
  reportError,
756
+ reportInstall,
643
757
  flush,
644
758
  shutdown,
645
759
  status,