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.
- package/README.md +666 -2
- package/bin/ai-obs.js +8 -0
- package/dist/adapters/claude-code/analyze.js +189 -0
- package/dist/adapters/claude-code/analyze.js.map +1 -0
- package/dist/adapters/claude-code/detect.js +60 -0
- package/dist/adapters/claude-code/detect.js.map +1 -0
- package/dist/adapters/claude-code/handlers.js +183 -0
- package/dist/adapters/claude-code/handlers.js.map +1 -0
- package/dist/adapters/claude-code/index.js +55 -0
- package/dist/adapters/claude-code/index.js.map +1 -0
- package/dist/adapters/claude-code/run-links.js +53 -0
- package/dist/adapters/claude-code/run-links.js.map +1 -0
- package/dist/adapters/claude-code/session.js +427 -0
- package/dist/adapters/claude-code/session.js.map +1 -0
- package/dist/adapters/claude-code/settings.js +161 -0
- package/dist/adapters/claude-code/settings.js.map +1 -0
- package/dist/adapters/claude-code/state.js +66 -0
- package/dist/adapters/claude-code/state.js.map +1 -0
- package/dist/adapters/claude-code/subagents.js +246 -0
- package/dist/adapters/claude-code/subagents.js.map +1 -0
- package/dist/adapters/claude-code/transcript.js +309 -0
- package/dist/adapters/claude-code/transcript.js.map +1 -0
- package/dist/adapters/codex/accumulator.js +135 -0
- package/dist/adapters/codex/accumulator.js.map +1 -0
- package/dist/adapters/codex/detect.js +93 -0
- package/dist/adapters/codex/detect.js.map +1 -0
- package/dist/adapters/codex/hooks-config.js +158 -0
- package/dist/adapters/codex/hooks-config.js.map +1 -0
- package/dist/adapters/codex/index.js +270 -0
- package/dist/adapters/codex/index.js.map +1 -0
- package/dist/adapters/codex/locate.js +72 -0
- package/dist/adapters/codex/locate.js.map +1 -0
- package/dist/adapters/codex/records.js +194 -0
- package/dist/adapters/codex/records.js.map +1 -0
- package/dist/adapters/codex/rollout.js +72 -0
- package/dist/adapters/codex/rollout.js.map +1 -0
- package/dist/adapters/codex/session.js +212 -0
- package/dist/adapters/codex/session.js.map +1 -0
- package/dist/adapters/codex/subagents.js +139 -0
- package/dist/adapters/codex/subagents.js.map +1 -0
- package/dist/adapters/codex/summarize.js +84 -0
- package/dist/adapters/codex/summarize.js.map +1 -0
- package/dist/adapters/codex/tools.js +145 -0
- package/dist/adapters/codex/tools.js.map +1 -0
- package/dist/adapters/hook-command.js +94 -0
- package/dist/adapters/hook-command.js.map +1 -0
- package/dist/adapters/index.js +12 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/analysis/corrections.js +79 -0
- package/dist/analysis/corrections.js.map +1 -0
- package/dist/analysis/digest.js +16 -0
- package/dist/analysis/digest.js.map +1 -0
- package/dist/analysis/outcome.js +76 -0
- package/dist/analysis/outcome.js.map +1 -0
- package/dist/analysis/phases.js +203 -0
- package/dist/analysis/phases.js.map +1 -0
- package/dist/analysis/summaries.js +350 -0
- package/dist/analysis/summaries.js.map +1 -0
- package/dist/analysis/tokens.js +67 -0
- package/dist/analysis/tokens.js.map +1 -0
- package/dist/analysis/usage.js +229 -0
- package/dist/analysis/usage.js.map +1 -0
- package/dist/cc-cache.js +81 -0
- package/dist/cc-cache.js.map +1 -0
- package/dist/cli.js +128 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/common.js +235 -0
- package/dist/commands/common.js.map +1 -0
- package/dist/commands/doctor.js +193 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/flush.js +131 -0
- package/dist/commands/flush.js.map +1 -0
- package/dist/commands/init.js +119 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/link.js +171 -0
- package/dist/commands/link.js.map +1 -0
- package/dist/commands/live.js +130 -0
- package/dist/commands/live.js.map +1 -0
- package/dist/commands/login.js +397 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/status.js +155 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/token.js +92 -0
- package/dist/commands/token.js.map +1 -0
- package/dist/commands/uninstall.js +56 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/config.js +305 -0
- package/dist/config.js.map +1 -0
- package/dist/context.js +109 -0
- package/dist/context.js.map +1 -0
- package/dist/delivery.js +151 -0
- package/dist/delivery.js.map +1 -0
- package/dist/dotfile.js +187 -0
- package/dist/dotfile.js.map +1 -0
- package/dist/events.js +259 -0
- package/dist/events.js.map +1 -0
- package/dist/fsutil.js +50 -0
- package/dist/fsutil.js.map +1 -0
- package/dist/git.js +139 -0
- package/dist/git.js.map +1 -0
- package/dist/hook.js +147 -0
- package/dist/hook.js.map +1 -0
- package/dist/http.js +35 -0
- package/dist/http.js.map +1 -0
- package/dist/jobs.js +53 -0
- package/dist/jobs.js.map +1 -0
- package/dist/live/claude-code.js +226 -0
- package/dist/live/claude-code.js.map +1 -0
- package/dist/live/codex.js +91 -0
- package/dist/live/codex.js.map +1 -0
- package/dist/live/monitor.js +229 -0
- package/dist/live/monitor.js.map +1 -0
- package/dist/live/render.js +92 -0
- package/dist/live/render.js.map +1 -0
- package/dist/lock.js +93 -0
- package/dist/lock.js.map +1 -0
- package/dist/log.js +36 -0
- package/dist/log.js.map +1 -0
- package/dist/migrate.js +109 -0
- package/dist/migrate.js.map +1 -0
- package/dist/nudge.js +231 -0
- package/dist/nudge.js.map +1 -0
- package/dist/paths.js +73 -0
- package/dist/paths.js.map +1 -0
- package/dist/pk-cache.js +110 -0
- package/dist/pk-cache.js.map +1 -0
- package/dist/prices.js +45 -0
- package/dist/prices.js.map +1 -0
- package/dist/project-key.js +144 -0
- package/dist/project-key.js.map +1 -0
- package/dist/protocol-types.js +30 -0
- package/dist/protocol-types.js.map +1 -0
- package/dist/queue.js +649 -0
- package/dist/queue.js.map +1 -0
- package/dist/routing.js +122 -0
- package/dist/routing.js.map +1 -0
- package/dist/runtime.js +54 -0
- package/dist/runtime.js.map +1 -0
- package/dist/sender.js +559 -0
- package/dist/sender.js.map +1 -0
- package/dist/state.js +59 -0
- package/dist/state.js.map +1 -0
- package/dist/ulid.js +73 -0
- package/dist/ulid.js.map +1 -0
- package/dist/version.js +26 -0
- package/dist/version.js.map +1 -0
- package/dist/warnings.js +71 -0
- package/dist/warnings.js.map +1 -0
- package/dist/worker-main.js +14 -0
- package/dist/worker-main.js.map +1 -0
- package/dist/worker.js +295 -0
- package/dist/worker.js.map +1 -0
- 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
|