@vibe-cafe/vibe-usage 0.14.4 → 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/README.md +7 -4
- package/package.json +1 -1
- package/src/index.js +20 -1
- package/src/parsers/contract.js +5 -2
- package/src/parsers/cursor.js +649 -91
- package/src/state.js +19 -2
- package/src/sync.js +544 -337
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,
|
|
@@ -79,11 +80,105 @@ export function reaggregateHiddenProjectBuckets(buckets) {
|
|
|
79
80
|
})));
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Compare local parser diagnostics (Cursor's signed-in account and newest
|
|
85
|
+
* event) with the previous run and warn only on a meaningful change. The
|
|
86
|
+
* values live in state.json under `parserMeta`; they are local diagnostics,
|
|
87
|
+
* never uploaded, and older CLIs ignore them.
|
|
88
|
+
*
|
|
89
|
+
* This is how an account switch stops being silent: the old token keeps
|
|
90
|
+
* returning the old account's history, so the dashboard simply stops growing
|
|
91
|
+
* while every sync still "succeeds". `status` prints the recorded account and
|
|
92
|
+
* newest event for the same reason.
|
|
93
|
+
*/
|
|
94
|
+
export function applyParserInfos(state, parserInfos) {
|
|
95
|
+
reportParserInfoChanges(state, parserInfos);
|
|
96
|
+
return recordParserInfos(state, parserInfos);
|
|
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;
|
|
106
|
+
for (const { source, info } of parserInfos) {
|
|
107
|
+
if (!usableInfo(info)) continue;
|
|
108
|
+
const previous = state.parserMeta?.[source];
|
|
109
|
+
if (source === 'cursor') {
|
|
110
|
+
const warnings = [];
|
|
111
|
+
if (previous?.account && info.account && previous.account !== info.account) {
|
|
112
|
+
warnings.push(
|
|
113
|
+
`cursor: Cursor 登录账号已从 ${previous.account} 更换为 ${info.account};`
|
|
114
|
+
+ '如果这不是你刚切换的账号,请在 Cursor 设置中重新登录。',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (previous?.latestEventAt && info.latestEventAt) {
|
|
118
|
+
const before = Date.parse(previous.latestEventAt);
|
|
119
|
+
const after = Date.parse(info.latestEventAt);
|
|
120
|
+
if (Number.isFinite(before) && Number.isFinite(after) && after < before) {
|
|
121
|
+
warnings.push(
|
|
122
|
+
`cursor: Cursor 最新事件从 ${previous.latestEventAt} 回退到 ${info.latestEventAt};`
|
|
123
|
+
+ '这通常意味着当前登录的是另一个(更旧的)Cursor 账号。',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
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.
|
|
130
|
+
for (const message of warnings) {
|
|
131
|
+
process.stderr.write(`${dim(` ${message}`)}\n`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
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)) {
|
|
155
|
+
state.parserMeta[source] = info;
|
|
156
|
+
changed = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return changed;
|
|
160
|
+
}
|
|
161
|
+
|
|
82
162
|
// Parser execution is I/O bound (log reads, occasional network calls). Run a
|
|
83
163
|
// bounded number at once to cut wall-clock sync time without the memory spike
|
|
84
164
|
// of loading every tool's logs simultaneously.
|
|
85
165
|
export const PARSER_CONCURRENCY = 4;
|
|
86
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
|
+
|
|
87
182
|
// Run `fn` over `items` with at most `limit` in flight, preserving order.
|
|
88
183
|
export async function mapWithConcurrency(items, limit, fn) {
|
|
89
184
|
const results = new Array(items.length);
|
|
@@ -174,386 +269,498 @@ export async function runSync({
|
|
|
174
269
|
}
|
|
175
270
|
}
|
|
176
271
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
272
|
+
// Loaded before parsing so a stateful parser can use the previous run's
|
|
273
|
+
// diagnostics (Cursor uses the last event time to gate its events fallback
|
|
274
|
+
// and avoid re-uploading already-known history under a different model id).
|
|
275
|
+
const state = loadState(identity);
|
|
276
|
+
|
|
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
|
+
};
|
|
185
300
|
|
|
186
301
|
// Run parsers concurrently (bounded) so one slow parser (Cursor's network
|
|
187
302
|
// fetch, a cold Codex index) can't stall the rest. Results are collected in
|
|
188
303
|
// registry order so output and merged arrays stay deterministic.
|
|
189
304
|
const parserRegistry = Object.entries(parsers);
|
|
190
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
|
+
};
|
|
191
322
|
status.start(elapsedMs => `正在扫描本地日志 ${parsedSources}/${parserRegistry.length}${phaseElapsed(elapsedMs)}`);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
|
196
385
|
try {
|
|
197
|
-
|
|
198
|
-
extraRoots: extraRootList(config.extraRoots?.[source]),
|
|
199
|
-
...(source === 'codex' ? {
|
|
200
|
-
codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome),
|
|
201
|
-
} : {}),
|
|
202
|
-
});
|
|
203
|
-
return { source, result };
|
|
386
|
+
normalized = normalizeParserResult(source, result);
|
|
204
387
|
} catch (err) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 });
|
|
208
412
|
}
|
|
209
|
-
},
|
|
210
|
-
);
|
|
211
|
-
status.stop();
|
|
212
|
-
|
|
213
|
-
for (const { source, result, error } of parserOutcomes) {
|
|
214
|
-
if (error) {
|
|
215
|
-
// Parser errors are non-fatal — pass-through in dim gray (no translation).
|
|
216
|
-
process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
let normalized;
|
|
220
|
-
try {
|
|
221
|
-
normalized = normalizeParserResult(source, result);
|
|
222
|
-
} catch (err) {
|
|
223
|
-
process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
const { buckets, sessions, skipped, warnings, indexing } = normalized;
|
|
227
|
-
if (indexing) {
|
|
228
|
-
parserProgress.push({ source, ...indexing });
|
|
229
|
-
}
|
|
230
|
-
// Parser warnings always reach stderr, including quiet (daemon) runs: the
|
|
231
|
-
// daemon log is the only trail a background failure leaves. Cursor's fetch
|
|
232
|
-
// soft-skip used to be filtered out here to keep that log tidy, which made
|
|
233
|
-
// a permanently failing export indistinguishable from a healthy one -- the
|
|
234
|
-
// tool still listed as "installed" while it had never uploaded a byte.
|
|
235
|
-
for (const message of warnings) {
|
|
236
|
-
process.stderr.write(`${dim(` ${message}`)}\n`);
|
|
237
413
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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;
|
|
246
434
|
}
|
|
247
|
-
}
|
|
248
435
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
}
|
|
256
443
|
}
|
|
257
|
-
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
258
|
-
pruneState(state, new Set(), new Set(), okSources);
|
|
259
|
-
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
260
|
-
if (pruned > 0) saveState(state, identity);
|
|
261
444
|
if (!quiet && parserProgress.length > 0) {
|
|
262
445
|
for (const p of parserProgress) {
|
|
263
446
|
console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
|
|
264
447
|
}
|
|
265
|
-
} else if (!quiet) {
|
|
266
|
-
console.log(dim('暂无新数据。'));
|
|
267
448
|
}
|
|
268
|
-
return 0;
|
|
269
|
-
}
|
|
270
449
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
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);
|
|
277
455
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
for (const
|
|
281
|
-
|
|
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
|
+
}
|
|
282
468
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
host = osHostname().replace(/\.local$/, '');
|
|
288
|
-
config.hostname = host;
|
|
289
|
-
saveConfig(config);
|
|
290
|
-
}
|
|
291
|
-
// Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
|
|
292
|
-
// the same account data isn't stored as separate rows per machine.
|
|
293
|
-
for (const b of allBuckets) if (!b.hostname) b.hostname = host;
|
|
294
|
-
for (const s of allSessions) if (!s.hostname) s.hostname = host;
|
|
295
|
-
|
|
296
|
-
if (!quiet) {
|
|
297
|
-
if (uploadProject) {
|
|
298
|
-
console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
|
|
299
|
-
} else {
|
|
300
|
-
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);
|
|
301
473
|
}
|
|
302
|
-
}
|
|
303
|
-
if (!uploadProject) {
|
|
304
|
-
for (const b of allBuckets) b.project = 'unknown';
|
|
305
|
-
for (const s of allSessions) s.project = 'unknown';
|
|
306
|
-
allBuckets = reaggregateHiddenProjectBuckets(allBuckets);
|
|
307
|
-
}
|
|
308
474
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
};
|
|
340
|
-
okSources.delete('kiki');
|
|
341
|
-
}
|
|
342
|
-
if (migration.blocked) {
|
|
343
|
-
okSources.delete('kiki');
|
|
344
|
-
process.stderr.write(`${dim(' kiki: 历史与 kimi-code 同步记录重叠,暂停 Kiki 上传以避免双计。请保留 state.json,停用旧兼容采集器并按 README 迁移,显式设置 config set kikiStartAt <UTC半小时切点>。')}\n`);
|
|
345
|
-
}
|
|
346
|
-
// A cut (or the guard) withholds real Kiki history and freezes legacy
|
|
347
|
-
// kimi-code rows; neither is silent, even in quiet/daemon runs. The user
|
|
348
|
-
// learns the counts and time range, and both ways out: set a verified cut, or
|
|
349
|
-
// clear the guard when they know no compatibility collector ever ran.
|
|
350
|
-
const withheld = migration.withheld;
|
|
351
|
-
if (withheld.buckets > 0 || withheld.sessions > 0
|
|
352
|
-
|| migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
|
|
353
|
-
const parts = [];
|
|
354
|
-
if (withheld.buckets > 0 || withheld.sessions > 0) {
|
|
355
|
-
const range = withheld.earliest ? `,时间范围 ${withheld.earliest} 至 ${withheld.latest}` : '';
|
|
356
|
-
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');
|
|
357
505
|
}
|
|
358
|
-
if (migration.
|
|
359
|
-
|
|
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);
|
|
360
553
|
}
|
|
361
|
-
process.stderr.write(`${dim(` kiki: ${parts.join(';')}。若这段历史应归入 Kiki,请设置切点 config set kikiStartAt <UTC半小时切点>;若确认从未运行过兼容采集器,可用 config set kikiStartAt none 解除保护。`)}\n`);
|
|
362
|
-
}
|
|
363
|
-
allBuckets = migration.buckets;
|
|
364
|
-
const uploadSessions = migration.sessions;
|
|
365
|
-
const changedBuckets = [];
|
|
366
|
-
const changedSessions = [];
|
|
367
|
-
const liveBucketKeys = new Set(migration.preserveBuckets);
|
|
368
|
-
const liveSessionKeys = new Set(migration.preserveSessions);
|
|
369
|
-
// key -> hash, committed to state only after the owning batch's upload
|
|
370
|
-
// succeeds (a failed batch re-sends next sync — no silent gap).
|
|
371
|
-
const pendingBucketState = new Map();
|
|
372
|
-
const pendingSessionState = new Map();
|
|
373
|
-
|
|
374
|
-
for (const b of allBuckets) {
|
|
375
|
-
const key = bucketKey(b);
|
|
376
|
-
const h = bucketHash(b);
|
|
377
|
-
liveBucketKeys.add(key);
|
|
378
|
-
if (migration.preserveBuckets.has(key) || state.buckets[key] === h) continue;
|
|
379
|
-
changedBuckets.push(b);
|
|
380
|
-
pendingBucketState.set(key, h);
|
|
381
|
-
}
|
|
382
|
-
for (const s of uploadSessions) {
|
|
383
|
-
const key = sessionKey(s);
|
|
384
|
-
const h = sessionHash(s);
|
|
385
|
-
liveSessionKeys.add(key);
|
|
386
|
-
if (migration.preserveSessions.has(key) || state.sessions[key] === h) continue;
|
|
387
|
-
changedSessions.push(s);
|
|
388
|
-
pendingSessionState.set(key, h);
|
|
389
|
-
}
|
|
390
554
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
+
};
|
|
408
585
|
|
|
409
|
-
const allBucketsToSend = changedBuckets;
|
|
410
|
-
const allSessionsToSend = changedSessions;
|
|
411
|
-
|
|
412
|
-
let totalIngested = 0;
|
|
413
|
-
let totalSessionsSynced = 0;
|
|
414
|
-
let totalDroppedBuckets = 0;
|
|
415
|
-
let totalDroppedUnknownModels = 0;
|
|
416
|
-
let totalDroppedImplausible = 0;
|
|
417
|
-
let totalProtectedBuckets = 0;
|
|
418
|
-
const droppedSources = new Set();
|
|
419
|
-
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
420
|
-
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
421
|
-
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
422
|
-
const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
|
|
423
|
-
|
|
424
|
-
// Say up front how much is about to go up. A first sync (or one that
|
|
425
|
-
// backfills after a parser was broken) can be thousands of batches, and with
|
|
426
|
-
// only a per-batch progress line the user cannot tell a long upload from a
|
|
427
|
-
// hung one -- which is exactly how a silently failing parser stayed hidden.
|
|
428
|
-
if (!quiet) {
|
|
429
|
-
const pending = [`${allBucketsToSend.length} buckets`];
|
|
430
|
-
if (allSessionsToSend.length > 0) pending.push(`${allSessionsToSend.length} sessions`);
|
|
431
|
-
const batchNote = totalBatches > 1 ? `,分 ${totalBatches} 批` : '';
|
|
432
|
-
console.log(dim(` 待上传 ${pending.join(' · ')}${batchNote}`));
|
|
433
|
-
}
|
|
434
586
|
|
|
435
|
-
|
|
436
|
-
|
|
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);
|
|
437
599
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const batchNum = batchIdx + 1;
|
|
443
|
-
const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
|
|
444
|
-
|
|
445
|
-
// Only measured batches feed the estimate, so the first batch shows no
|
|
446
|
-
// ETA rather than a made-up one.
|
|
447
|
-
const remaining = estimateRemainingSeconds({
|
|
448
|
-
elapsedMs: Date.now() - uploadStartedAt,
|
|
449
|
-
doneBatches: batchIdx,
|
|
450
|
-
totalBatches,
|
|
451
|
-
});
|
|
452
|
-
const etaNote = remaining === null ? '' : ` · 预计还需 ${formatDuration(remaining)}`;
|
|
453
|
-
|
|
454
|
-
let batchBytes = 0;
|
|
455
|
-
const result = await ingest(apiUrl, config.apiKey, batch, {
|
|
456
|
-
client: forBatch(syncClient, batchIdx, totalBatches),
|
|
457
|
-
onProgress(sent, total) {
|
|
458
|
-
batchBytes = total;
|
|
459
|
-
const pct = Math.round((sent / total) * 100);
|
|
460
|
-
process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)${etaNote}`)}\x1b[K`);
|
|
461
|
-
},
|
|
462
|
-
}, batchSessions.length > 0 ? batchSessions : undefined);
|
|
463
|
-
uploadedBytes += batchBytes;
|
|
464
|
-
totalIngested += result.ingested ?? batch.length;
|
|
465
|
-
totalSessionsSynced += result.sessions ?? 0;
|
|
466
|
-
const batchUnknownSources = new Set(result.dropped?.unknownSources || []);
|
|
467
|
-
if (result.dropped) {
|
|
468
|
-
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
469
|
-
totalDroppedUnknownModels += Number(result.dropped.unknownModels) || 0;
|
|
470
|
-
totalDroppedImplausible += Number(result.dropped.implausible) || 0;
|
|
471
|
-
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('无新增数据。'));
|
|
472
604
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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);
|
|
489
674
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
+
}
|
|
501
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
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (batchStateChanged) saveState(state, identity);
|
|
502
708
|
}
|
|
503
|
-
if (batchStateChanged) saveState(state, identity);
|
|
504
|
-
}
|
|
505
709
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
+
}
|
|
516
720
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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(';')})`));
|
|
521
730
|
}
|
|
522
|
-
if (totalDroppedUnknownModels > 0) reasons.push(`模型未知: ${totalDroppedUnknownModels}`);
|
|
523
|
-
if (totalDroppedImplausible > 0) reasons.push(`超出合理范围: ${totalDroppedImplausible}`);
|
|
524
|
-
if (reasons.length === 0) reasons.push('服务端拒绝');
|
|
525
|
-
console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
|
|
526
|
-
}
|
|
527
731
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
732
|
+
if (totalProtectedBuckets > 0) {
|
|
733
|
+
console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
|
|
734
|
+
}
|
|
531
735
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
+
}
|
|
538
742
|
|
|
539
|
-
|
|
540
|
-
console.log();
|
|
541
|
-
console.log(`${arrow('前往 Dashboard 查看详情')} ${link(`${apiUrl}/usage`)}`);
|
|
542
|
-
}
|
|
743
|
+
if (!quiet && final) printDashboardLink();
|
|
543
744
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
+
}
|
|
548
762
|
if (throws) throw err;
|
|
549
763
|
process.exit(1);
|
|
550
764
|
}
|
|
551
|
-
if (totalIngested > 0) {
|
|
552
|
-
console.error(failure(`部分完成(已上传 ${totalIngested} buckets): ${err.message}`));
|
|
553
|
-
} else {
|
|
554
|
-
console.error(failure(`同步失败: ${err.message}`));
|
|
555
|
-
}
|
|
556
|
-
if (throws) throw err;
|
|
557
|
-
process.exit(1);
|
|
558
765
|
}
|
|
559
766
|
}
|