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.
- package/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-Bkv-HcP1.js} +1 -1
- package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-DRH-Ty20.js} +1 -1
- package/dist/assets/{History-D3Qw2BWn.js → History-CfRhT1Im.js} +2 -2
- package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-wEmh_U6c.js} +1 -1
- package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-D_t7Rbi7.js} +1 -1
- package/dist/assets/{Library-DAMyrUtY.js → Library-CpArQ-OJ.js} +1 -1
- package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-pjaKYs84.js} +1 -1
- package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-Xc141kjj.js} +1 -1
- package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-ftqaV3kn.js} +1 -1
- package/dist/assets/{Memory-CgYJ_QqD.js → Memory-ChMWkNd0.js} +6 -6
- package/dist/assets/{Panel-dTNWC59Q.js → Panel-D9Kr40Ai.js} +1 -1
- package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-DKoNVgzj.js} +1 -1
- package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-BtChISho.js} +2 -2
- package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
- package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-I0_dWNTX.js} +1 -1
- package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-CbES7MC8.js} +7 -7
- package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-5GTEveb2.js} +1 -1
- package/dist/assets/Settings-BX3FElXk.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
- package/dist/assets/{Skills-DHb4INtF.js → Skills-DJB6-bBM.js} +2 -2
- package/dist/assets/SystemPrompt-BiDDrJUA.js +1 -0
- package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-_Wrevtop.js} +1 -1
- package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-OWWXdLRy.js} +1 -1
- package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-B122N0HL.js} +1 -1
- package/dist/assets/{index-CYhdtisq.css → index-CDo9xBR9.css} +1 -1
- package/dist/assets/{index-CzAxC432.js → index-DhvuQL4C.js} +316 -314
- package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-sGoCTd7J.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/scripts/hooks/guard-destructive-git.cjs +514 -0
- package/scripts/hooks/guard-inline-implementation.cjs +219 -0
- package/scripts/hooks/guard-prd-writes.cjs +200 -0
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
- package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +149 -0
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
- package/src/main/__tests__/telemetryClient.test.cjs +242 -1
- package/src/main/__tests__/telemetryContract.test.cjs +930 -0
- package/src/main/crashDiagnostics.cjs +29 -1
- package/src/main/health.cjs +197 -3
- package/src/main/index.cjs +65 -6
- package/src/main/ipcSchemas.cjs +19 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +103 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +626 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +123 -0
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +136 -0
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +95 -0
- package/src/main/lib/crashTelemetry.cjs +37 -0
- package/src/main/lib/delegationReadiness.cjs +119 -1
- package/src/main/lib/epicMint.cjs +2 -0
- package/src/main/lib/gitWorktree.cjs +427 -17
- package/src/main/lib/jobWorktree.cjs +2 -1
- package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
- package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
- package/src/main/lib/opsErrorLog.cjs +78 -25
- package/src/main/lib/queueStore.cjs +241 -22
- package/src/main/lib/reaperHelpers.cjs +23 -1
- package/src/main/lib/scheduleJobSchema.cjs +7 -0
- package/src/main/lib/scheduleJobTransitions.cjs +12 -0
- package/src/main/lib/telemetryBacklog.cjs +601 -0
- package/src/main/lib/telemetryBoot.cjs +76 -0
- package/src/main/lib/telemetryClient.cjs +150 -5
- package/src/main/lib/telemetryConsent.cjs +34 -0
- package/src/main/lib/telemetryCounters.cjs +45 -0
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +2 -0
- package/src/main/scheduler.cjs +481 -33
- package/src/preload/api.d.ts +84 -4
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-DXVgKLUx.js +0 -3
- 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 };
|