ai-obs-cli 0.0.0-stage → 0.5.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 (155) hide show
  1. package/README.md +666 -2
  2. package/bin/ai-obs.js +8 -0
  3. package/dist/adapters/claude-code/analyze.js +189 -0
  4. package/dist/adapters/claude-code/analyze.js.map +1 -0
  5. package/dist/adapters/claude-code/detect.js +60 -0
  6. package/dist/adapters/claude-code/detect.js.map +1 -0
  7. package/dist/adapters/claude-code/handlers.js +183 -0
  8. package/dist/adapters/claude-code/handlers.js.map +1 -0
  9. package/dist/adapters/claude-code/index.js +55 -0
  10. package/dist/adapters/claude-code/index.js.map +1 -0
  11. package/dist/adapters/claude-code/run-links.js +53 -0
  12. package/dist/adapters/claude-code/run-links.js.map +1 -0
  13. package/dist/adapters/claude-code/session.js +427 -0
  14. package/dist/adapters/claude-code/session.js.map +1 -0
  15. package/dist/adapters/claude-code/settings.js +161 -0
  16. package/dist/adapters/claude-code/settings.js.map +1 -0
  17. package/dist/adapters/claude-code/state.js +66 -0
  18. package/dist/adapters/claude-code/state.js.map +1 -0
  19. package/dist/adapters/claude-code/subagents.js +246 -0
  20. package/dist/adapters/claude-code/subagents.js.map +1 -0
  21. package/dist/adapters/claude-code/transcript.js +309 -0
  22. package/dist/adapters/claude-code/transcript.js.map +1 -0
  23. package/dist/adapters/codex/accumulator.js +135 -0
  24. package/dist/adapters/codex/accumulator.js.map +1 -0
  25. package/dist/adapters/codex/detect.js +93 -0
  26. package/dist/adapters/codex/detect.js.map +1 -0
  27. package/dist/adapters/codex/hooks-config.js +158 -0
  28. package/dist/adapters/codex/hooks-config.js.map +1 -0
  29. package/dist/adapters/codex/index.js +270 -0
  30. package/dist/adapters/codex/index.js.map +1 -0
  31. package/dist/adapters/codex/locate.js +72 -0
  32. package/dist/adapters/codex/locate.js.map +1 -0
  33. package/dist/adapters/codex/records.js +194 -0
  34. package/dist/adapters/codex/records.js.map +1 -0
  35. package/dist/adapters/codex/rollout.js +72 -0
  36. package/dist/adapters/codex/rollout.js.map +1 -0
  37. package/dist/adapters/codex/session.js +212 -0
  38. package/dist/adapters/codex/session.js.map +1 -0
  39. package/dist/adapters/codex/subagents.js +139 -0
  40. package/dist/adapters/codex/subagents.js.map +1 -0
  41. package/dist/adapters/codex/summarize.js +84 -0
  42. package/dist/adapters/codex/summarize.js.map +1 -0
  43. package/dist/adapters/codex/tools.js +145 -0
  44. package/dist/adapters/codex/tools.js.map +1 -0
  45. package/dist/adapters/hook-command.js +94 -0
  46. package/dist/adapters/hook-command.js.map +1 -0
  47. package/dist/adapters/index.js +12 -0
  48. package/dist/adapters/index.js.map +1 -0
  49. package/dist/adapters/types.js +2 -0
  50. package/dist/adapters/types.js.map +1 -0
  51. package/dist/analysis/corrections.js +79 -0
  52. package/dist/analysis/corrections.js.map +1 -0
  53. package/dist/analysis/digest.js +16 -0
  54. package/dist/analysis/digest.js.map +1 -0
  55. package/dist/analysis/outcome.js +76 -0
  56. package/dist/analysis/outcome.js.map +1 -0
  57. package/dist/analysis/phases.js +203 -0
  58. package/dist/analysis/phases.js.map +1 -0
  59. package/dist/analysis/summaries.js +350 -0
  60. package/dist/analysis/summaries.js.map +1 -0
  61. package/dist/analysis/tokens.js +67 -0
  62. package/dist/analysis/tokens.js.map +1 -0
  63. package/dist/analysis/usage.js +229 -0
  64. package/dist/analysis/usage.js.map +1 -0
  65. package/dist/cc-cache.js +81 -0
  66. package/dist/cc-cache.js.map +1 -0
  67. package/dist/cli.js +128 -0
  68. package/dist/cli.js.map +1 -0
  69. package/dist/commands/common.js +235 -0
  70. package/dist/commands/common.js.map +1 -0
  71. package/dist/commands/doctor.js +193 -0
  72. package/dist/commands/doctor.js.map +1 -0
  73. package/dist/commands/flush.js +131 -0
  74. package/dist/commands/flush.js.map +1 -0
  75. package/dist/commands/init.js +119 -0
  76. package/dist/commands/init.js.map +1 -0
  77. package/dist/commands/link.js +171 -0
  78. package/dist/commands/link.js.map +1 -0
  79. package/dist/commands/live.js +130 -0
  80. package/dist/commands/live.js.map +1 -0
  81. package/dist/commands/login.js +397 -0
  82. package/dist/commands/login.js.map +1 -0
  83. package/dist/commands/status.js +155 -0
  84. package/dist/commands/status.js.map +1 -0
  85. package/dist/commands/token.js +92 -0
  86. package/dist/commands/token.js.map +1 -0
  87. package/dist/commands/uninstall.js +56 -0
  88. package/dist/commands/uninstall.js.map +1 -0
  89. package/dist/config.js +305 -0
  90. package/dist/config.js.map +1 -0
  91. package/dist/context.js +109 -0
  92. package/dist/context.js.map +1 -0
  93. package/dist/delivery.js +151 -0
  94. package/dist/delivery.js.map +1 -0
  95. package/dist/dotfile.js +187 -0
  96. package/dist/dotfile.js.map +1 -0
  97. package/dist/events.js +259 -0
  98. package/dist/events.js.map +1 -0
  99. package/dist/fsutil.js +50 -0
  100. package/dist/fsutil.js.map +1 -0
  101. package/dist/git.js +139 -0
  102. package/dist/git.js.map +1 -0
  103. package/dist/hook.js +147 -0
  104. package/dist/hook.js.map +1 -0
  105. package/dist/http.js +35 -0
  106. package/dist/http.js.map +1 -0
  107. package/dist/jobs.js +53 -0
  108. package/dist/jobs.js.map +1 -0
  109. package/dist/live/claude-code.js +226 -0
  110. package/dist/live/claude-code.js.map +1 -0
  111. package/dist/live/codex.js +91 -0
  112. package/dist/live/codex.js.map +1 -0
  113. package/dist/live/monitor.js +229 -0
  114. package/dist/live/monitor.js.map +1 -0
  115. package/dist/live/render.js +92 -0
  116. package/dist/live/render.js.map +1 -0
  117. package/dist/lock.js +93 -0
  118. package/dist/lock.js.map +1 -0
  119. package/dist/log.js +36 -0
  120. package/dist/log.js.map +1 -0
  121. package/dist/migrate.js +109 -0
  122. package/dist/migrate.js.map +1 -0
  123. package/dist/nudge.js +231 -0
  124. package/dist/nudge.js.map +1 -0
  125. package/dist/paths.js +73 -0
  126. package/dist/paths.js.map +1 -0
  127. package/dist/pk-cache.js +110 -0
  128. package/dist/pk-cache.js.map +1 -0
  129. package/dist/prices.js +45 -0
  130. package/dist/prices.js.map +1 -0
  131. package/dist/project-key.js +144 -0
  132. package/dist/project-key.js.map +1 -0
  133. package/dist/protocol-types.js +30 -0
  134. package/dist/protocol-types.js.map +1 -0
  135. package/dist/queue.js +649 -0
  136. package/dist/queue.js.map +1 -0
  137. package/dist/routing.js +122 -0
  138. package/dist/routing.js.map +1 -0
  139. package/dist/runtime.js +54 -0
  140. package/dist/runtime.js.map +1 -0
  141. package/dist/sender.js +559 -0
  142. package/dist/sender.js.map +1 -0
  143. package/dist/state.js +59 -0
  144. package/dist/state.js.map +1 -0
  145. package/dist/ulid.js +73 -0
  146. package/dist/ulid.js.map +1 -0
  147. package/dist/version.js +26 -0
  148. package/dist/version.js.map +1 -0
  149. package/dist/warnings.js +71 -0
  150. package/dist/warnings.js.map +1 -0
  151. package/dist/worker-main.js +14 -0
  152. package/dist/worker-main.js.map +1 -0
  153. package/dist/worker.js +295 -0
  154. package/dist/worker.js.map +1 -0
  155. package/package.json +25 -4
