@token-dashboard/codex-usage-uploader 0.1.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 +113 -0
- package/bin/codex-usage-uploader.js +9 -0
- package/package.json +20 -0
- package/src/auth.js +56 -0
- package/src/cli.js +596 -0
- package/src/collector.js +683 -0
- package/src/constants.js +35 -0
- package/src/install.js +101 -0
- package/src/launchd.js +151 -0
- package/src/parser.js +180 -0
- package/src/runtime-config.js +142 -0
- package/src/state-db.js +334 -0
- package/src/utils.js +53 -0
package/src/state-db.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import {
|
|
5
|
+
MAX_BATCH_BYTES,
|
|
6
|
+
MAX_BUFFER_AGE_SECONDS,
|
|
7
|
+
MAX_EVENTS_PER_BATCH,
|
|
8
|
+
} from './constants.js';
|
|
9
|
+
import { nowTs, stableStringify } from './utils.js';
|
|
10
|
+
|
|
11
|
+
export class StateDb {
|
|
12
|
+
constructor(dbPath) {
|
|
13
|
+
this.dbPath = dbPath;
|
|
14
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
15
|
+
this.db = new DatabaseSync(dbPath, { readBigInts: true });
|
|
16
|
+
this.initSchema();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
close() {
|
|
20
|
+
this.db.close();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
initSchema() {
|
|
24
|
+
this.db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS identity_config (
|
|
26
|
+
key TEXT PRIMARY KEY,
|
|
27
|
+
value TEXT NOT NULL,
|
|
28
|
+
updated_at REAL NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS pending_batches (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
batch_key TEXT NOT NULL UNIQUE,
|
|
34
|
+
status TEXT NOT NULL,
|
|
35
|
+
payload_json TEXT NOT NULL,
|
|
36
|
+
payload_bytes INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
session_count INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
event_count INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
next_retry_at REAL,
|
|
42
|
+
last_error TEXT,
|
|
43
|
+
created_at REAL NOT NULL,
|
|
44
|
+
updated_at REAL NOT NULL
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS upload_checkpoint (
|
|
48
|
+
key TEXT PRIMARY KEY,
|
|
49
|
+
value TEXT NOT NULL,
|
|
50
|
+
updated_at REAL NOT NULL
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
this.ensureIngestionFilesTable();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ensureIngestionFilesTable() {
|
|
57
|
+
const existing = this.db
|
|
58
|
+
.prepare(
|
|
59
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ingestion_files'",
|
|
60
|
+
)
|
|
61
|
+
.get();
|
|
62
|
+
if (!existing) {
|
|
63
|
+
this.createIngestionFilesTable();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const columns = this.db.prepare('PRAGMA table_info(ingestion_files)').all();
|
|
68
|
+
const hasSourceRoot = columns.some((column) => column.name === 'source_root');
|
|
69
|
+
if (hasSourceRoot) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.db.exec('BEGIN');
|
|
74
|
+
try {
|
|
75
|
+
this.db.exec('ALTER TABLE ingestion_files RENAME TO ingestion_files_legacy');
|
|
76
|
+
this.createIngestionFilesTable();
|
|
77
|
+
this.db.prepare(`
|
|
78
|
+
INSERT INTO ingestion_files(
|
|
79
|
+
source_root, relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at
|
|
80
|
+
)
|
|
81
|
+
SELECT 'sessions', relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at
|
|
82
|
+
FROM ingestion_files_legacy
|
|
83
|
+
`).run();
|
|
84
|
+
this.db.exec('DROP TABLE ingestion_files_legacy');
|
|
85
|
+
this.db.exec('COMMIT');
|
|
86
|
+
} catch (error) {
|
|
87
|
+
this.db.exec('ROLLBACK');
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
createIngestionFilesTable() {
|
|
93
|
+
this.db.exec(`
|
|
94
|
+
CREATE TABLE IF NOT EXISTS ingestion_files (
|
|
95
|
+
source_root TEXT NOT NULL,
|
|
96
|
+
relpath TEXT NOT NULL,
|
|
97
|
+
file_size INTEGER NOT NULL,
|
|
98
|
+
file_mtime_ns INTEGER NOT NULL,
|
|
99
|
+
last_line_no INTEGER NOT NULL,
|
|
100
|
+
state_json TEXT,
|
|
101
|
+
updated_at REAL NOT NULL,
|
|
102
|
+
PRIMARY KEY (source_root, relpath)
|
|
103
|
+
);
|
|
104
|
+
`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getIdentity() {
|
|
108
|
+
const rows = this.db.prepare('SELECT key, value FROM identity_config').all();
|
|
109
|
+
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setIdentity(values) {
|
|
113
|
+
const ts = nowTs();
|
|
114
|
+
const upsert = this.db.prepare(`
|
|
115
|
+
INSERT INTO identity_config(key, value, updated_at)
|
|
116
|
+
VALUES (?, ?, ?)
|
|
117
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
118
|
+
`);
|
|
119
|
+
const del = this.db.prepare('DELETE FROM identity_config WHERE key = ?');
|
|
120
|
+
this.db.exec('BEGIN');
|
|
121
|
+
try {
|
|
122
|
+
for (const [key, value] of Object.entries(values)) {
|
|
123
|
+
if (value == null) {
|
|
124
|
+
del.run(key);
|
|
125
|
+
} else {
|
|
126
|
+
upsert.run(key, value, ts);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.db.exec('COMMIT');
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.db.exec('ROLLBACK');
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getCheckpoint(key) {
|
|
137
|
+
const row = this.db.prepare('SELECT value FROM upload_checkpoint WHERE key = ?').get(key);
|
|
138
|
+
return row?.value ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setCheckpoint(key, value) {
|
|
142
|
+
const ts = nowTs();
|
|
143
|
+
this.db.prepare(`
|
|
144
|
+
INSERT INTO upload_checkpoint(key, value, updated_at)
|
|
145
|
+
VALUES (?, ?, ?)
|
|
146
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
147
|
+
`).run(key, value, ts);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getFileState(sourceRoot, relpath) {
|
|
151
|
+
const stmt = this.db.prepare(
|
|
152
|
+
'SELECT * FROM ingestion_files WHERE source_root = ? AND relpath = ?',
|
|
153
|
+
);
|
|
154
|
+
stmt.setReadBigInts(true);
|
|
155
|
+
return stmt.get(sourceRoot, relpath) ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
upsertFileState(sourceRoot, relpath, fileSize, fileMtimeNs, lastLineNo, state) {
|
|
159
|
+
this.db.prepare(`
|
|
160
|
+
INSERT INTO ingestion_files(
|
|
161
|
+
source_root, relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at
|
|
162
|
+
)
|
|
163
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
164
|
+
ON CONFLICT(source_root, relpath) DO UPDATE SET
|
|
165
|
+
file_size = excluded.file_size,
|
|
166
|
+
file_mtime_ns = excluded.file_mtime_ns,
|
|
167
|
+
last_line_no = excluded.last_line_no,
|
|
168
|
+
state_json = excluded.state_json,
|
|
169
|
+
updated_at = excluded.updated_at
|
|
170
|
+
`).run(
|
|
171
|
+
sourceRoot,
|
|
172
|
+
relpath,
|
|
173
|
+
fileSize,
|
|
174
|
+
fileMtimeNs,
|
|
175
|
+
lastLineNo,
|
|
176
|
+
stableStringify(state),
|
|
177
|
+
nowTs(),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
getBufferingBatch() {
|
|
182
|
+
const stmt = this.db.prepare(`
|
|
183
|
+
SELECT * FROM pending_batches
|
|
184
|
+
WHERE status = 'buffering'
|
|
185
|
+
ORDER BY id ASC
|
|
186
|
+
LIMIT 1
|
|
187
|
+
`);
|
|
188
|
+
stmt.setReadBigInts(true);
|
|
189
|
+
return stmt.get() ?? null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
saveBufferingPayload(payload) {
|
|
193
|
+
const existing = this.getBufferingBatch();
|
|
194
|
+
const payloadJson = stableStringify(payload);
|
|
195
|
+
const payloadBytes = Buffer.byteLength(payloadJson, 'utf8');
|
|
196
|
+
const sessionCount = payload.sessions?.length ?? 0;
|
|
197
|
+
const turnCount = payload.turns?.length ?? 0;
|
|
198
|
+
const eventCount = payload.events?.length ?? 0;
|
|
199
|
+
const ts = nowTs();
|
|
200
|
+
if (existing) {
|
|
201
|
+
this.db.prepare(`
|
|
202
|
+
UPDATE pending_batches
|
|
203
|
+
SET payload_json = ?, payload_bytes = ?, session_count = ?, turn_count = ?, event_count = ?, updated_at = ?
|
|
204
|
+
WHERE id = ?
|
|
205
|
+
`).run(payloadJson, payloadBytes, sessionCount, turnCount, eventCount, ts, existing.id);
|
|
206
|
+
} else {
|
|
207
|
+
this.db.prepare(`
|
|
208
|
+
INSERT INTO pending_batches(
|
|
209
|
+
batch_key, status, payload_json, payload_bytes, session_count, turn_count, event_count,
|
|
210
|
+
attempt_count, next_retry_at, created_at, updated_at
|
|
211
|
+
) VALUES (?, 'buffering', ?, ?, ?, ?, ?, 0, NULL, ?, ?)
|
|
212
|
+
`).run(randomBatchKey(), payloadJson, payloadBytes, sessionCount, turnCount, eventCount, ts, ts);
|
|
213
|
+
}
|
|
214
|
+
return this.getBufferingBatch();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
sealStaleBatches(force = false) {
|
|
218
|
+
const selectStmt = this.db.prepare(
|
|
219
|
+
"SELECT id, created_at FROM pending_batches WHERE status = 'buffering'",
|
|
220
|
+
);
|
|
221
|
+
selectStmt.setReadBigInts(true);
|
|
222
|
+
const rows = selectStmt.all();
|
|
223
|
+
let sealed = 0;
|
|
224
|
+
const ts = nowTs();
|
|
225
|
+
const updateStmt = this.db.prepare(`
|
|
226
|
+
UPDATE pending_batches
|
|
227
|
+
SET status = 'pending', next_retry_at = ?, updated_at = ?
|
|
228
|
+
WHERE id = ?
|
|
229
|
+
`);
|
|
230
|
+
for (const row of rows) {
|
|
231
|
+
if (force || ts - Number(row.created_at) >= MAX_BUFFER_AGE_SECONDS) {
|
|
232
|
+
updateStmt.run(ts, ts, row.id);
|
|
233
|
+
sealed += 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return sealed;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
sealBufferIfThresholdHit() {
|
|
240
|
+
const row = this.getBufferingBatch();
|
|
241
|
+
if (!row) return false;
|
|
242
|
+
const shouldSeal =
|
|
243
|
+
Number(row.event_count) >= MAX_EVENTS_PER_BATCH ||
|
|
244
|
+
Number(row.payload_bytes) >= MAX_BATCH_BYTES ||
|
|
245
|
+
nowTs() - Number(row.created_at) >= MAX_BUFFER_AGE_SECONDS;
|
|
246
|
+
if (shouldSeal) {
|
|
247
|
+
const ts = nowTs();
|
|
248
|
+
this.db.prepare(`
|
|
249
|
+
UPDATE pending_batches
|
|
250
|
+
SET status = 'pending', next_retry_at = ?, updated_at = ?
|
|
251
|
+
WHERE id = ?
|
|
252
|
+
`).run(ts, ts, row.id);
|
|
253
|
+
}
|
|
254
|
+
return shouldSeal;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
iterDuePendingBatches() {
|
|
258
|
+
const stmt = this.db.prepare(`
|
|
259
|
+
SELECT * FROM pending_batches
|
|
260
|
+
WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
|
261
|
+
ORDER BY created_at ASC, id ASC
|
|
262
|
+
`);
|
|
263
|
+
stmt.setReadBigInts(true);
|
|
264
|
+
return stmt.all(nowTs());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
markAllPendingDue() {
|
|
268
|
+
this.db.prepare(`
|
|
269
|
+
UPDATE pending_batches
|
|
270
|
+
SET next_retry_at = 0, updated_at = ?
|
|
271
|
+
WHERE status = 'pending'
|
|
272
|
+
`).run(nowTs());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
markBatchUploaded(batchId) {
|
|
276
|
+
this.db.prepare('DELETE FROM pending_batches WHERE id = ?').run(batchId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
markBatchFailed(batchId, attemptCount, errorMessage) {
|
|
280
|
+
const nextRetryAt = nowTs() + Math.min(300, 2 ** Math.min(attemptCount, 8));
|
|
281
|
+
this.db.prepare(`
|
|
282
|
+
UPDATE pending_batches
|
|
283
|
+
SET attempt_count = ?, next_retry_at = ?, last_error = ?, updated_at = ?
|
|
284
|
+
WHERE id = ?
|
|
285
|
+
`).run(attemptCount, nextRetryAt, String(errorMessage).slice(0, 1000), nowTs(), batchId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
resetBackfillState() {
|
|
289
|
+
this.db.exec('BEGIN');
|
|
290
|
+
try {
|
|
291
|
+
this.db.prepare('DELETE FROM ingestion_files').run();
|
|
292
|
+
this.db.prepare('DELETE FROM pending_batches').run();
|
|
293
|
+
this.db.prepare('DELETE FROM upload_checkpoint').run();
|
|
294
|
+
this.db.exec('COMMIT');
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.db.exec('ROLLBACK');
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
getQueueStats() {
|
|
302
|
+
const stmt = this.db.prepare(`
|
|
303
|
+
SELECT
|
|
304
|
+
COALESCE(SUM(CASE WHEN status = 'buffering' THEN 1 ELSE 0 END), 0) AS buffering_batch_count,
|
|
305
|
+
COALESCE(SUM(CASE WHEN status = 'pending' AND attempt_count = 0 THEN 1 ELSE 0 END), 0) AS pending_batch_count,
|
|
306
|
+
COALESCE(SUM(CASE WHEN status = 'pending' AND attempt_count > 0 THEN 1 ELSE 0 END), 0) AS retrying_batch_count,
|
|
307
|
+
COALESCE(SUM(session_count), 0) AS queued_sessions,
|
|
308
|
+
COALESCE(SUM(turn_count), 0) AS queued_turns,
|
|
309
|
+
COALESCE(SUM(event_count), 0) AS queued_events,
|
|
310
|
+
MIN(created_at) AS oldest_created_at
|
|
311
|
+
FROM pending_batches
|
|
312
|
+
`);
|
|
313
|
+
stmt.setReadBigInts(true);
|
|
314
|
+
const row = stmt.get() ?? {};
|
|
315
|
+
const oldestCreatedAt =
|
|
316
|
+
row.oldest_created_at == null ? null : Number(row.oldest_created_at);
|
|
317
|
+
return {
|
|
318
|
+
bufferingBatchCount: Number(row.buffering_batch_count ?? 0),
|
|
319
|
+
pendingBatchCount: Number(row.pending_batch_count ?? 0),
|
|
320
|
+
retryingBatchCount: Number(row.retrying_batch_count ?? 0),
|
|
321
|
+
queuedSessions: Number(row.queued_sessions ?? 0),
|
|
322
|
+
queuedTurns: Number(row.queued_turns ?? 0),
|
|
323
|
+
queuedEvents: Number(row.queued_events ?? 0),
|
|
324
|
+
oldestPendingAgeSeconds:
|
|
325
|
+
oldestCreatedAt == null
|
|
326
|
+
? null
|
|
327
|
+
: Math.max(0, Math.floor(nowTs() - oldestCreatedAt)),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function randomBatchKey() {
|
|
333
|
+
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 14)}`;
|
|
334
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Buffer } from 'node:buffer';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export function nowTs() {
|
|
8
|
+
return Date.now() / 1000;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stableStringify(value) {
|
|
16
|
+
return JSON.stringify(sortForJson(value));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sortForJson(value) {
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
return value.map(sortForJson);
|
|
22
|
+
}
|
|
23
|
+
if (value && typeof value === 'object') {
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
Object.keys(value)
|
|
26
|
+
.sort()
|
|
27
|
+
.map((key) => [key, sortForJson(value[key])]),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isoToEpochMs(value) {
|
|
34
|
+
if (!value) return null;
|
|
35
|
+
const normalized = String(value).replace('Z', '+00:00');
|
|
36
|
+
const timestamp = Date.parse(normalized);
|
|
37
|
+
return Number.isNaN(timestamp) ? null : timestamp;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function deriveRepoName(repositoryUrl, cwd) {
|
|
41
|
+
if (repositoryUrl) {
|
|
42
|
+
let last = repositoryUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
|
43
|
+
if (last.endsWith('.git')) last = last.slice(0, -4);
|
|
44
|
+
if (last) return last;
|
|
45
|
+
}
|
|
46
|
+
if (cwd) return path.basename(cwd) || null;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function computeEventUid(collectorId, relpath, lineNo) {
|
|
51
|
+
const raw = Buffer.from(`${collectorId}|${relpath}|${lineNo}`, 'utf8');
|
|
52
|
+
return createHash('sha1').update(raw).digest('hex');
|
|
53
|
+
}
|