@vibe-cafe/vibe-usage 0.10.1 → 0.10.3
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/api.js +70 -18
- package/src/client-meta.js +44 -0
- package/src/daemon.js +1 -1
- package/src/sync.js +39 -6
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -10,11 +10,22 @@ const INITIAL_DELAY = 1000;
|
|
|
10
10
|
// guaranteeing no uncompressed request ever leaves the client.
|
|
11
11
|
const GZIP_MIN_BYTES = 0;
|
|
12
12
|
|
|
13
|
+
export function retryDelayMs(attempt, random = Math.random) {
|
|
14
|
+
const ceiling = INITIAL_DELAY * 2 ** attempt;
|
|
15
|
+
// Equal jitter keeps a real backoff floor while preventing every desktop
|
|
16
|
+
// client from retrying a shared outage on the same 1s / 2s boundaries.
|
|
17
|
+
return Math.round(ceiling / 2 + random() * ceiling / 2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
14
25
|
let lastError;
|
|
15
26
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
16
27
|
try {
|
|
17
|
-
return await _send(apiUrl, apiKey, buckets, opts
|
|
28
|
+
return await _send(apiUrl, apiKey, buckets, opts, sessions);
|
|
18
29
|
} catch (err) {
|
|
19
30
|
lastError = err;
|
|
20
31
|
// Don't retry auth errors or client errors
|
|
@@ -22,22 +33,29 @@ export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
|
22
33
|
throw err;
|
|
23
34
|
}
|
|
24
35
|
if (attempt < MAX_RETRIES - 1) {
|
|
25
|
-
|
|
26
|
-
await new Promise(r => setTimeout(r, delay));
|
|
36
|
+
await sleep(retryDelayMs(attempt));
|
|
27
37
|
}
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
throw lastError;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
function
|
|
43
|
+
export function encodeIngestBody(buckets, opts, sessions) {
|
|
44
|
+
const payload = { buckets };
|
|
45
|
+
if (sessions && sessions.length > 0) payload.sessions = sessions;
|
|
46
|
+
if (opts?.client) payload.client = opts.client;
|
|
47
|
+
const raw = Buffer.from(JSON.stringify(payload));
|
|
48
|
+
const useGzip = raw.length >= GZIP_MIN_BYTES;
|
|
49
|
+
return {
|
|
50
|
+
body: useGzip ? gzipSync(raw) : raw,
|
|
51
|
+
useGzip,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _send(apiUrl, apiKey, buckets, opts, sessions) {
|
|
34
56
|
return new Promise((resolve, reject) => {
|
|
35
57
|
const url = new URL('/api/usage/ingest', apiUrl);
|
|
36
|
-
const
|
|
37
|
-
if (sessions && sessions.length > 0) payload.sessions = sessions;
|
|
38
|
-
const raw = Buffer.from(JSON.stringify(payload));
|
|
39
|
-
const useGzip = raw.length >= GZIP_MIN_BYTES;
|
|
40
|
-
const body = useGzip ? gzipSync(raw) : raw;
|
|
58
|
+
const { body, useGzip } = encodeIngestBody(buckets, opts, sessions);
|
|
41
59
|
const totalBytes = body.length;
|
|
42
60
|
const mod = url.protocol === 'https:' ? https : http;
|
|
43
61
|
|
|
@@ -89,7 +107,7 @@ function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
|
|
|
89
107
|
while (ok && sent < totalBytes) {
|
|
90
108
|
const slice = body.subarray(sent, sent + CHUNK);
|
|
91
109
|
sent += slice.length;
|
|
92
|
-
if (onProgress) onProgress(sent, totalBytes);
|
|
110
|
+
if (opts?.onProgress) opts.onProgress(sent, totalBytes);
|
|
93
111
|
ok = req.write(slice);
|
|
94
112
|
}
|
|
95
113
|
if (sent < totalBytes) {
|
|
@@ -214,13 +232,36 @@ function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
|
|
|
214
232
|
|
|
215
233
|
/**
|
|
216
234
|
* GET user settings from the vibecafe API.
|
|
217
|
-
* Returns null
|
|
235
|
+
* Returns null after transient failures are exhausted. A 401 remains distinct
|
|
236
|
+
* so callers can surface invalid credentials instead of calling it an outage.
|
|
218
237
|
* @param {string} apiUrl
|
|
219
238
|
* @param {string} apiKey
|
|
220
239
|
* @returns {Promise<{uploadProject: boolean} | null>}
|
|
221
240
|
*/
|
|
222
|
-
export function fetchSettings(apiUrl, apiKey) {
|
|
223
|
-
|
|
241
|
+
export async function fetchSettings(apiUrl, apiKey, retry = {}) {
|
|
242
|
+
const wait = retry.sleep ?? sleep;
|
|
243
|
+
const random = retry.random ?? Math.random;
|
|
244
|
+
|
|
245
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
246
|
+
try {
|
|
247
|
+
return await fetchSettingsOnce(apiUrl, apiKey);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err.message === 'UNAUTHORIZED') throw err;
|
|
250
|
+
// Retrying a permanent client response cannot make it valid. 429 is the
|
|
251
|
+
// exception: it is transient load shedding and benefits from backoff.
|
|
252
|
+
if (err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 429) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
256
|
+
await wait(retryDelayMs(attempt, random));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function fetchSettingsOnce(apiUrl, apiKey) {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
224
265
|
const url = new URL('/api/usage/settings', apiUrl);
|
|
225
266
|
const mod = url.protocol === 'https:' ? https : http;
|
|
226
267
|
|
|
@@ -234,20 +275,31 @@ export function fetchSettings(apiUrl, apiKey) {
|
|
|
234
275
|
let data = '';
|
|
235
276
|
res.on('data', (chunk) => { data += chunk; });
|
|
236
277
|
res.on('end', () => {
|
|
278
|
+
if (res.statusCode === 401) {
|
|
279
|
+
reject(new Error('UNAUTHORIZED'));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
237
282
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
238
|
-
|
|
283
|
+
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
|
|
284
|
+
err.statusCode = res.statusCode;
|
|
285
|
+
reject(err);
|
|
239
286
|
return;
|
|
240
287
|
}
|
|
241
288
|
try {
|
|
242
|
-
|
|
289
|
+
const settings = JSON.parse(data);
|
|
290
|
+
if (typeof settings?.uploadProject !== 'boolean') {
|
|
291
|
+
reject(new Error('Invalid settings response'));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
resolve(settings);
|
|
243
295
|
} catch {
|
|
244
|
-
|
|
296
|
+
reject(new Error('Invalid settings response'));
|
|
245
297
|
}
|
|
246
298
|
});
|
|
247
299
|
});
|
|
248
300
|
|
|
249
|
-
req.on('error',
|
|
250
|
-
req.on('timeout', () => { req.destroy(
|
|
301
|
+
req.on('error', reject);
|
|
302
|
+
req.on('timeout', () => { req.destroy(new Error('Settings request timed out')); });
|
|
251
303
|
req.end();
|
|
252
304
|
});
|
|
253
305
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const pkg = JSON.parse(
|
|
5
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf-8'),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const SURFACES = new Set(['cli', 'daemon', 'mac-app', 'windows-app']);
|
|
9
|
+
|
|
10
|
+
export const COLLECTOR_VERSION = String(pkg.version);
|
|
11
|
+
|
|
12
|
+
function cleanEnv(value, maxLength = 50) {
|
|
13
|
+
if (typeof value !== 'string') return null;
|
|
14
|
+
const cleaned = value.trim().slice(0, maxLength);
|
|
15
|
+
return cleaned || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSyncClient({ defaultSurface = 'cli', hostname } = {}) {
|
|
19
|
+
const requestedSurface = cleanEnv(process.env.VIBE_USAGE_SURFACE, 30);
|
|
20
|
+
const surface = requestedSurface && SURFACES.has(requestedSurface)
|
|
21
|
+
? requestedSurface
|
|
22
|
+
: defaultSurface;
|
|
23
|
+
const runtime = process.versions.bun ? 'bun' : 'node';
|
|
24
|
+
const runtimeVersion = process.versions.bun || process.versions.node;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
collectorVersion: COLLECTOR_VERSION,
|
|
28
|
+
surface,
|
|
29
|
+
surfaceVersion: cleanEnv(process.env.VIBE_USAGE_SURFACE_VERSION) || COLLECTOR_VERSION,
|
|
30
|
+
runtime,
|
|
31
|
+
runtimeVersion,
|
|
32
|
+
platform: process.platform,
|
|
33
|
+
hostname: cleanEnv(hostname, 200) || 'unknown',
|
|
34
|
+
syncId: randomUUID(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function forBatch(client, batchIndex, batchCount) {
|
|
39
|
+
return {
|
|
40
|
+
...client,
|
|
41
|
+
batchIndex,
|
|
42
|
+
batchCount,
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -34,7 +34,7 @@ export async function runDaemon() {
|
|
|
34
34
|
// eslint-disable-next-line no-constant-condition
|
|
35
35
|
while (true) {
|
|
36
36
|
try {
|
|
37
|
-
await runSync({ throws: true, quiet: true });
|
|
37
|
+
await runSync({ throws: true, quiet: true, surface: 'daemon' });
|
|
38
38
|
consecutiveAuthFailures = 0;
|
|
39
39
|
} catch (err) {
|
|
40
40
|
if (err.message === 'UNAUTHORIZED') {
|
package/src/sync.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
bucketKey, bucketHash, sessionKey, sessionHash,
|
|
6
6
|
} from './state.js';
|
|
7
7
|
import { ingest, fetchSettings } from './api.js';
|
|
8
|
+
import { createSyncClient, forBatch } from './client-meta.js';
|
|
8
9
|
import { parsers } from './parsers/index.js';
|
|
9
10
|
import { success, failure, arrow, link, dim } from './output.js';
|
|
10
11
|
|
|
@@ -17,7 +18,16 @@ function formatBytes(bytes) {
|
|
|
17
18
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export
|
|
21
|
+
export function resolveUploadProjectSetting(settings) {
|
|
22
|
+
if (typeof settings?.uploadProject !== 'boolean') {
|
|
23
|
+
const error = new Error('SETTINGS_UNAVAILABLE');
|
|
24
|
+
error.code = 'SETTINGS_UNAVAILABLE';
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
return settings.uploadProject;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runSync({ throws = false, quiet = false, surface = 'cli' } = {}) {
|
|
21
31
|
const config = loadConfig();
|
|
22
32
|
if (!config?.apiKey) {
|
|
23
33
|
console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
|
|
@@ -31,6 +41,26 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
31
41
|
saveConfig(config);
|
|
32
42
|
}
|
|
33
43
|
|
|
44
|
+
// Privacy is a required input, not an optional hint. If the settings API is
|
|
45
|
+
// unavailable, treating it as `false` changes every project-bearing item's
|
|
46
|
+
// incremental identity to `unknown` and can trigger a full-history upload.
|
|
47
|
+
// Resolve it before parsing or loading upload state so failure is a true
|
|
48
|
+
// no-op: no data upload and no state mutation.
|
|
49
|
+
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
50
|
+
let uploadProject;
|
|
51
|
+
try {
|
|
52
|
+
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
53
|
+
uploadProject = resolveUploadProjectSetting(settings);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.message === 'UNAUTHORIZED') {
|
|
56
|
+
console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
|
|
57
|
+
} else {
|
|
58
|
+
console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
|
|
59
|
+
}
|
|
60
|
+
if (throws) throw err;
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
const allBuckets = [];
|
|
35
65
|
const allSessions = [];
|
|
36
66
|
const parserResults = [];
|
|
@@ -115,11 +145,6 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
115
145
|
for (const b of allBuckets) if (!b.hostname) b.hostname = host;
|
|
116
146
|
for (const s of allSessions) if (!s.hostname) s.hostname = host;
|
|
117
147
|
|
|
118
|
-
// Privacy: check if user allows project name upload
|
|
119
|
-
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
120
|
-
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
121
|
-
const uploadProject = settings?.uploadProject === true;
|
|
122
|
-
|
|
123
148
|
if (!quiet) {
|
|
124
149
|
if (uploadProject) {
|
|
125
150
|
console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
|
|
@@ -192,10 +217,12 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
192
217
|
let totalDroppedBuckets = 0;
|
|
193
218
|
let totalDroppedUnknownModels = 0;
|
|
194
219
|
let totalDroppedImplausible = 0;
|
|
220
|
+
let totalProtectedBuckets = 0;
|
|
195
221
|
const droppedSources = new Set();
|
|
196
222
|
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
197
223
|
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
198
224
|
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
225
|
+
const syncClient = createSyncClient({ defaultSurface: surface, hostname: host });
|
|
199
226
|
|
|
200
227
|
try {
|
|
201
228
|
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
@@ -205,6 +232,7 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
205
232
|
const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
|
|
206
233
|
|
|
207
234
|
const result = await ingest(apiUrl, config.apiKey, batch, {
|
|
235
|
+
client: forBatch(syncClient, batchIdx, totalBatches),
|
|
208
236
|
onProgress(sent, total) {
|
|
209
237
|
const pct = Math.round((sent / total) * 100);
|
|
210
238
|
process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)`)}\x1b[K`);
|
|
@@ -219,6 +247,7 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
219
247
|
totalDroppedImplausible += Number(result.dropped.implausible) || 0;
|
|
220
248
|
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
221
249
|
}
|
|
250
|
+
totalProtectedBuckets += Number(result.protected?.buckets) || 0;
|
|
222
251
|
|
|
223
252
|
// Commit only this batch's hashes, only after it uploaded successfully.
|
|
224
253
|
// A batch that throws aborts the loop with its keys still absent from
|
|
@@ -259,6 +288,10 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
259
288
|
console.log(dim(` ${totalDroppedBuckets} buckets dropped (${reasons.join(';')})`));
|
|
260
289
|
}
|
|
261
290
|
|
|
291
|
+
if (totalProtectedBuckets > 0) {
|
|
292
|
+
console.log(dim(` 服务端保留了 ${totalProtectedBuckets} 个更大的已有 bucket(本次较小快照未覆盖)`));
|
|
293
|
+
}
|
|
294
|
+
|
|
262
295
|
if (!quiet && totalSessionsSynced > 0) {
|
|
263
296
|
const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
|
|
264
297
|
const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
|