package/dist/queue.js ADDED
@@ -0,0 +1,649 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { ensureDir, errorMessage, readJsonFile, writeFileAtomic, writeJsonAtomic } from './fsutil.js';
5
+ import { nullLogger } from './log.js';
6
+ import { isPathKey } from './project-key.js';
7
+ import { keyDirName, legacyTarget, resolveStream, sendableNow, streamDirName, unresolvedDirName, upgradeStreamKey, } from './routing.js';
8
+ import { TEAM_SLUG_RE } from './protocol-types.js';
9
+ import { MAX_BATCH_BYTES, MAX_BATCH_EVENTS } from './protocol-types.js';
10
+ import { decodeTime, ulid } from './ulid.js';
11
+ /** Room left in a batch for the envelope (schema, collector, machine, identity). */
12
+ const ENVELOPE_RESERVE = 4096;
13
+ const SEGMENT_RE = /^[0-9A-HJKMNP-TV-Z]{26}\.ndjson$/;
14
+ /**
15
+ * Append-only NDJSON event queue under ~/.ai-obs/queue (ADR-0011). Segments are named by ULID, so
16
+ * name order = age order. Callers serialize mutation through the worker lock; even so, ack() re-reads
17
+ * a partially sent segment and keeps any lines appended since readBatch().
18
+ */
19
+ export class EventQueue {
20
+ dir;
21
+ maxBytes;
22
+ maxAgeMs;
23
+ segmentBytes;
24
+ log;
25
+ now;
26
+ autoCap;
27
+ deadDir;
28
+ constructor(dir, opts = {}) {
29
+ this.dir = dir;
30
+ this.maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
31
+ this.maxAgeMs = opts.maxAgeMs ?? 30 * 24 * 3600_000;
32
+ this.segmentBytes = opts.segmentBytes ?? 1024 * 1024;
33
+ this.log = opts.log ?? nullLogger;
34
+ this.now = opts.now ?? Date.now;
35
+ this.autoCap = opts.autoCap ?? true;
36
+ this.deadDir = opts.deadDir ?? join(dir, 'dead');
37
+ }
38
+ /** Appends events (one write per call) and enforces the cap (unless `autoCap` is false). */
39
+ enqueue(events) {
40
+ if (!events.length)
41
+ return;
42
+ ensureDir(this.dir);
43
+ const data = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
44
+ const segments = this.segments();
45
+ const newest = segments.at(-1);
46
+ const target = newest && newest.size + data.length <= this.segmentBytes ? newest.file : `${ulid(this.now())}.ndjson`;
47
+ appendFileSync(join(this.dir, target), data, { mode: 0o600 });
48
+ if (this.autoCap)
49
+ this.enforceCap();
50
+ }
51
+ /** Oldest-first batch of at most `maxEvents` events / `maxBytes` bytes, or null when empty. */
52
+ readBatch(limits = {}) {
53
+ const maxEvents = limits.maxEvents ?? MAX_BATCH_EVENTS;
54
+ const maxBytes = (limits.maxBytes ?? MAX_BATCH_BYTES) - ENVELOPE_RESERVE;
55
+ const events = [];
56
+ const cursor = [];
57
+ let bytes = 2;
58
+ for (const seg of this.segments()) {
59
+ const lines = this.readLines(seg.file);
60
+ let consumed = 0;
61
+ let full = false;
62
+ for (const line of lines) {
63
+ if (events.length >= maxEvents) {
64
+ full = true;
65
+ break;
66
+ }
67
+ const size = Buffer.byteLength(line) + 1;
68
+ if (!line.trim()) {
69
+ consumed++;
70
+ continue;
71
+ }
72
+ let event;
73
+ try {
74
+ event = JSON.parse(line);
75
+ }
76
+ catch {
77
+ this.log.warn('queue: dropping corrupt line', { segment: seg.file });
78
+ consumed++;
79
+ continue;
80
+ }
81
+ if (size > maxBytes) {
82
+ this.log.warn('queue: dropping event larger than a batch', { event_id: event.event_id, bytes: size });
83
+ consumed++;
84
+ continue;
85
+ }
86
+ if (bytes + size > maxBytes) {
87
+ full = true;
88
+ break;
89
+ }
90
+ events.push(event);
91
+ bytes += size;
92
+ consumed++;
93
+ }
94
+ if (consumed)
95
+ cursor.push({ file: seg.file, consumedLines: consumed });
96
+ if (full)
97
+ break;
98
+ }
99
+ if (!events.length && !cursor.length)
100
+ return null;
101
+ return { events, bytes, cursor };
102
+ }
103
+ /** Removes a delivered batch: whole segments are deleted, a partial one is rewritten atomically. */
104
+ ack(batch) {
105
+ for (const c of batch.cursor) {
106
+ const path = join(this.dir, c.file);
107
+ const lines = this.readLines(c.file);
108
+ const rest = lines.slice(c.consumedLines).filter((l) => l.trim());
109
+ if (!rest.length)
110
+ rmSync(path, { force: true });
111
+ else
112
+ writeFileAtomic(path, rest.join('\n') + '\n');
113
+ }
114
+ }
115
+ /** Moves a batch the server refused as a whole into dead/ (capped at 5 MB) and acks it. `meta` is added to the log line. */
116
+ deadLetter(batch, reason, meta = {}) {
117
+ const deadDir = this.deadDir;
118
+ try {
119
+ ensureDir(deadDir);
120
+ const body = batch.events.map((e) => JSON.stringify(e)).join('\n') + '\n';
121
+ writeFileAtomic(join(deadDir, `${ulid(this.now())}.ndjson`), body);
122
+ const dead = this.segments(deadDir);
123
+ let total = dead.reduce((s, d) => s + d.size, 0);
124
+ for (const d of dead) {
125
+ if (total <= 5 * 1024 * 1024)
126
+ break;
127
+ rmSync(join(deadDir, d.file), { force: true });
128
+ total -= d.size;
129
+ }
130
+ }
131
+ catch (err) {
132
+ this.log.error('queue: dead-letter write failed', { error: String(err) });
133
+ }
134
+ this.log.warn('queue: batch dead-lettered', { ...meta, events: batch.events.length, reason: reason.slice(0, 200) });
135
+ this.ack(batch);
136
+ }
137
+ /** Drops segments older than maxAge, then oldest segments until under maxBytes. Logged. */
138
+ enforceCap() {
139
+ const segments = this.segments();
140
+ let total = segments.reduce((s, x) => s + x.size, 0);
141
+ let droppedFiles = 0;
142
+ let droppedBytes = 0;
143
+ const cutoff = this.now() - this.maxAgeMs;
144
+ for (const seg of segments) {
145
+ const tooOld = seg.createdAt < cutoff;
146
+ // Never drop the newest segment for size: it holds what was just written.
147
+ const tooBig = total > this.maxBytes && seg !== segments.at(-1);
148
+ if (!tooOld && !tooBig)
149
+ continue;
150
+ rmSync(join(this.dir, seg.file), { force: true });
151
+ total -= seg.size;
152
+ droppedFiles++;
153
+ droppedBytes += seg.size;
154
+ }
155
+ if (droppedFiles)
156
+ this.log.warn('queue: cap reached, dropped oldest segments', { droppedFiles, droppedBytes });
157
+ return { droppedFiles, droppedBytes };
158
+ }
159
+ stats() {
160
+ const segments = this.segments();
161
+ let events = 0;
162
+ for (const s of segments)
163
+ events += this.readLines(s.file).filter((l) => l.trim()).length;
164
+ return {
165
+ files: segments.length,
166
+ bytes: segments.reduce((s, x) => s + x.size, 0),
167
+ events,
168
+ oldestAt: segments[0]?.createdAt ?? null,
169
+ };
170
+ }
171
+ /** Segment files of this queue, oldest first (name order = age order). */
172
+ segments(dir = this.dir) {
173
+ let names;
174
+ try {
175
+ names = readdirSync(dir);
176
+ }
177
+ catch {
178
+ return [];
179
+ }
180
+ return names
181
+ .filter((n) => SEGMENT_RE.test(n))
182
+ .sort()
183
+ .flatMap((file) => {
184
+ try {
185
+ const st = statSync(join(dir, file));
186
+ return [{ file, size: st.size, createdAt: decodeTime(file) }];
187
+ }
188
+ catch {
189
+ return [];
190
+ }
191
+ });
192
+ }
193
+ /** Non-empty lines of one segment (what a cap drop loses). */
194
+ countEvents(file) {
195
+ return this.readLines(file).filter((l) => l.trim()).length;
196
+ }
197
+ readLines(file) {
198
+ try {
199
+ const text = readFileSync(join(this.dir, file), 'utf8');
200
+ const lines = text.split('\n');
201
+ if (lines.at(-1) === '')
202
+ lines.pop();
203
+ return lines;
204
+ }
205
+ catch {
206
+ return [];
207
+ }
208
+ }
209
+ }
210
+ // ---------------------------------------------------------------- streams (brief appendix E)
211
+ export { streamDirName, unresolvedDirName };
212
+ const STREAM_FILE = 'stream.json';
213
+ /** A stream not observed for longer than this starts a new sendable period (observe, C13). */
214
+ export const SEEN_GAP_MS = 2 * 24 * 3600_000;
215
+ /** `seen_at` of a stream whose state did not change is rewritten at most this often. */
216
+ const SEEN_REFRESH_MS = 3600_000;
217
+ /** A `.tmp-*` stream directory older than this was left by a crash and is removed by prune(). */
218
+ const TMP_STALE_MS = 10 * 60_000;
219
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
220
+ const nonEmpty = (v) => typeof v === 'string' && v.length > 0;
221
+ const isTeam = (v) => v === '' || (typeof v === 'string' && TEAM_SLUG_RE.test(v));
222
+ /** The routing identity of a stream directory. */
223
+ export function identityOfRef(ref) {
224
+ if (ref.kind === 'project' && ref.pk !== undefined && ref.endpoint !== undefined && ref.team !== undefined)
225
+ return { kind: 'project', pk: ref.pk, endpoint: ref.endpoint, team: ref.team };
226
+ if (ref.kind === 'unresolved' && ref.cwd !== undefined && ref.endpoint !== undefined && ref.team !== undefined)
227
+ return { kind: 'unresolved', cwd: ref.cwd, endpoint: ref.endpoint, team: ref.team };
228
+ if (ref.kind === 'pre-upgrade')
229
+ return { kind: 'pre-upgrade' };
230
+ if (ref.kind === 'legacy')
231
+ return { kind: 'legacy' };
232
+ return { kind: 'unknown' };
233
+ }
234
+ /** A project key as it may appear in the log: never a local path. */
235
+ export function loggableKey(pk) {
236
+ if (pk === undefined)
237
+ return undefined;
238
+ return isPathKey(pk) ? '(local path)' : pk;
239
+ }
240
+ /**
241
+ * The queue as streams keyed by (project key, server endpoint, team) (brief appendix E). Each stream is an
242
+ * EventQueue directory with a `stream.json`; root segments are the frozen pre-upgrade stream, which no API
243
+ * writes. Mutation is serialized by the worker lock, like EventQueue. The store enforces the caps across
244
+ * all streams: the 30-day age cap only for streams that can be sent now (a registered server whose token is
245
+ * neither refused nor expired), counted from when they became sendable; the size cap for all (C13).
246
+ */
247
+ export class QueueStore {
248
+ root;
249
+ projectsDir;
250
+ unresolvedDir;
251
+ deadDir;
252
+ opts;
253
+ log;
254
+ now;
255
+ maxBytes;
256
+ maxAgeMs;
257
+ constructor(root, opts) {
258
+ this.root = root;
259
+ this.opts = opts;
260
+ this.projectsDir = join(root, 'p');
261
+ this.unresolvedDir = join(root, 'u');
262
+ this.deadDir = join(root, 'dead');
263
+ this.log = opts.log ?? nullLogger;
264
+ this.now = opts.now ?? Date.now;
265
+ this.maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
266
+ this.maxAgeMs = opts.maxAgeMs ?? 30 * 24 * 3600_000;
267
+ }
268
+ /**
269
+ * Appends events to the key's stream (created on first use) and enforces the caps. A null key (no cwd,
270
+ * C16; nothing to record) drops the events (logged). A key stored by 0.2 – 0.4 is converted first.
271
+ */
272
+ enqueue(events, rawKey) {
273
+ if (!events.length)
274
+ return;
275
+ const key = this.current(rawKey);
276
+ if (!key) {
277
+ this.log.warn('queue: events without a stream key dropped', { count: events.length });
278
+ return;
279
+ }
280
+ let ref = this.ensure(key);
281
+ if (!ref) {
282
+ this.log.error('queue: stream directory unusable, events dropped', { count: events.length });
283
+ return;
284
+ }
285
+ try {
286
+ this.stream(ref).enqueue(events);
287
+ }
288
+ catch (err) {
289
+ if (err.code !== 'ENOENT')
290
+ throw err;
291
+ ref = this.ensure(key); // pruned between ensure and append
292
+ if (!ref)
293
+ return;
294
+ this.stream(ref).enqueue(events);
295
+ }
296
+ this.enforceCap(ref);
297
+ }
298
+ /** Non-empty streams, oldest first (by their oldest segment). */
299
+ streams() {
300
+ return this.scan(false).map((x) => x.ref);
301
+ }
302
+ /** The stream as an EventQueue (no auto cap: the store caps; refused batches go to the shared queue/dead). */
303
+ stream(ref) {
304
+ const { routing: _r, dropped: _d, ...q } = this.opts;
305
+ return new EventQueue(ref.dir, { ...q, autoCap: false, deadDir: this.deadDir });
306
+ }
307
+ /** An unresolved stream whose key is now known moves into its project stream (same server and team). */
308
+ adopt(ref, pk) {
309
+ if (ref.kind !== 'unresolved' || ref.endpoint === undefined || ref.team === undefined)
310
+ return;
311
+ const target = this.ensure({ kind: 'project', pk, endpoint: ref.endpoint, team: ref.team });
312
+ if (!target)
313
+ return;
314
+ this.moveSegments(ref.dir, target.dir);
315
+ rmSync(ref.dir, { recursive: true, force: true });
316
+ }
317
+ /**
318
+ * `ai-obs link`: the key's streams recorded while no server was chosen (endpoint '') move into the stream
319
+ * of the linked server and team. Streams already bound to a server keep it (capture-time pinning).
320
+ * Returns the number of events moved.
321
+ */
322
+ retarget(pk, to) {
323
+ if (to.endpoint === '')
324
+ return 0;
325
+ const sources = this.scan(false).filter((x) => x.ref.kind === 'project' && x.ref.pk === pk && x.ref.endpoint === '');
326
+ if (!sources.length)
327
+ return 0;
328
+ const target = this.ensure({ kind: 'project', pk, ...to });
329
+ if (!target)
330
+ return 0;
331
+ let moved = 0;
332
+ for (const { ref } of sources) {
333
+ if (ref.dir === target.dir)
334
+ continue;
335
+ moved += this.moveSegments(ref.dir, target.dir);
336
+ rmSync(ref.dir, { recursive: true, force: true });
337
+ }
338
+ if (moved)
339
+ this.log.info('queue: events of a repository without a server moved to its linked server', { events: moved, project: loggableKey(pk) });
340
+ return moved;
341
+ }
342
+ /**
343
+ * Converts the 0.2 – 0.4 stream directories (`o-` open, `t-` token-bound; under p/ and u/) into `s-`
344
+ * streams (brief appendix E): a token binding becomes its endpoint with no team, an open stream the only
345
+ * server or, with several, no server ('' → held `no-server` until `ai-obs link`). Segments keep their
346
+ * ULID names, so time order and event ids are unchanged. A directory without a readable stream.json is
347
+ * left as it is. Caller holds the worker lock.
348
+ */
349
+ migrateLegacy(config) {
350
+ const out = { streams: 0, events: 0, unassignedEvents: 0 };
351
+ for (const [parent, kind] of [
352
+ [this.projectsDir, 'project'],
353
+ [this.unresolvedDir, 'unresolved'],
354
+ ]) {
355
+ for (const name of this.dirNames(parent)) {
356
+ if (!/^[ot]-/.test(name))
357
+ continue;
358
+ const dir = join(parent, name);
359
+ const raw = readJsonFile(join(dir, STREAM_FILE));
360
+ const target = isObject(raw) ? legacyTarget(raw.bind, config) : null;
361
+ if (!isObject(raw) || !target)
362
+ continue;
363
+ let key = null;
364
+ if (kind === 'project' && nonEmpty(raw.pk))
365
+ key = { kind, pk: raw.pk, ...target };
366
+ if (kind === 'unresolved' && nonEmpty(raw.cwd))
367
+ key = { kind, cwd: raw.cwd, ...target };
368
+ if (!key)
369
+ continue;
370
+ const dest = this.ensure(key);
371
+ if (!dest)
372
+ continue;
373
+ const events = this.moveSegments(dir, dest.dir);
374
+ rmSync(dir, { recursive: true, force: true });
375
+ out.streams++;
376
+ out.events += events;
377
+ if (target.endpoint === '')
378
+ out.unassignedEvents += events;
379
+ }
380
+ }
381
+ if (out.streams)
382
+ this.log.info('queue: 0.4 streams converted', { ...out });
383
+ return out;
384
+ }
385
+ /** Whether any 0.2 – 0.4 stream directory is left. */
386
+ hasLegacy() {
387
+ return [this.projectsDir, this.unresolvedDir].some((p) => this.dirNames(p).some((n) => /^[ot]-/.test(n)));
388
+ }
389
+ /**
390
+ * Records whether a stream is sendable now (stream.json `sendable_since` / `seen_at`) and returns the
391
+ * start of its current sendable period (ms), or null while it is held. The 30-day age cap counts from
392
+ * there (C13), so data released after being held (a login, a new token, a link) gets its full 30 days
393
+ * from the release. A stream not seen for more than SEEN_GAP_MS (the collector did not run) starts a new
394
+ * period: a hold in that gap could not be seen. Never throws.
395
+ */
396
+ observe(ref, sendable) {
397
+ if (ref.kind !== 'project' && ref.kind !== 'unresolved')
398
+ return null;
399
+ const file = join(ref.dir, STREAM_FILE);
400
+ const raw = readJsonFile(file);
401
+ if (!isObject(raw))
402
+ return null;
403
+ const now = this.now();
404
+ const ms = (v) => (typeof v === 'string' ? Date.parse(v) : Number.NaN);
405
+ const since0 = ms(raw.sendable_since);
406
+ const seen = ms(raw.seen_at);
407
+ const fresh = Number.isFinite(seen) && now >= seen && now - seen <= SEEN_GAP_MS;
408
+ const since = sendable ? (Number.isFinite(since0) && fresh ? since0 : now) : null;
409
+ const changed = since === null ? Number.isFinite(since0) : since !== since0;
410
+ if (changed || !fresh || now - seen > SEEN_REFRESH_MS) {
411
+ const { sendable_since: _s, seen_at: _t, ...rest } = raw;
412
+ try {
413
+ writeJsonAtomic(file, { ...rest, ...(since !== null ? { sendable_since: new Date(since).toISOString() } : {}), seen_at: new Date(now).toISOString() }, 0o600);
414
+ }
415
+ catch {
416
+ /* the stream was removed meanwhile */
417
+ }
418
+ }
419
+ return since;
420
+ }
421
+ /** Removes stream directories without segments and stale `.tmp-*` directories. */
422
+ prune() {
423
+ for (const parent of [this.projectsDir, this.unresolvedDir]) {
424
+ for (const name of this.dirNames(parent)) {
425
+ const dir = join(parent, name);
426
+ try {
427
+ if (name.startsWith('.tmp-')) {
428
+ if (this.now() - statSync(dir).mtimeMs > TMP_STALE_MS)
429
+ rmSync(dir, { recursive: true, force: true });
430
+ continue;
431
+ }
432
+ if (!this.segmentsOf(dir).length)
433
+ rmSync(dir, { recursive: true, force: true });
434
+ }
435
+ catch {
436
+ /* vanished */
437
+ }
438
+ }
439
+ }
440
+ }
441
+ /**
442
+ * Caps across all streams (C13): segments older than maxAge are dropped only for streams that can be sent
443
+ * now and have been sendable for longer than maxAge (observe); then the globally oldest segments go until
444
+ * the total is under maxBytes (never the newest segment of `justWritten`). Dropped events are counted
445
+ * (held / sendable) into the `dropped` recorder and logged.
446
+ */
447
+ enforceCap(justWritten) {
448
+ const result = { droppedFiles: 0, droppedBytes: 0, droppedEvents: 0, droppedHeldEvents: 0 };
449
+ const all = this.scan(false).flatMap(({ ref }) => {
450
+ const q = this.queueOf(ref);
451
+ return q.segments().map((seg) => ({ ref, seg, q }));
452
+ });
453
+ let total = all.reduce((s, x) => s + x.seg.size, 0);
454
+ const cutoff = this.now() - this.maxAgeMs;
455
+ if (total <= this.maxBytes && !all.some((x) => x.seg.createdAt < cutoff))
456
+ return result;
457
+ const view = this.opts.routing();
458
+ const sendableByDir = new Map();
459
+ const sendable = (ref) => {
460
+ let v = sendableByDir.get(ref.dir);
461
+ if (v === undefined) {
462
+ v = !!view && sendableNow(view, resolveStream(view, identityOfRef(ref)));
463
+ sendableByDir.set(ref.dir, v);
464
+ }
465
+ return v;
466
+ };
467
+ const sinceByDir = new Map();
468
+ /** start of the stream's current sendable period (observe), null while held */
469
+ const sendableSince = (ref) => {
470
+ if (!sinceByDir.has(ref.dir))
471
+ sinceByDir.set(ref.dir, view ? this.observe(ref, sendable(ref)) : null);
472
+ return sinceByDir.get(ref.dir) ?? null;
473
+ };
474
+ let held = 0;
475
+ let sent = 0;
476
+ const dropped = new Set();
477
+ const drop = (x) => {
478
+ const events = x.q.countEvents(x.seg.file);
479
+ try {
480
+ rmSync(join(x.ref.dir, x.seg.file), { force: true });
481
+ }
482
+ catch {
483
+ return;
484
+ }
485
+ dropped.add(x);
486
+ total -= x.seg.size;
487
+ result.droppedFiles++;
488
+ result.droppedBytes += x.seg.size;
489
+ result.droppedEvents += events;
490
+ if (sendable(x.ref))
491
+ sent += events;
492
+ else
493
+ held += events;
494
+ };
495
+ all.sort((a, b) => a.seg.createdAt - b.seg.createdAt || (a.seg.file < b.seg.file ? -1 : a.seg.file > b.seg.file ? 1 : 0));
496
+ // age cap: only data that has been sendable for the whole period (held, refused and expired time does not count, C13)
497
+ for (const x of all) {
498
+ if (x.seg.createdAt >= cutoff)
499
+ continue;
500
+ const since = sendableSince(x.ref);
501
+ if (since !== null && since < cutoff)
502
+ drop(x);
503
+ }
504
+ const keep = justWritten ? this.segmentsOf(justWritten.dir).at(-1)?.file : undefined;
505
+ for (const x of all) {
506
+ if (total <= this.maxBytes)
507
+ break;
508
+ if (dropped.has(x) || (justWritten && x.ref.dir === justWritten.dir && x.seg.file === keep))
509
+ continue;
510
+ drop(x);
511
+ }
512
+ result.droppedHeldEvents = held;
513
+ if (result.droppedFiles) {
514
+ this.log.warn('queue: cap reached, dropped oldest segments', { ...result });
515
+ try {
516
+ this.opts.dropped?.({ held, sendable: sent });
517
+ }
518
+ catch (err) {
519
+ this.log.error('queue: cannot record dropped events', { error: errorMessage(err) });
520
+ }
521
+ }
522
+ return result;
523
+ }
524
+ stats() {
525
+ const streams = this.scan(false).map(({ ref }) => ({ ...this.queueOf(ref).stats(), stream: ref }));
526
+ const oldest = streams.map((s) => s.oldestAt).filter((x) => x !== null);
527
+ return {
528
+ files: streams.reduce((s, x) => s + x.files, 0),
529
+ bytes: streams.reduce((s, x) => s + x.bytes, 0),
530
+ events: streams.reduce((s, x) => s + x.events, 0),
531
+ oldestAt: oldest.length ? Math.min(...oldest) : null,
532
+ streams,
533
+ };
534
+ }
535
+ // -------------------------------------------------------------- internals
536
+ /** A key in the current shape (keys stored by 0.2 – 0.4 are converted with the current servers). */
537
+ current(key) {
538
+ if (!key)
539
+ return null;
540
+ const k = key;
541
+ if (typeof k.endpoint === 'string' && isTeam(k.team))
542
+ return key;
543
+ return upgradeStreamKey(key, this.opts.routing()?.config ?? null);
544
+ }
545
+ queueOf(ref) {
546
+ return ref.kind === 'pre-upgrade' ? new EventQueue(this.root, { autoCap: false, deadDir: this.deadDir, log: this.log }) : this.stream(ref);
547
+ }
548
+ segmentsOf(dir) {
549
+ return new EventQueue(dir, { autoCap: false }).segments();
550
+ }
551
+ dirNames(parent) {
552
+ try {
553
+ return readdirSync(parent, { withFileTypes: true })
554
+ .filter((d) => d.isDirectory())
555
+ .map((d) => d.name)
556
+ .sort();
557
+ }
558
+ catch {
559
+ return [];
560
+ }
561
+ }
562
+ /** The stream of a directory, as read from its stream.json; anything that does not hash to its name is unknown. */
563
+ readRef(dir, name, kind) {
564
+ if (/^[ot]-/.test(name))
565
+ return { kind: 'legacy', dir };
566
+ const raw = readJsonFile(join(dir, STREAM_FILE));
567
+ if (isObject(raw) && typeof raw.endpoint === 'string' && isTeam(raw.team)) {
568
+ const { endpoint, team } = raw;
569
+ if (kind === 'project' && nonEmpty(raw.key) && streamDirName(raw.key, endpoint, team) === name)
570
+ return { kind, dir, pk: raw.key, endpoint, team };
571
+ if (kind === 'unresolved' && nonEmpty(raw.cwd) && unresolvedDirName(raw.cwd, endpoint, team) === name)
572
+ return { kind, dir, cwd: raw.cwd, endpoint, team };
573
+ }
574
+ return { kind: 'unknown', dir };
575
+ }
576
+ /** All streams (optionally the empty ones too), oldest segment first; the pre-upgrade stream when root segments exist. */
577
+ scan(includeEmpty) {
578
+ const out = [];
579
+ const rootSegs = this.segmentsOf(this.root);
580
+ if (rootSegs.length)
581
+ out.push({ ref: { kind: 'pre-upgrade', dir: this.root }, oldest: rootSegs[0].createdAt });
582
+ for (const [parent, kind] of [
583
+ [this.projectsDir, 'project'],
584
+ [this.unresolvedDir, 'unresolved'],
585
+ ]) {
586
+ for (const name of this.dirNames(parent)) {
587
+ if (name.startsWith('.tmp-'))
588
+ continue;
589
+ const dir = join(parent, name);
590
+ const segs = this.segmentsOf(dir);
591
+ if (!segs.length && !includeEmpty)
592
+ continue;
593
+ out.push({ ref: this.readRef(dir, name, kind), oldest: segs[0]?.createdAt ?? Infinity });
594
+ }
595
+ }
596
+ return out.sort((a, b) => a.oldest - b.oldest || (a.ref.dir < b.ref.dir ? -1 : a.ref.dir > b.ref.dir ? 1 : 0));
597
+ }
598
+ /**
599
+ * The key's stream directory, created atomically with its stream.json in place (temp dir + rename;
600
+ * another writer winning the race is fine). Null when a directory of that name exists but is not
601
+ * this stream (never append into it).
602
+ */
603
+ ensure(key) {
604
+ const project = key.kind === 'project';
605
+ const parent = project ? this.projectsDir : this.unresolvedDir;
606
+ const name = keyDirName(key);
607
+ const dir = join(parent, name);
608
+ const expected = () => {
609
+ const ref = this.readRef(dir, name, key.kind);
610
+ return ref.kind === key.kind ? ref : null;
611
+ };
612
+ if (existsSync(join(dir, STREAM_FILE)))
613
+ return expected();
614
+ ensureDir(parent);
615
+ const tmp = join(parent, `.tmp-${randomBytes(6).toString('hex')}`);
616
+ try {
617
+ ensureDir(tmp);
618
+ const body = project ? { key: key.pk, endpoint: key.endpoint, team: key.team } : { cwd: key.cwd, endpoint: key.endpoint, team: key.team };
619
+ writeJsonAtomic(join(tmp, STREAM_FILE), { ...body, created_at: new Date(this.now()).toISOString() }, 0o600);
620
+ renameSync(tmp, dir);
621
+ }
622
+ catch (err) {
623
+ rmSync(tmp, { recursive: true, force: true });
624
+ const code = err.code;
625
+ if (code !== 'EEXIST' && code !== 'ENOTEMPTY' && code !== 'EPERM')
626
+ throw err;
627
+ }
628
+ return expected();
629
+ }
630
+ /** Renames every segment of `from` into `to` (ULID names keep time order). Returns the events moved. */
631
+ moveSegments(from, to) {
632
+ const q = new EventQueue(from, { autoCap: false });
633
+ let events = 0;
634
+ for (const seg of q.segments()) {
635
+ events += q.countEvents(seg.file);
636
+ const src = join(from, seg.file);
637
+ const dest = join(to, seg.file);
638
+ if (existsSync(dest)) {
639
+ appendFileSync(dest, readFileSync(src), { mode: 0o600 });
640
+ rmSync(src, { force: true });
641
+ }
642
+ else {
643
+ renameSync(src, dest);
644
+ }
645
+ }
646
+ return events;
647
+ }
648
+ }
649
+ //# sourceMappingURL=queue.js.map