@vibe-cafe/vibe-usage 0.14.5 → 0.14.6
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/package.json +1 -1
- package/src/index.js +5 -1
- package/src/parsers/cursor.js +273 -153
- package/src/sync.js +496 -358
package/src/sync.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hostname as osHostname } from 'node:os';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
3
|
import { loadConfig, saveConfig } from './config.js';
|
|
3
4
|
import {
|
|
4
5
|
loadState, saveState, pruneState, stateIdentity,
|
|
@@ -91,13 +92,20 @@ export function reaggregateHiddenProjectBuckets(buckets) {
|
|
|
91
92
|
* newest event for the same reason.
|
|
92
93
|
*/
|
|
93
94
|
export function applyParserInfos(state, parserInfos) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
reportParserInfoChanges(state, parserInfos);
|
|
96
|
+
return recordParserInfos(state, parserInfos);
|
|
97
|
+
}
|
|
97
98
|
|
|
99
|
+
function usableInfo(info) {
|
|
100
|
+
return !!info && typeof info === 'object' && !Array.isArray(info);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Warn about meaningful changes against the recorded info; mutates nothing. */
|
|
104
|
+
export function reportParserInfoChanges(state, parserInfos) {
|
|
105
|
+
if (!Array.isArray(parserInfos)) return;
|
|
98
106
|
for (const { source, info } of parserInfos) {
|
|
99
|
-
if (!
|
|
100
|
-
const previous = state.parserMeta[source];
|
|
107
|
+
if (!usableInfo(info)) continue;
|
|
108
|
+
const previous = state.parserMeta?.[source];
|
|
101
109
|
if (source === 'cursor') {
|
|
102
110
|
const warnings = [];
|
|
103
111
|
if (previous?.account && info.account && previous.account !== info.account) {
|
|
@@ -116,23 +124,34 @@ export function applyParserInfos(state, parserInfos) {
|
|
|
116
124
|
);
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
+ `事件 ${info.events ?? 0})。如果最近用过 Cursor,请运行 status 确认登录账号;`
|
|
123
|
-
+ 'X 订阅的用量也可能并入 grok.com/dashboard。',
|
|
124
|
-
);
|
|
125
|
-
} else if (previous?.events > 0 && info.events === 0) {
|
|
126
|
-
warnings.push(
|
|
127
|
-
`cursor: Cursor 本次没有返回任何用量事件(账号 ${info.account || '未知'});`
|
|
128
|
-
+ '如果最近用过 Cursor,请确认 Cursor 仍登录该账号。',
|
|
129
|
-
);
|
|
130
|
-
}
|
|
127
|
+
// No warning when the counts drop to zero: since #131 they are what one
|
|
128
|
+
// run read from a rolling 7-day window, and the last event sliding out
|
|
129
|
+
// of that window is ordinary idleness, not a lost export.
|
|
131
130
|
for (const message of warnings) {
|
|
132
131
|
process.stderr.write(`${dim(` ${message}`)}\n`);
|
|
133
132
|
}
|
|
134
133
|
}
|
|
135
|
-
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Write parser info into state.parserMeta; returns whether anything changed.
|
|
139
|
+
*
|
|
140
|
+
* Parser info is not only diagnostics: Cursor's events fallback asks only for
|
|
141
|
+
* events newer than the recorded `latestTokenAt`. runSync therefore calls this
|
|
142
|
+
* for a source only once every item that source changed in the run has been
|
|
143
|
+
* committed, in the same saveState() as the last of those hashes. Recording it
|
|
144
|
+
* earlier let any save -- the prune save before the upload, or another
|
|
145
|
+
* source's batch -- persist the new value, so a failed or killed upload made
|
|
146
|
+
* the retry skip exactly the events the server never received.
|
|
147
|
+
*/
|
|
148
|
+
export function recordParserInfos(state, parserInfos) {
|
|
149
|
+
if (!Array.isArray(parserInfos) || parserInfos.length === 0) return false;
|
|
150
|
+
if (!state.parserMeta || typeof state.parserMeta !== 'object') state.parserMeta = {};
|
|
151
|
+
let changed = false;
|
|
152
|
+
for (const { source, info } of parserInfos) {
|
|
153
|
+
if (!usableInfo(info)) continue;
|
|
154
|
+
if (JSON.stringify(state.parserMeta[source] ?? null) !== JSON.stringify(info)) {
|
|
136
155
|
state.parserMeta[source] = info;
|
|
137
156
|
changed = true;
|
|
138
157
|
}
|
|
@@ -145,6 +164,21 @@ export function applyParserInfos(state, parserInfos) {
|
|
|
145
164
|
// of loading every tool's logs simultaneously.
|
|
146
165
|
export const PARSER_CONCURRENCY = 4;
|
|
147
166
|
|
|
167
|
+
// Parsers whose run time follows the size of a remote account rather than this
|
|
168
|
+
// machine. Cursor downloads cursor.com's usage export; a whole-account export
|
|
169
|
+
// for a heavy account took 137s (issue #131), and every other tool's upload
|
|
170
|
+
// used to wait for it. If one is still running once the local parsers are done
|
|
171
|
+
// (plus a short grace), runSync uploads the local results first.
|
|
172
|
+
export const DEFERRABLE_SOURCES = new Set(['cursor']);
|
|
173
|
+
const DEFAULT_DEFERRED_GRACE_MS = 5_000;
|
|
174
|
+
|
|
175
|
+
export function resolveDeferredGraceMs(value) {
|
|
176
|
+
const ms = Number(value);
|
|
177
|
+
return value !== undefined && value !== '' && Number.isInteger(ms) && ms >= 0 && ms <= 600_000
|
|
178
|
+
? ms
|
|
179
|
+
: DEFAULT_DEFERRED_GRACE_MS;
|
|
180
|
+
}
|
|
181
|
+
|
|
148
182
|
// Run `fn` over `items` with at most `limit` in flight, preserving order.
|
|
149
183
|
export async function mapWithConcurrency(items, limit, fn) {
|
|
150
184
|
const results = new Array(items.length);
|
|
@@ -240,389 +274,493 @@ export async function runSync({
|
|
|
240
274
|
// and avoid re-uploading already-known history under a different model id).
|
|
241
275
|
const state = loadState(identity);
|
|
242
276
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
277
|
+
// A sync may run as two passes (see below); these lines print once, and
|
|
278
|
+
// the upload client (one syncId per runSync) is created once.
|
|
279
|
+
let syncClient = null;
|
|
280
|
+
// What the server accepted so far in this runSync, across both passes. A
|
|
281
|
+
// pass that uploaded only sessions (e.g. Qoder CLI) counts as an upload too.
|
|
282
|
+
const uploadedSoFar = { buckets: 0, sessions: 0 };
|
|
283
|
+
const uploadedAnything = () => uploadedSoFar.buckets > 0 || uploadedSoFar.sessions > 0;
|
|
284
|
+
const partialLabel = () => {
|
|
285
|
+
const parts = [`${uploadedSoFar.buckets} buckets`];
|
|
286
|
+
if (uploadedSoFar.sessions > 0) parts.push(`${uploadedSoFar.sessions} sessions`);
|
|
287
|
+
return `部分完成(已上传 ${parts.join(' · ')})`;
|
|
288
|
+
};
|
|
289
|
+
let identityNoticeShown = false;
|
|
290
|
+
let projectNoticeShown = false;
|
|
291
|
+
const announceIdentityChange = () => {
|
|
292
|
+
if (!state.identityChanged || quiet || identityNoticeShown) return;
|
|
293
|
+
identityNoticeShown = true;
|
|
294
|
+
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
295
|
+
};
|
|
296
|
+
const printDashboardLink = () => {
|
|
297
|
+
console.log();
|
|
298
|
+
console.log(`${arrow('前往 Dashboard 查看详情')} ${link(`${apiUrl}/usage`)}`);
|
|
299
|
+
};
|
|
252
300
|
|
|
253
301
|
// Run parsers concurrently (bounded) so one slow parser (Cursor's network
|
|
254
302
|
// fetch, a cold Codex index) can't stall the rest. Results are collected in
|
|
255
303
|
// registry order so output and merged arrays stay deterministic.
|
|
256
304
|
const parserRegistry = Object.entries(parsers);
|
|
257
305
|
let parsedSources = 0;
|
|
306
|
+
const runParser = async ([source, parse]) => {
|
|
307
|
+
try {
|
|
308
|
+
const result = await parse({
|
|
309
|
+
extraRoots: extraRootList(config.extraRoots?.[source]),
|
|
310
|
+
previousInfo: state.parserMeta?.[source],
|
|
311
|
+
...(source === 'codex' ? {
|
|
312
|
+
codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
|
|
313
|
+
} : {}),
|
|
314
|
+
});
|
|
315
|
+
return { source, result };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return { source, error: err };
|
|
318
|
+
} finally {
|
|
319
|
+
parsedSources++;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
258
322
|
status.start(elapsedMs => `正在扫描本地日志 ${parsedSources}/${parserRegistry.length}${phaseElapsed(elapsedMs)}`);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
323
|
+
// Deferrable (network-bound) parsers start first, outside the pool: they
|
|
324
|
+
// wait on a remote account, not on this machine's disk or CPU.
|
|
325
|
+
const deferredEntries = parserRegistry.filter(([source]) => DEFERRABLE_SOURCES.has(source));
|
|
326
|
+
const localEntries = parserRegistry.filter(([source]) => !DEFERRABLE_SOURCES.has(source));
|
|
327
|
+
let deferredSettled = deferredEntries.length === 0;
|
|
328
|
+
const deferredOutcomes = Promise.all(deferredEntries.map(runParser)).then((outcomes) => {
|
|
329
|
+
deferredSettled = true;
|
|
330
|
+
return outcomes;
|
|
331
|
+
});
|
|
332
|
+
const localOutcomes = await mapWithConcurrency(localEntries, PARSER_CONCURRENCY, runParser);
|
|
333
|
+
if (!deferredSettled) {
|
|
334
|
+
// A short grace keeps the ordinary case -- a Cursor export that finishes
|
|
335
|
+
// just after the local logs -- in one pass and one summary. `ref: false`:
|
|
336
|
+
// an already-finished sync must not idle on it.
|
|
337
|
+
await Promise.race([
|
|
338
|
+
deferredOutcomes,
|
|
339
|
+
sleep(resolveDeferredGraceMs(process.env.VIBE_USAGE_DEFERRED_GRACE_MS), undefined, { ref: false }),
|
|
340
|
+
]);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (deferredSettled) {
|
|
344
|
+
const bySource = new Map([...localOutcomes, ...await deferredOutcomes].map(o => [o.source, o]));
|
|
345
|
+
status.stop();
|
|
346
|
+
return syncOutcomes(parserRegistry.map(([source]) => bySource.get(source)));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Still downloading (a first whole-account Cursor export can take minutes,
|
|
350
|
+
// issue #131): upload every finished tool now, then the slow source. Each
|
|
351
|
+
// pass diffs, prunes (scoped to its own sources) and commits state per batch
|
|
352
|
+
// exactly like a single pass, so the uploaded keys and values are the same;
|
|
353
|
+
// only their order changes. A desktop app that kills the process at its own
|
|
354
|
+
// timeout now loses at most the slow source, not every tool's upload.
|
|
355
|
+
status.stop();
|
|
356
|
+
const pendingSources = deferredEntries.map(([source]) => source).join(', ');
|
|
357
|
+
if (!quiet) console.log(dim(` ${pendingSources}: 仍在读取用量数据,先上传其他工具`));
|
|
358
|
+
const firstPassIngested = await syncOutcomes(localOutcomes, { final: false });
|
|
359
|
+
status.start(elapsedMs => `正在等待 ${pendingSources}${phaseElapsed(elapsedMs)}`);
|
|
360
|
+
const lateOutcomes = await deferredOutcomes;
|
|
361
|
+
status.stop();
|
|
362
|
+
return firstPassIngested + await syncOutcomes(lateOutcomes);
|
|
363
|
+
|
|
364
|
+
// One diff-and-upload pass over a set of parser outcomes. `final` is false
|
|
365
|
+
// only for the first of two passes (see above): that pass stays quiet about
|
|
366
|
+
// "no new data" and leaves the dashboard link to the last pass.
|
|
367
|
+
async function syncOutcomes(parserOutcomes, { final = true } = {}) {
|
|
368
|
+
let allBuckets = [];
|
|
369
|
+
const allSessions = [];
|
|
370
|
+
const parserResults = [];
|
|
371
|
+
const parserProgress = [];
|
|
372
|
+
const parserInfos = [];
|
|
373
|
+
// Sources whose parser ran to completion this sync. pruneState() below is
|
|
374
|
+
// scoped to these so a transient parser failure doesn't evict that tool's
|
|
375
|
+
// state and force a full re-upload next run.
|
|
376
|
+
const okSources = new Set();
|
|
377
|
+
|
|
378
|
+
for (const { source, result, error } of parserOutcomes) {
|
|
379
|
+
if (error) {
|
|
380
|
+
// Parser errors are non-fatal — pass-through in dim gray (no translation).
|
|
381
|
+
process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
let normalized;
|
|
263
385
|
try {
|
|
264
|
-
|
|
265
|
-
extraRoots: extraRootList(config.extraRoots?.[source]),
|
|
266
|
-
previousInfo: state.parserMeta?.[source],
|
|
267
|
-
...(source === 'codex' ? {
|
|
268
|
-
codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
|
|
269
|
-
} : {}),
|
|
270
|
-
});
|
|
271
|
-
return { source, result };
|
|
386
|
+
normalized = normalizeParserResult(source, result);
|
|
272
387
|
} catch (err) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
388
|
+
process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const { buckets, sessions, skipped, warnings, indexing, info } = normalized;
|
|
392
|
+
if (indexing) {
|
|
393
|
+
parserProgress.push({ source, ...indexing });
|
|
394
|
+
}
|
|
395
|
+
if (info) parserInfos.push({ source, info });
|
|
396
|
+
// Parser warnings always reach stderr, including quiet (daemon) runs: the
|
|
397
|
+
// daemon log is the only trail a background failure leaves. Cursor's fetch
|
|
398
|
+
// soft-skip used to be filtered out here to keep that log tidy, which made
|
|
399
|
+
// a permanently failing export indistinguishable from a healthy one -- the
|
|
400
|
+
// tool still listed as "installed" while it had never uploaded a byte.
|
|
401
|
+
for (const message of warnings) {
|
|
402
|
+
process.stderr.write(`${dim(` ${message}`)}\n`);
|
|
403
|
+
}
|
|
404
|
+
// A parser may downgrade a transient error (Cursor network timeout) to a
|
|
405
|
+
// warning instead of throwing. Its empty result is not proof that its prior
|
|
406
|
+
// data disappeared, so it must not be pruned this run.
|
|
407
|
+
if (!skipped) okSources.add(source);
|
|
408
|
+
for (const bucket of buckets) allBuckets.push(bucket);
|
|
409
|
+
for (const session of sessions) allSessions.push(session);
|
|
410
|
+
if (buckets.length > 0 || sessions.length > 0) {
|
|
411
|
+
parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
|
|
276
412
|
}
|
|
277
|
-
},
|
|
278
|
-
);
|
|
279
|
-
status.stop();
|
|
280
|
-
|
|
281
|
-
for (const { source, result, error } of parserOutcomes) {
|
|
282
|
-
if (error) {
|
|
283
|
-
// Parser errors are non-fatal — pass-through in dim gray (no translation).
|
|
284
|
-
process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
let normalized;
|
|
288
|
-
try {
|
|
289
|
-
normalized = normalizeParserResult(source, result);
|
|
290
|
-
} catch (err) {
|
|
291
|
-
process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
const { buckets, sessions, skipped, warnings, indexing, info } = normalized;
|
|
295
|
-
if (indexing) {
|
|
296
|
-
parserProgress.push({ source, ...indexing });
|
|
297
|
-
}
|
|
298
|
-
if (info) parserInfos.push({ source, info });
|
|
299
|
-
// Parser warnings always reach stderr, including quiet (daemon) runs: the
|
|
300
|
-
// daemon log is the only trail a background failure leaves. Cursor's fetch
|
|
301
|
-
// soft-skip used to be filtered out here to keep that log tidy, which made
|
|
302
|
-
// a permanently failing export indistinguishable from a healthy one -- the
|
|
303
|
-
// tool still listed as "installed" while it had never uploaded a byte.
|
|
304
|
-
for (const message of warnings) {
|
|
305
|
-
process.stderr.write(`${dim(` ${message}`)}\n`);
|
|
306
413
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
414
|
+
|
|
415
|
+
if (allBuckets.length === 0 && allSessions.length === 0) {
|
|
416
|
+
// Successful parsers emitted no live items. Prune their old keys even on
|
|
417
|
+
// this fast path; otherwise deleting the final local log would leave dead
|
|
418
|
+
// state entries forever. Failed-parser sources remain protected.
|
|
419
|
+
announceIdentityChange();
|
|
420
|
+
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
421
|
+
pruneState(state, new Set(), new Set(), okSources);
|
|
422
|
+
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
423
|
+
const metaChanged = applyParserInfos(state, parserInfos);
|
|
424
|
+
if (pruned > 0 || metaChanged) saveState(state, identity);
|
|
425
|
+
if (!quiet && parserProgress.length > 0) {
|
|
426
|
+
for (const p of parserProgress) {
|
|
427
|
+
console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
|
|
428
|
+
}
|
|
429
|
+
} else if (!quiet && final) {
|
|
430
|
+
if (uploadedAnything()) printDashboardLink();
|
|
431
|
+
else console.log(dim('暂无新数据。'));
|
|
432
|
+
}
|
|
433
|
+
return 0;
|
|
315
434
|
}
|
|
316
|
-
}
|
|
317
435
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
436
|
+
if (!quiet && parserResults.length > 0) {
|
|
437
|
+
for (const p of parserResults) {
|
|
438
|
+
const parts = [];
|
|
439
|
+
if (p.buckets > 0) parts.push(`${p.buckets} buckets`);
|
|
440
|
+
if (p.sessions > 0) parts.push(`${p.sessions} sessions`);
|
|
441
|
+
console.log(` ${dim(p.source.padEnd(14))}${parts.join(' · ')}`);
|
|
442
|
+
}
|
|
324
443
|
}
|
|
325
|
-
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
326
|
-
pruneState(state, new Set(), new Set(), okSources);
|
|
327
|
-
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
328
|
-
const metaChanged = applyParserInfos(state, parserInfos);
|
|
329
|
-
if (pruned > 0 || metaChanged) saveState(state, identity);
|
|
330
444
|
if (!quiet && parserProgress.length > 0) {
|
|
331
445
|
for (const p of parserProgress) {
|
|
332
446
|
console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
|
|
333
447
|
}
|
|
334
|
-
} else if (!quiet) {
|
|
335
|
-
console.log(dim('暂无新数据。'));
|
|
336
448
|
}
|
|
337
|
-
return 0;
|
|
338
|
-
}
|
|
339
449
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
console.log(` ${dim(p.source.padEnd(14))}${parts.join(' · ')}`);
|
|
450
|
+
let host = config.hostname;
|
|
451
|
+
if (!host) {
|
|
452
|
+
host = osHostname().replace(/\.local$/, '');
|
|
453
|
+
config.hostname = host;
|
|
454
|
+
saveConfig(config);
|
|
346
455
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
for (const
|
|
350
|
-
|
|
456
|
+
// Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
|
|
457
|
+
// the same account data isn't stored as separate rows per machine.
|
|
458
|
+
for (const b of allBuckets) if (!b.hostname) b.hostname = host;
|
|
459
|
+
for (const s of allSessions) if (!s.hostname) s.hostname = host;
|
|
460
|
+
|
|
461
|
+
if (!quiet && !projectNoticeShown) {
|
|
462
|
+
projectNoticeShown = true;
|
|
463
|
+
if (uploadProject) {
|
|
464
|
+
console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
|
|
465
|
+
} else {
|
|
466
|
+
console.log(dim(' 项目名: 已隐藏'));
|
|
467
|
+
}
|
|
351
468
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
host = osHostname().replace(/\.local$/, '');
|
|
357
|
-
config.hostname = host;
|
|
358
|
-
saveConfig(config);
|
|
359
|
-
}
|
|
360
|
-
// Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
|
|
361
|
-
// the same account data isn't stored as separate rows per machine.
|
|
362
|
-
for (const b of allBuckets) if (!b.hostname) b.hostname = host;
|
|
363
|
-
for (const s of allSessions) if (!s.hostname) s.hostname = host;
|
|
364
|
-
|
|
365
|
-
if (!quiet) {
|
|
366
|
-
if (uploadProject) {
|
|
367
|
-
console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
|
|
368
|
-
} else {
|
|
369
|
-
console.log(dim(' 项目名: 已隐藏'));
|
|
469
|
+
if (!uploadProject) {
|
|
470
|
+
for (const b of allBuckets) b.project = 'unknown';
|
|
471
|
+
for (const s of allSessions) s.project = 'unknown';
|
|
472
|
+
allBuckets = reaggregateHiddenProjectBuckets(allBuckets);
|
|
370
473
|
}
|
|
371
|
-
}
|
|
372
|
-
if (!uploadProject) {
|
|
373
|
-
for (const b of allBuckets) b.project = 'unknown';
|
|
374
|
-
for (const s of allSessions) s.project = 'unknown';
|
|
375
|
-
allBuckets = reaggregateHiddenProjectBuckets(allBuckets);
|
|
376
|
-
}
|
|
377
474
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
};
|
|
409
|
-
okSources.delete('kiki');
|
|
410
|
-
}
|
|
411
|
-
if (migration.blocked) {
|
|
412
|
-
okSources.delete('kiki');
|
|
413
|
-
process.stderr.write(`${dim(' kiki: 历史与 kimi-code 同步记录重叠,暂停 Kiki 上传以避免双计。请保留 state.json,停用旧兼容采集器并按 README 迁移,显式设置 config set kikiStartAt <UTC半小时切点>。')}\n`);
|
|
414
|
-
}
|
|
415
|
-
// A cut (or the guard) withholds real Kiki history and freezes legacy
|
|
416
|
-
// kimi-code rows; neither is silent, even in quiet/daemon runs. The user
|
|
417
|
-
// learns the counts and time range, and both ways out: set a verified cut, or
|
|
418
|
-
// clear the guard when they know no compatibility collector ever ran.
|
|
419
|
-
const withheld = migration.withheld;
|
|
420
|
-
if (withheld.buckets > 0 || withheld.sessions > 0
|
|
421
|
-
|| migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
|
|
422
|
-
const parts = [];
|
|
423
|
-
if (withheld.buckets > 0 || withheld.sessions > 0) {
|
|
424
|
-
const range = withheld.earliest ? `,时间范围 ${withheld.earliest} 至 ${withheld.latest}` : '';
|
|
425
|
-
parts.push(`本次未上传 ${withheld.buckets} 个桶 / ${withheld.sessions} 个会话(${withheld.totalTokens} tokens${range})`);
|
|
475
|
+
// Incremental upload diff: parsers above emit a complete view of live local
|
|
476
|
+
// data (Codex may assemble that view from its disposable parser cache). Here
|
|
477
|
+
// we drop anything whose content matches what we already uploaded, so only
|
|
478
|
+
// new/changed items go over the network. A quiet machine sends zero bytes;
|
|
479
|
+
// an active one sends just the current 30-min bucket.
|
|
480
|
+
// Missing/corrupt state.json => empty maps => one-time full upload, then
|
|
481
|
+
// incremental forever after.
|
|
482
|
+
announceIdentityChange();
|
|
483
|
+
reportParserInfoChanges(state, parserInfos);
|
|
484
|
+
let migration;
|
|
485
|
+
try {
|
|
486
|
+
migration = planKikiMigration(allBuckets, allSessions, state, config.kikiStartAt);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
process.stderr.write(`${dim(` kiki: ${err.message}`)}\n`);
|
|
489
|
+
migration = planKikiMigration(allBuckets, allSessions, state);
|
|
490
|
+
migration.buckets = migration.buckets.filter(b => b.source !== 'kiki');
|
|
491
|
+
migration.sessions = migration.sessions.filter(s => s.source !== 'kiki');
|
|
492
|
+
// The invalid cut drops every Kiki row regardless of what the fallback
|
|
493
|
+
// plan did; report that as withheld so the diagnostic is not silent.
|
|
494
|
+
const droppedBuckets = allBuckets.filter(b => b.source === 'kiki');
|
|
495
|
+
const droppedSessions = allSessions.filter(s => s.source === 'kiki');
|
|
496
|
+
const droppedStarts = droppedBuckets.map(b => Date.parse(b.bucketStart)).filter(Number.isFinite);
|
|
497
|
+
migration.withheld = {
|
|
498
|
+
buckets: droppedBuckets.length,
|
|
499
|
+
sessions: droppedSessions.length,
|
|
500
|
+
totalTokens: droppedBuckets.reduce((sum, b) => sum + (Number(b.totalTokens) || 0), 0),
|
|
501
|
+
earliest: droppedStarts.length > 0 ? new Date(Math.min(...droppedStarts)).toISOString() : null,
|
|
502
|
+
latest: droppedStarts.length > 0 ? new Date(Math.max(...droppedStarts)).toISOString() : null,
|
|
503
|
+
};
|
|
504
|
+
okSources.delete('kiki');
|
|
426
505
|
}
|
|
427
|
-
if (migration.
|
|
428
|
-
|
|
506
|
+
if (migration.blocked) {
|
|
507
|
+
okSources.delete('kiki');
|
|
508
|
+
process.stderr.write(`${dim(' kiki: 历史与 kimi-code 同步记录重叠,暂停 Kiki 上传以避免双计。请保留 state.json,停用旧兼容采集器并按 README 迁移,显式设置 config set kikiStartAt <UTC半小时切点>。')}\n`);
|
|
509
|
+
}
|
|
510
|
+
// A cut (or the guard) withholds real Kiki history and freezes legacy
|
|
511
|
+
// kimi-code rows; neither is silent, even in quiet/daemon runs. The user
|
|
512
|
+
// learns the counts and time range, and both ways out: set a verified cut, or
|
|
513
|
+
// clear the guard when they know no compatibility collector ever ran.
|
|
514
|
+
const withheld = migration.withheld;
|
|
515
|
+
if (withheld.buckets > 0 || withheld.sessions > 0
|
|
516
|
+
|| migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
|
|
517
|
+
const parts = [];
|
|
518
|
+
if (withheld.buckets > 0 || withheld.sessions > 0) {
|
|
519
|
+
const range = withheld.earliest ? `,时间范围 ${withheld.earliest} 至 ${withheld.latest}` : '';
|
|
520
|
+
parts.push(`本次未上传 ${withheld.buckets} 个桶 / ${withheld.sessions} 个会话(${withheld.totalTokens} tokens${range})`);
|
|
521
|
+
}
|
|
522
|
+
if (migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
|
|
523
|
+
parts.push(`已冻结 ${migration.frozenBuckets} 个旧 kimi-code 桶 / ${migration.frozenSessions} 个会话,其增长不再上传(服务端记录会停留在旧值)`);
|
|
524
|
+
}
|
|
525
|
+
process.stderr.write(`${dim(` kiki: ${parts.join(';')}。若这段历史应归入 Kiki,请设置切点 config set kikiStartAt <UTC半小时切点>;若确认从未运行过兼容采集器,可用 config set kikiStartAt none 解除保护。`)}\n`);
|
|
526
|
+
}
|
|
527
|
+
allBuckets = migration.buckets;
|
|
528
|
+
const uploadSessions = migration.sessions;
|
|
529
|
+
const changedBuckets = [];
|
|
530
|
+
const changedSessions = [];
|
|
531
|
+
const liveBucketKeys = new Set(migration.preserveBuckets);
|
|
532
|
+
const liveSessionKeys = new Set(migration.preserveSessions);
|
|
533
|
+
// key -> hash, committed to state only after the owning batch's upload
|
|
534
|
+
// succeeds (a failed batch re-sends next sync — no silent gap).
|
|
535
|
+
const pendingBucketState = new Map();
|
|
536
|
+
const pendingSessionState = new Map();
|
|
537
|
+
|
|
538
|
+
for (const b of allBuckets) {
|
|
539
|
+
const key = bucketKey(b);
|
|
540
|
+
const h = bucketHash(b);
|
|
541
|
+
liveBucketKeys.add(key);
|
|
542
|
+
if (migration.preserveBuckets.has(key) || state.buckets[key] === h) continue;
|
|
543
|
+
changedBuckets.push(b);
|
|
544
|
+
pendingBucketState.set(key, h);
|
|
545
|
+
}
|
|
546
|
+
for (const s of uploadSessions) {
|
|
547
|
+
const key = sessionKey(s);
|
|
548
|
+
const h = sessionHash(s);
|
|
549
|
+
liveSessionKeys.add(key);
|
|
550
|
+
if (migration.preserveSessions.has(key) || state.sessions[key] === h) continue;
|
|
551
|
+
changedSessions.push(s);
|
|
552
|
+
pendingSessionState.set(key, h);
|
|
429
553
|
}
|
|
430
|
-
process.stderr.write(`${dim(` kiki: ${parts.join(';')}。若这段历史应归入 Kiki,请设置切点 config set kikiStartAt <UTC半小时切点>;若确认从未运行过兼容采集器,可用 config set kikiStartAt none 解除保护。`)}\n`);
|
|
431
|
-
}
|
|
432
|
-
allBuckets = migration.buckets;
|
|
433
|
-
const uploadSessions = migration.sessions;
|
|
434
|
-
const changedBuckets = [];
|
|
435
|
-
const changedSessions = [];
|
|
436
|
-
const liveBucketKeys = new Set(migration.preserveBuckets);
|
|
437
|
-
const liveSessionKeys = new Set(migration.preserveSessions);
|
|
438
|
-
// key -> hash, committed to state only after the owning batch's upload
|
|
439
|
-
// succeeds (a failed batch re-sends next sync — no silent gap).
|
|
440
|
-
const pendingBucketState = new Map();
|
|
441
|
-
const pendingSessionState = new Map();
|
|
442
|
-
|
|
443
|
-
for (const b of allBuckets) {
|
|
444
|
-
const key = bucketKey(b);
|
|
445
|
-
const h = bucketHash(b);
|
|
446
|
-
liveBucketKeys.add(key);
|
|
447
|
-
if (migration.preserveBuckets.has(key) || state.buckets[key] === h) continue;
|
|
448
|
-
changedBuckets.push(b);
|
|
449
|
-
pendingBucketState.set(key, h);
|
|
450
|
-
}
|
|
451
|
-
for (const s of uploadSessions) {
|
|
452
|
-
const key = sessionKey(s);
|
|
453
|
-
const h = sessionHash(s);
|
|
454
|
-
liveSessionKeys.add(key);
|
|
455
|
-
if (migration.preserveSessions.has(key) || state.sessions[key] === h) continue;
|
|
456
|
-
changedSessions.push(s);
|
|
457
|
-
pendingSessionState.set(key, h);
|
|
458
|
-
}
|
|
459
554
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
555
|
+
// Parser info is written into state.parserMeta only once every item its
|
|
556
|
+
// source changed this run is committed (see recordParserInfos), and in the
|
|
557
|
+
// same save as the last of those hashes. "Committed" follows the hash rule
|
|
558
|
+
// below exactly: a 200 whose `dropped.unknownSources` names the source
|
|
559
|
+
// leaves its hashes -- and so its info -- uncommitted for a retry; items
|
|
560
|
+
// dropped for other reasons (unknown model, implausible) are committed
|
|
561
|
+
// like any accepted item, since resending cannot change the answer.
|
|
562
|
+
// Sources with nothing to send record their info right away.
|
|
563
|
+
const unsentBySource = new Map();
|
|
564
|
+
const noteUnsent = (id) => {
|
|
565
|
+
const source = id.slice(2, id.indexOf('|'));
|
|
566
|
+
if (!unsentBySource.has(source)) unsentBySource.set(source, new Set());
|
|
567
|
+
unsentBySource.get(source).add(id);
|
|
568
|
+
};
|
|
569
|
+
for (const key of pendingBucketState.keys()) noteUnsent(`b:${key}`);
|
|
570
|
+
for (const key of pendingSessionState.keys()) noteUnsent(`s:${key}`);
|
|
571
|
+
const heldInfos = new Map(parserInfos
|
|
572
|
+
.filter(({ source }) => unsentBySource.has(source))
|
|
573
|
+
.map(item => [item.source, item]));
|
|
574
|
+
const metaChanged = recordParserInfos(
|
|
575
|
+
state,
|
|
576
|
+
parserInfos.filter(({ source }) => !unsentBySource.has(source)),
|
|
577
|
+
);
|
|
578
|
+
const markCommitted = (id) => {
|
|
579
|
+
const source = id.slice(2, id.indexOf('|'));
|
|
580
|
+
const unsent = unsentBySource.get(source);
|
|
581
|
+
if (!unsent || !unsent.delete(id) || unsent.size > 0) return;
|
|
582
|
+
const held = heldInfos.get(source);
|
|
583
|
+
if (held) recordParserInfos(state, [held]);
|
|
584
|
+
};
|
|
477
585
|
|
|
478
|
-
const allBucketsToSend = changedBuckets;
|
|
479
|
-
const allSessionsToSend = changedSessions;
|
|
480
|
-
|
|
481
|
-
let totalIngested = 0;
|
|
482
|
-
let totalSessionsSynced = 0;
|
|
483
|
-
let totalDroppedBuckets = 0;
|
|
484
|
-
let totalDroppedUnknownModels = 0;
|
|
485
|
-
let totalDroppedImplausible = 0;
|
|
486
|
-
let totalProtectedBuckets = 0;
|
|
487
|
-
const droppedSources = new Set();
|
|
488
|
-
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
489
|
-
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
490
|
-
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
491
|
-
const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
|
|
492
|
-
|
|
493
|
-
// Say up front how much is about to go up. A first sync (or one that
|
|
494
|
-
// backfills after a parser was broken) can be thousands of batches, and with
|
|
495
|
-
// only a per-batch progress line the user cannot tell a long upload from a
|
|
496
|
-
// hung one -- which is exactly how a silently failing parser stayed hidden.
|
|
497
|
-
if (!quiet) {
|
|
498
|
-
const pending = [`${allBucketsToSend.length} buckets`];
|
|
499
|
-
if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
|
|
500
|
-
const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
|
|
501
|
-
console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
|
|
502
|
-
}
|
|
503
586
|
|
|
504
|
-
|
|
505
|
-
|
|
587
|
+
// Drop entries the parsers no longer emit (deleted logs) so state.json can't
|
|
588
|
+
// grow forever. Done by liveness, never by age — an old bucket's hash never
|
|
589
|
+
// changes, so keeping it is exactly what prevents re-uploading it.
|
|
590
|
+
//
|
|
591
|
+
// Persist the pruned state unconditionally and immediately: removing dead
|
|
592
|
+
// keys is independent of whether anything uploads, so it must NOT be coupled
|
|
593
|
+
// to upload success. If we deferred this to the batch loop, a first-batch
|
|
594
|
+
// failure would throw before any saveState and the prune would be lost.
|
|
595
|
+
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
596
|
+
pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
|
|
597
|
+
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
598
|
+
if (pruned > 0 || metaChanged) saveState(state, identity);
|
|
506
599
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const batchNum = batchIdx + 1;
|
|
512
|
-
const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
|
|
513
|
-
|
|
514
|
-
// Only measured batches feed the estimate, so the first batch shows no
|
|
515
|
-
// ETA rather than a made-up one.
|
|
516
|
-
const remaining = estimateRemainingSeconds({
|
|
517
|
-
elapsedMs: Date.now() - uploadStartedAt,
|
|
518
|
-
doneBatches: batchIdx,
|
|
519
|
-
totalBatches,
|
|
520
|
-
});
|
|
521
|
-
const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
|
|
522
|
-
|
|
523
|
-
let batchBytes = 0;
|
|
524
|
-
const result = await ingest(apiUrl, config.apiKey, batch, {
|
|
525
|
-
client: forBatch(syncClient, batchIdx, totalBatches),
|
|
526
|
-
onProgress(sent, total) {
|
|
527
|
-
batchBytes = total;
|
|
528
|
-
const pct = Math.round((sent / total) * 100);
|
|
529
|
-
process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
|
|
530
|
-
},
|
|
531
|
-
}, batchSessions.length > 0 ? batchSessions : undefined);
|
|
532
|
-
uploadedBytes += batchBytes;
|
|
533
|
-
totalIngested += result.ingested ?? batch.length;
|
|
534
|
-
totalSessionsSynced += result.sessions ?? 0;
|
|
535
|
-
const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
|
|
536
|
-
if (result.dropped) {
|
|
537
|
-
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
538
|
-
totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
|
|
539
|
-
totalDroppedImplausible += Number(result.dropped.implausible) || 0;
|
|
540
|
-
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
600
|
+
if (changedBuckets.length === 0 && changedSessions.length === 0) {
|
|
601
|
+
if (!quiet && final) {
|
|
602
|
+
if (uploadedAnything()) printDashboardLink();
|
|
603
|
+
else console.log(dim('无新增数据。'));
|
|
541
604
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const allBucketsToSend = changedBuckets;
|
|
609
|
+
const allSessionsToSend = changedSessions;
|
|
610
|
+
|
|
611
|
+
let totalIngested = 0;
|
|
612
|
+
let totalSessionsSynced = 0;
|
|
613
|
+
let totalDroppedBuckets = 0;
|
|
614
|
+
let totalDroppedUnknownModels = 0;
|
|
615
|
+
let totalDroppedImplausible = 0;
|
|
616
|
+
let totalProtectedBuckets = 0;
|
|
617
|
+
const droppedSources = new Set();
|
|
618
|
+
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
619
|
+
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
620
|
+
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
621
|
+
// One syncId per runSync, shared by both passes.
|
|
622
|
+
syncClient ??= createSyncClient({ defaultSurface: surface, hostname: host });
|
|
623
|
+
|
|
624
|
+
// Say up front how much is about to go up. A first sync (or one that
|
|
625
|
+
// backfills after a parser was broken) can be thousands of batches, and with
|
|
626
|
+
// only a per-batch progress line the user cannot tell a long upload from a
|
|
627
|
+
// hung one -- which is exactly how a silently failing parser stayed hidden.
|
|
628
|
+
if (!quiet) {
|
|
629
|
+
const pending = [`${allBucketsToSend.length} buckets`];
|
|
630
|
+
if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
|
|
631
|
+
const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
|
|
632
|
+
console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let uploadedBytes = 0;
|
|
636
|
+
const uploadStartedAt = Date.now();
|
|
637
|
+
|
|
638
|
+
try {
|
|
639
|
+
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
640
|
+
const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
|
|
641
|
+
const batchSessions = allSessionsToSend.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
|
|
642
|
+
const batchNum = batchIdx + 1;
|
|
643
|
+
const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
|
|
644
|
+
|
|
645
|
+
// Only measured batches feed the estimate, so the first batch shows no
|
|
646
|
+
// ETA rather than a made-up one.
|
|
647
|
+
const remaining = estimateRemainingSeconds({
|
|
648
|
+
elapsedMs: Date.now() - uploadStartedAt,
|
|
649
|
+
doneBatches: batchIdx,
|
|
650
|
+
totalBatches,
|
|
651
|
+
});
|
|
652
|
+
const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
|
|
653
|
+
|
|
654
|
+
let batchBytes = 0;
|
|
655
|
+
const result = await ingest(apiUrl, config.apiKey, batch, {
|
|
656
|
+
client: forBatch(syncClient, batchIdx, totalBatches),
|
|
657
|
+
onProgress(sent, total) {
|
|
658
|
+
batchBytes = total;
|
|
659
|
+
const pct = Math.round((sent / total) * 100);
|
|
660
|
+
process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
|
|
661
|
+
},
|
|
662
|
+
}, batchSessions.length > 0 ? batchSessions : undefined);
|
|
663
|
+
uploadedBytes += batchBytes;
|
|
664
|
+
totalIngested += result.ingested ?? batch.length;
|
|
665
|
+
totalSessionsSynced += result.sessions ?? 0;
|
|
666
|
+
uploadedSoFar.buckets += result.ingested ?? batch.length;
|
|
667
|
+
uploadedSoFar.sessions += result.sessions ?? 0;
|
|
668
|
+
const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
|
|
669
|
+
if (result.dropped) {
|
|
670
|
+
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
671
|
+
totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
|
|
672
|
+
totalDroppedImplausible += Number(result.dropped.implausible) || 0;
|
|
673
|
+
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
558
674
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
675
|
+
totalProtectedBuckets += Number(result.protected?.buckets) || 0;
|
|
676
|
+
|
|
677
|
+
// Commit only hashes from this successful batch. Persist before starting
|
|
678
|
+
// the next batch: if a later upload fails or the process exits abruptly,
|
|
679
|
+
// the next sync retries only the uncommitted suffix.
|
|
680
|
+
let batchStateChanged = false;
|
|
681
|
+
for (const b of batch) {
|
|
682
|
+
// A source unknown to an older backend may become valid after deploy.
|
|
683
|
+
// Leave those hashes uncommitted so the next sync retries them instead
|
|
684
|
+
// of turning a temporary release-order mismatch into permanent loss.
|
|
685
|
+
if (batchUnknownSources.has(b.source)) continue;
|
|
686
|
+
const key = bucketKey(b);
|
|
687
|
+
const entry = pendingBucketState.get(key);
|
|
688
|
+
if (entry) {
|
|
689
|
+
state.buckets[key] = entry;
|
|
690
|
+
batchStateChanged = true;
|
|
691
|
+
markCommitted(`b:${key}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
for (const s of batchSessions) {
|
|
695
|
+
// Same uncommitted-on-drop rule as buckets: a session the backend
|
|
696
|
+
// rejected for an unknown source must be retried on the next sync
|
|
697
|
+
// rather than permanently lost.
|
|
698
|
+
if (batchUnknownSources.has(s.source)) continue;
|
|
699
|
+
const key = sessionKey(s);
|
|
700
|
+
const entry = pendingSessionState.get(key);
|
|
701
|
+
if (entry) {
|
|
702
|
+
state.sessions[key] = entry;
|
|
703
|
+
batchStateChanged = true;
|
|
704
|
+
markCommitted(`s:${key}`);
|
|
705
|
+
}
|
|
570
706
|
}
|
|
707
|
+
if (batchStateChanged) saveState(state, identity);
|
|
571
708
|
}
|
|
572
|
-
if (batchStateChanged) saveState(state, identity);
|
|
573
|
-
}
|
|
574
709
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
710
|
+
if (totalBatches > 1 || allBucketsToSend.length > 0) {
|
|
711
|
+
process.stdout.write('\r\x1b[K');
|
|
712
|
+
}
|
|
713
|
+
const syncParts = [`${totalIngested} buckets`];
|
|
714
|
+
if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
|
|
715
|
+
console.log(success(`已同步 ${syncParts.join(' · ')}`));
|
|
716
|
+
if (!quiet && uploadedBytes > 0) {
|
|
717
|
+
const elapsed = (Date.now() - uploadStartedAt) / 1000;
|
|
718
|
+
console.log(dim(` 上传 ${formatBytes(uploadedBytes)}(已压缩),用时 ${formatDuration(elapsed)}`));
|
|
719
|
+
}
|
|
585
720
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
721
|
+
if (totalDroppedBuckets > 0) {
|
|
722
|
+
const reasons = [];
|
|
723
|
+
if (droppedSources.size > 0) {
|
|
724
|
+
reasons.push(`服务端未收录的 source: ${Array.from(droppedSources).sort().join(', ')}`);
|
|
725
|
+
}
|
|
726
|
+
if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
|
|
727
|
+
if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
|
|
728
|
+
if (reasons.length === 0) reasons.push('服务端拒绝');
|
|
729
|
+
console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
|
|
590
730
|
}
|
|
591
|
-
if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
|
|
592
|
-
if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
|
|
593
|
-
if (reasons.length === 0) reasons.push('服务端拒绝');
|
|
594
|
-
console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
|
|
595
|
-
}
|
|
596
731
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
732
|
+
if (totalProtectedBuckets > 0) {
|
|
733
|
+
console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
|
|
734
|
+
}
|
|
600
735
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
736
|
+
if (!quiet && totalSessionsSynced > 0) {
|
|
737
|
+
const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
|
|
738
|
+
const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
|
|
739
|
+
const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
|
|
740
|
+
console.log(dim(` 活跃 ${formatDuration(totalActive)} / 总时长 ${formatDuration(totalDuration)} · ${totalMsgs} 条消息`));
|
|
741
|
+
}
|
|
607
742
|
|
|
608
|
-
|
|
609
|
-
console.log();
|
|
610
|
-
console.log(`${arrow('前往 Dashboard 查看详情')} ${link(`${apiUrl}/usage`)}`);
|
|
611
|
-
}
|
|
743
|
+
if (!quiet && final) printDashboardLink();
|
|
612
744
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
745
|
+
return totalIngested;
|
|
746
|
+
} catch (err) {
|
|
747
|
+
// Every failure line says what this runSync already delivered (both
|
|
748
|
+
// passes, buckets and sessions): when only the slow source's pass fails,
|
|
749
|
+
// the other tools did reach the server. It goes to stderr on one line,
|
|
750
|
+
// because the Windows app shows the last error line of stderr.
|
|
751
|
+
if (err.message === 'UNAUTHORIZED') {
|
|
752
|
+
const keyHint = 'API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。';
|
|
753
|
+
console.error(failure(uploadedAnything() ? `${partialLabel()}: ${keyHint}` : keyHint));
|
|
754
|
+
if (throws) throw err;
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
if (uploadedAnything()) {
|
|
758
|
+
console.error(failure(`${partialLabel()}: ${err.message}`));
|
|
759
|
+
} else {
|
|
760
|
+
console.error(failure(`同步失败: ${err.message}`));
|
|
761
|
+
}
|
|
617
762
|
if (throws) throw err;
|
|
618
763
|
process.exit(1);
|
|
619
764
|
}
|
|
620
|
-
if (totalIngested > 0) {
|
|
621
|
-
console.error(failure(`部分完成(已上传 ${totalIngested} buckets): ${err.message}`));
|
|
622
|
-
} else {
|
|
623
|
-
console.error(failure(`同步失败: ${err.message}`));
|
|
624
|
-
}
|
|
625
|
-
if (throws) throw err;
|
|
626
|
-
process.exit(1);
|
|
627
765
|
}
|
|
628
766
|
}
|