@vibescore/tracker 0.0.6 → 0.0.8
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 +40 -7
- package/README.zh-CN.md +40 -7
- package/package.json +1 -1
- package/src/cli.js +2 -1
- package/src/commands/init.js +134 -13
- package/src/commands/status.js +14 -1
- package/src/commands/sync.js +46 -5
- package/src/commands/uninstall.js +60 -8
- package/src/lib/claude-config.js +190 -0
- package/src/lib/codex-config.js +83 -16
- package/src/lib/diagnostics.js +22 -4
- package/src/lib/rollout.js +249 -18
- package/src/lib/uploader.js +25 -1
package/src/lib/rollout.js
CHANGED
|
@@ -5,6 +5,10 @@ const readline = require('node:readline');
|
|
|
5
5
|
|
|
6
6
|
const { ensureDir } = require('./fs');
|
|
7
7
|
|
|
8
|
+
const DEFAULT_SOURCE = 'codex';
|
|
9
|
+
const DEFAULT_MODEL = 'unknown';
|
|
10
|
+
const BUCKET_SEPARATOR = '|';
|
|
11
|
+
|
|
8
12
|
async function listRolloutFiles(sessionsDir) {
|
|
9
13
|
const out = [];
|
|
10
14
|
const years = await safeReadDir(sessionsDir);
|
|
@@ -33,7 +37,14 @@ async function listRolloutFiles(sessionsDir) {
|
|
|
33
37
|
return out;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
async function
|
|
40
|
+
async function listClaudeProjectFiles(projectsDir) {
|
|
41
|
+
const out = [];
|
|
42
|
+
await walkClaudeProjects(projectsDir, out);
|
|
43
|
+
out.sort((a, b) => a.localeCompare(b));
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress, source }) {
|
|
37
48
|
await ensureDir(path.dirname(queuePath));
|
|
38
49
|
let filesProcessed = 0;
|
|
39
50
|
let eventsAggregated = 0;
|
|
@@ -42,13 +53,18 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
|
|
|
42
53
|
const totalFiles = Array.isArray(rolloutFiles) ? rolloutFiles.length : 0;
|
|
43
54
|
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
44
55
|
const touchedBuckets = new Set();
|
|
56
|
+
const defaultSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
45
57
|
|
|
46
58
|
if (!cursors.files || typeof cursors.files !== 'object') {
|
|
47
59
|
cursors.files = {};
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
for (let idx = 0; idx < rolloutFiles.length; idx++) {
|
|
51
|
-
const
|
|
63
|
+
const entry = rolloutFiles[idx];
|
|
64
|
+
const filePath = typeof entry === 'string' ? entry : entry?.path;
|
|
65
|
+
if (!filePath) continue;
|
|
66
|
+
const fileSource =
|
|
67
|
+
typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
|
|
52
68
|
const st = await fs.stat(filePath).catch(() => null);
|
|
53
69
|
if (!st || !st.isFile()) continue;
|
|
54
70
|
|
|
@@ -65,7 +81,8 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
|
|
|
65
81
|
lastTotal,
|
|
66
82
|
lastModel,
|
|
67
83
|
hourlyState,
|
|
68
|
-
touchedBuckets
|
|
84
|
+
touchedBuckets,
|
|
85
|
+
source: fileSource
|
|
69
86
|
});
|
|
70
87
|
|
|
71
88
|
cursors.files[key] = {
|
|
@@ -98,7 +115,81 @@ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onPro
|
|
|
98
115
|
return { filesProcessed, eventsAggregated, bucketsQueued };
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
async function
|
|
118
|
+
async function parseClaudeIncremental({ projectFiles, cursors, queuePath, onProgress, source }) {
|
|
119
|
+
await ensureDir(path.dirname(queuePath));
|
|
120
|
+
let filesProcessed = 0;
|
|
121
|
+
let eventsAggregated = 0;
|
|
122
|
+
|
|
123
|
+
const cb = typeof onProgress === 'function' ? onProgress : null;
|
|
124
|
+
const files = Array.isArray(projectFiles) ? projectFiles : [];
|
|
125
|
+
const totalFiles = files.length;
|
|
126
|
+
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
127
|
+
const touchedBuckets = new Set();
|
|
128
|
+
const defaultSource = normalizeSourceInput(source) || 'claude';
|
|
129
|
+
|
|
130
|
+
if (!cursors.files || typeof cursors.files !== 'object') {
|
|
131
|
+
cursors.files = {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let idx = 0; idx < files.length; idx++) {
|
|
135
|
+
const entry = files[idx];
|
|
136
|
+
const filePath = typeof entry === 'string' ? entry : entry?.path;
|
|
137
|
+
if (!filePath) continue;
|
|
138
|
+
const fileSource =
|
|
139
|
+
typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
|
|
140
|
+
const st = await fs.stat(filePath).catch(() => null);
|
|
141
|
+
if (!st || !st.isFile()) continue;
|
|
142
|
+
|
|
143
|
+
const key = filePath;
|
|
144
|
+
const prev = cursors.files[key] || null;
|
|
145
|
+
const inode = st.ino || 0;
|
|
146
|
+
const startOffset = prev && prev.inode === inode ? prev.offset || 0 : 0;
|
|
147
|
+
|
|
148
|
+
const result = await parseClaudeFile({
|
|
149
|
+
filePath,
|
|
150
|
+
startOffset,
|
|
151
|
+
hourlyState,
|
|
152
|
+
touchedBuckets,
|
|
153
|
+
source: fileSource
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
cursors.files[key] = {
|
|
157
|
+
inode,
|
|
158
|
+
offset: result.endOffset,
|
|
159
|
+
updatedAt: new Date().toISOString()
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
filesProcessed += 1;
|
|
163
|
+
eventsAggregated += result.eventsAggregated;
|
|
164
|
+
|
|
165
|
+
if (cb) {
|
|
166
|
+
cb({
|
|
167
|
+
index: idx + 1,
|
|
168
|
+
total: totalFiles,
|
|
169
|
+
filePath,
|
|
170
|
+
filesProcessed,
|
|
171
|
+
eventsAggregated,
|
|
172
|
+
bucketsQueued: touchedBuckets.size
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
|
|
178
|
+
hourlyState.updatedAt = new Date().toISOString();
|
|
179
|
+
cursors.hourly = hourlyState;
|
|
180
|
+
|
|
181
|
+
return { filesProcessed, eventsAggregated, bucketsQueued };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function parseRolloutFile({
|
|
185
|
+
filePath,
|
|
186
|
+
startOffset,
|
|
187
|
+
lastTotal,
|
|
188
|
+
lastModel,
|
|
189
|
+
hourlyState,
|
|
190
|
+
touchedBuckets,
|
|
191
|
+
source
|
|
192
|
+
}) {
|
|
102
193
|
const st = await fs.stat(filePath);
|
|
103
194
|
const endOffset = st.size;
|
|
104
195
|
if (startOffset >= endOffset) {
|
|
@@ -130,13 +221,13 @@ async function parseRolloutFile({ filePath, startOffset, lastTotal, lastModel, h
|
|
|
130
221
|
continue;
|
|
131
222
|
}
|
|
132
223
|
|
|
133
|
-
const
|
|
134
|
-
if (!
|
|
224
|
+
const token = extractTokenCount(obj);
|
|
225
|
+
if (!token) continue;
|
|
135
226
|
|
|
136
|
-
const info =
|
|
227
|
+
const info = token.info;
|
|
137
228
|
if (!info || typeof info !== 'object') continue;
|
|
138
229
|
|
|
139
|
-
const tokenTimestamp = typeof
|
|
230
|
+
const tokenTimestamp = typeof token.timestamp === 'string' ? token.timestamp : null;
|
|
140
231
|
if (!tokenTimestamp) continue;
|
|
141
232
|
|
|
142
233
|
const lastUsage = info.last_token_usage;
|
|
@@ -152,27 +243,78 @@ async function parseRolloutFile({ filePath, startOffset, lastTotal, lastModel, h
|
|
|
152
243
|
const bucketStart = toUtcHalfHourStart(tokenTimestamp);
|
|
153
244
|
if (!bucketStart) continue;
|
|
154
245
|
|
|
155
|
-
const bucket = getHourlyBucket(hourlyState, bucketStart);
|
|
246
|
+
const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
|
|
156
247
|
addTotals(bucket.totals, delta);
|
|
157
|
-
touchedBuckets.add(bucketStart);
|
|
248
|
+
touchedBuckets.add(bucketKey(source, model, bucketStart));
|
|
158
249
|
eventsAggregated += 1;
|
|
159
250
|
}
|
|
160
251
|
|
|
161
252
|
return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated };
|
|
162
253
|
}
|
|
163
254
|
|
|
255
|
+
async function parseClaudeFile({ filePath, startOffset, hourlyState, touchedBuckets, source }) {
|
|
256
|
+
const st = await fs.stat(filePath).catch(() => null);
|
|
257
|
+
if (!st || !st.isFile()) return { endOffset: startOffset, eventsAggregated: 0 };
|
|
258
|
+
|
|
259
|
+
const endOffset = st.size;
|
|
260
|
+
if (startOffset >= endOffset) return { endOffset, eventsAggregated: 0 };
|
|
261
|
+
|
|
262
|
+
const stream = fssync.createReadStream(filePath, { encoding: 'utf8', start: startOffset });
|
|
263
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
264
|
+
|
|
265
|
+
let eventsAggregated = 0;
|
|
266
|
+
for await (const line of rl) {
|
|
267
|
+
if (!line || !line.includes('\"usage\"')) continue;
|
|
268
|
+
let obj;
|
|
269
|
+
try {
|
|
270
|
+
obj = JSON.parse(line);
|
|
271
|
+
} catch (_e) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const usage = obj?.message?.usage || obj?.usage;
|
|
276
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
277
|
+
|
|
278
|
+
const model = normalizeModelInput(obj?.message?.model || obj?.model) || DEFAULT_MODEL;
|
|
279
|
+
const tokenTimestamp = typeof obj?.timestamp === 'string' ? obj.timestamp : null;
|
|
280
|
+
if (!tokenTimestamp) continue;
|
|
281
|
+
|
|
282
|
+
const delta = normalizeClaudeUsage(usage);
|
|
283
|
+
if (!delta || isAllZeroUsage(delta)) continue;
|
|
284
|
+
|
|
285
|
+
const bucketStart = toUtcHalfHourStart(tokenTimestamp);
|
|
286
|
+
if (!bucketStart) continue;
|
|
287
|
+
|
|
288
|
+
const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
|
|
289
|
+
addTotals(bucket.totals, delta);
|
|
290
|
+
touchedBuckets.add(bucketKey(source, model, bucketStart));
|
|
291
|
+
eventsAggregated += 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
rl.close();
|
|
295
|
+
stream.close?.();
|
|
296
|
+
return { endOffset, eventsAggregated };
|
|
297
|
+
}
|
|
298
|
+
|
|
164
299
|
async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
|
|
165
300
|
if (!touchedBuckets || touchedBuckets.size === 0) return 0;
|
|
166
301
|
|
|
167
302
|
const toAppend = [];
|
|
168
303
|
for (const bucketStart of touchedBuckets) {
|
|
169
|
-
const
|
|
304
|
+
const parsedKey = parseBucketKey(bucketStart);
|
|
305
|
+
const source = parsedKey.source || DEFAULT_SOURCE;
|
|
306
|
+
const model = parsedKey.model || DEFAULT_MODEL;
|
|
307
|
+
const hourStart = parsedKey.hourStart;
|
|
308
|
+
const bucket =
|
|
309
|
+
hourlyState.buckets[bucketKey(source, model, hourStart)] || hourlyState.buckets[bucketStart];
|
|
170
310
|
if (!bucket || !bucket.totals) continue;
|
|
171
311
|
const key = totalsKey(bucket.totals);
|
|
172
312
|
if (bucket.queuedKey === key) continue;
|
|
173
313
|
toAppend.push(
|
|
174
314
|
JSON.stringify({
|
|
175
|
-
|
|
315
|
+
source,
|
|
316
|
+
model,
|
|
317
|
+
hour_start: hourStart,
|
|
176
318
|
input_tokens: bucket.totals.input_tokens,
|
|
177
319
|
cached_input_tokens: bucket.totals.cached_input_tokens,
|
|
178
320
|
output_tokens: bucket.totals.output_tokens,
|
|
@@ -192,20 +334,39 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
|
|
|
192
334
|
|
|
193
335
|
function normalizeHourlyState(raw) {
|
|
194
336
|
const state = raw && typeof raw === 'object' ? raw : {};
|
|
195
|
-
const
|
|
337
|
+
const version = Number(state.version || 1);
|
|
338
|
+
if (!Number.isFinite(version) || version < 2) {
|
|
339
|
+
return {
|
|
340
|
+
version: 2,
|
|
341
|
+
buckets: {},
|
|
342
|
+
updatedAt: null
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const rawBuckets = state.buckets && typeof state.buckets === 'object' ? state.buckets : {};
|
|
346
|
+
const buckets = {};
|
|
347
|
+
for (const [key, value] of Object.entries(rawBuckets)) {
|
|
348
|
+
const parsed = parseBucketKey(key);
|
|
349
|
+
const hourStart = parsed.hourStart;
|
|
350
|
+
if (!hourStart) continue;
|
|
351
|
+
const normalizedKey = bucketKey(parsed.source, parsed.model, hourStart);
|
|
352
|
+
buckets[normalizedKey] = value;
|
|
353
|
+
}
|
|
196
354
|
return {
|
|
197
|
-
version:
|
|
355
|
+
version: 2,
|
|
198
356
|
buckets,
|
|
199
357
|
updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : null
|
|
200
358
|
};
|
|
201
359
|
}
|
|
202
360
|
|
|
203
|
-
function getHourlyBucket(state, hourStart) {
|
|
361
|
+
function getHourlyBucket(state, source, model, hourStart) {
|
|
204
362
|
const buckets = state.buckets;
|
|
205
|
-
|
|
363
|
+
const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
364
|
+
const normalizedModel = normalizeModelInput(model) || DEFAULT_MODEL;
|
|
365
|
+
const key = bucketKey(normalizedSource, normalizedModel, hourStart);
|
|
366
|
+
let bucket = buckets[key];
|
|
206
367
|
if (!bucket || typeof bucket !== 'object') {
|
|
207
368
|
bucket = { totals: initTotals(), queuedKey: null };
|
|
208
|
-
buckets[
|
|
369
|
+
buckets[key] = bucket;
|
|
209
370
|
return bucket;
|
|
210
371
|
}
|
|
211
372
|
|
|
@@ -267,6 +428,52 @@ function toUtcHalfHourStart(ts) {
|
|
|
267
428
|
return bucketStart.toISOString();
|
|
268
429
|
}
|
|
269
430
|
|
|
431
|
+
function bucketKey(source, model, hourStart) {
|
|
432
|
+
const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
433
|
+
const safeModel = normalizeModelInput(model) || DEFAULT_MODEL;
|
|
434
|
+
return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseBucketKey(key) {
|
|
438
|
+
if (typeof key !== 'string') return { source: DEFAULT_SOURCE, model: DEFAULT_MODEL, hourStart: '' };
|
|
439
|
+
const first = key.indexOf(BUCKET_SEPARATOR);
|
|
440
|
+
if (first <= 0) return { source: DEFAULT_SOURCE, model: DEFAULT_MODEL, hourStart: key };
|
|
441
|
+
const second = key.indexOf(BUCKET_SEPARATOR, first + 1);
|
|
442
|
+
if (second <= 0) {
|
|
443
|
+
return { source: key.slice(0, first), model: DEFAULT_MODEL, hourStart: key.slice(first + 1) };
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
source: key.slice(0, first),
|
|
447
|
+
model: key.slice(first + 1, second),
|
|
448
|
+
hourStart: key.slice(second + 1)
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function normalizeSourceInput(value) {
|
|
453
|
+
if (typeof value !== 'string') return null;
|
|
454
|
+
const trimmed = value.trim().toLowerCase();
|
|
455
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function normalizeModelInput(value) {
|
|
459
|
+
if (typeof value !== 'string') return null;
|
|
460
|
+
const trimmed = value.trim();
|
|
461
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function extractTokenCount(obj) {
|
|
465
|
+
const payload = obj?.payload;
|
|
466
|
+
if (!payload) return null;
|
|
467
|
+
if (payload.type === 'token_count') {
|
|
468
|
+
return { info: payload.info, timestamp: obj?.timestamp || null };
|
|
469
|
+
}
|
|
470
|
+
const msg = payload.msg;
|
|
471
|
+
if (msg && msg.type === 'token_count') {
|
|
472
|
+
return { info: msg.info, timestamp: obj?.timestamp || null };
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
270
477
|
function pickDelta(lastUsage, totalUsage, prevTotals) {
|
|
271
478
|
const hasLast = isNonEmptyObject(lastUsage);
|
|
272
479
|
const hasTotal = isNonEmptyObject(totalUsage);
|
|
@@ -315,6 +522,16 @@ function normalizeUsage(u) {
|
|
|
315
522
|
return out;
|
|
316
523
|
}
|
|
317
524
|
|
|
525
|
+
function normalizeClaudeUsage(u) {
|
|
526
|
+
return {
|
|
527
|
+
input_tokens: toNonNegativeInt(u?.input_tokens),
|
|
528
|
+
cached_input_tokens: toNonNegativeInt(u?.cache_read_input_tokens),
|
|
529
|
+
output_tokens: toNonNegativeInt(u?.output_tokens),
|
|
530
|
+
reasoning_output_tokens: 0,
|
|
531
|
+
total_tokens: toNonNegativeInt(u?.input_tokens) + toNonNegativeInt(u?.output_tokens)
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
318
535
|
function isNonEmptyObject(v) {
|
|
319
536
|
return Boolean(v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0);
|
|
320
537
|
}
|
|
@@ -359,7 +576,21 @@ async function safeReadDir(dir) {
|
|
|
359
576
|
}
|
|
360
577
|
}
|
|
361
578
|
|
|
579
|
+
async function walkClaudeProjects(dir, out) {
|
|
580
|
+
const entries = await safeReadDir(dir);
|
|
581
|
+
for (const entry of entries) {
|
|
582
|
+
const fullPath = path.join(dir, entry.name);
|
|
583
|
+
if (entry.isDirectory()) {
|
|
584
|
+
await walkClaudeProjects(fullPath, out);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(fullPath);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
362
591
|
module.exports = {
|
|
363
592
|
listRolloutFiles,
|
|
364
|
-
|
|
593
|
+
listClaudeProjectFiles,
|
|
594
|
+
parseRolloutIncremental,
|
|
595
|
+
parseClaudeIncremental
|
|
365
596
|
};
|
package/src/lib/uploader.js
CHANGED
|
@@ -5,6 +5,10 @@ const readline = require('node:readline');
|
|
|
5
5
|
const { ensureDir, readJson, writeJson } = require('./fs');
|
|
6
6
|
const { ingestHourly } = require('./vibescore-api');
|
|
7
7
|
|
|
8
|
+
const DEFAULT_SOURCE = 'codex';
|
|
9
|
+
const DEFAULT_MODEL = 'unknown';
|
|
10
|
+
const BUCKET_SEPARATOR = '|';
|
|
11
|
+
|
|
8
12
|
async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePath, maxBatches, batchSize, onProgress }) {
|
|
9
13
|
await ensureDir(require('node:path').dirname(queueStatePath));
|
|
10
14
|
|
|
@@ -70,7 +74,11 @@ async function readBatch(queuePath, startOffset, maxBuckets) {
|
|
|
70
74
|
}
|
|
71
75
|
const hourStart = typeof bucket?.hour_start === 'string' ? bucket.hour_start : null;
|
|
72
76
|
if (!hourStart) continue;
|
|
73
|
-
|
|
77
|
+
const source = normalizeSource(bucket?.source) || DEFAULT_SOURCE;
|
|
78
|
+
const model = normalizeModel(bucket?.model) || DEFAULT_MODEL;
|
|
79
|
+
bucket.source = source;
|
|
80
|
+
bucket.model = model;
|
|
81
|
+
bucketMap.set(bucketKey(source, model, hourStart), bucket);
|
|
74
82
|
linesRead += 1;
|
|
75
83
|
if (linesRead >= maxBuckets) break;
|
|
76
84
|
}
|
|
@@ -89,4 +97,20 @@ async function safeFileSize(p) {
|
|
|
89
97
|
}
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
function bucketKey(source, model, hourStart) {
|
|
101
|
+
return `${source}${BUCKET_SEPARATOR}${model}${BUCKET_SEPARATOR}${hourStart}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeSource(value) {
|
|
105
|
+
if (typeof value !== 'string') return null;
|
|
106
|
+
const trimmed = value.trim().toLowerCase();
|
|
107
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeModel(value) {
|
|
111
|
+
if (typeof value !== 'string') return null;
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
module.exports = { drainQueueToCloud };
|