charge-connect 0.1.5 → 0.1.7
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/collect.js +289 -94
- package/package.json +1 -1
package/collect.js
CHANGED
|
@@ -115,6 +115,18 @@ function accountHash(id) {
|
|
|
115
115
|
return require("node:crypto").createHash("sha256").update(String(id)).digest("hex").slice(0, 12);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// 퍼센트 수치 파싱 — 값이 있을 때만 숫자를 돌려주고, 비었으면 null.
|
|
119
|
+
// Number()에 맡기면 안 된다: Number(null)·Number("")·Number(false)·Number([])는 전부 0이고
|
|
120
|
+
// finite라, "수치가 비었다"와 "진짜 0%(정상 사용 0)"가 구분되지 않는다. 빈 값이 0%로 올라가면
|
|
121
|
+
// 서버 입장에선 멀쩡한 값이라(빈 창 가드는 JSON null만 보존한다) 다른 기기가 올린 게이지를 0으로 덮는다.
|
|
122
|
+
// 숫자이거나 공백 아닌 숫자 문자열일 때만 값으로 인정한다.
|
|
123
|
+
function percentValue(v) {
|
|
124
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
125
|
+
if (typeof v !== "string" || !v.trim()) return null;
|
|
126
|
+
const n = Number(v);
|
|
127
|
+
return Number.isFinite(n) ? n : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
/// 리셋 시각이 이미 지난 창은 무효 처리 (리셋 후 100% 박제 방지)
|
|
119
131
|
function dropExpired(w) {
|
|
120
132
|
if (!w) return null;
|
|
@@ -132,10 +144,18 @@ function sanitizeCachedProvider(prev) {
|
|
|
132
144
|
const extras = (prev.extras ?? [])
|
|
133
145
|
.map((extra) => ({ ...extra, window: dropExpired(extra.window) }))
|
|
134
146
|
.filter((extra) => extra.window);
|
|
147
|
+
const session = dropExpired(prev.session);
|
|
148
|
+
const weekly = dropExpired(prev.weekly);
|
|
149
|
+
// 만료를 걷어내고 표시할 창이 하나도 안 남으면 복원 대상에서 뺀다(null).
|
|
150
|
+
// 전부 null인 껍데기를 며칠 묵은 collected_at과 함께 올리면 앱엔 빈 카드가 뜨고,
|
|
151
|
+
// 서버 행의 나이가 그 옛 시각으로 되감겨 다른 기기의 묵은 업로드까지 신선도 가드를 통과한다.
|
|
152
|
+
// 결과적으로 그 프로바이더는 이번 페이로드에서 통째로 빠진다 — 서버의 은퇴 delete에는
|
|
153
|
+
// 20분 유예가 있어, 한두 사이클 빠졌다가 다시 관측되면 카드가 사라지지 않는다.
|
|
154
|
+
if (!session && !weekly && !extras.length) return null;
|
|
135
155
|
return {
|
|
136
156
|
...prev,
|
|
137
|
-
session
|
|
138
|
-
weekly
|
|
157
|
+
session,
|
|
158
|
+
weekly,
|
|
139
159
|
extras: extras.length ? extras : null,
|
|
140
160
|
};
|
|
141
161
|
}
|
|
@@ -191,8 +211,14 @@ function explicitWindowLabel(window) {
|
|
|
191
211
|
|
|
192
212
|
function normalizeRateWindow(window, slot, duplicateDuration = false, forcedLabel = null) {
|
|
193
213
|
if (!window || typeof window !== "object") return null;
|
|
194
|
-
|
|
195
|
-
|
|
214
|
+
// 후보마다 percentValue를 적용해 '유효한' 첫 값을 고른다. ?? 체인은 null/undefined만
|
|
215
|
+
// 건너뛰므로 ""·false·[]가 앞자리에 있으면 Number()에서 0으로 둔갑해 그대로 굳었다 —
|
|
216
|
+
// 즉 이 폴백의 의미가 "null/undefined 건너뛰기"에서 "유효하지 않은 값 건너뛰기"로 바뀐다.
|
|
217
|
+
// 빈 수치를 0%로 올리면 서버의 빈 창 가드를 통과해 다른 기기의 멀쩡한 게이지를 덮는다.
|
|
218
|
+
const percent = [window.usedPercent, window.percent, window.utilization]
|
|
219
|
+
.map(percentValue)
|
|
220
|
+
.find((value) => value !== null);
|
|
221
|
+
if (percent === undefined) return null;
|
|
196
222
|
const rawMinutes = window.windowMinutes ?? window.window_minutes ?? window.durationMinutes;
|
|
197
223
|
const parsedMinutes = rawMinutes == null ? null : Number(rawMinutes);
|
|
198
224
|
const minutes = Number.isFinite(parsedMinutes) && parsedMinutes > 0 ? parsedMinutes : null;
|
|
@@ -250,18 +276,53 @@ async function collectDaily() {
|
|
|
250
276
|
return [...byDay.values()].sort((a, b) => a.period.localeCompare(b.period));
|
|
251
277
|
}
|
|
252
278
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
279
|
+
// run은 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
280
|
+
async function collectLive({ run = runCcusage } = {}) {
|
|
281
|
+
const data = await run(["blocks", "--active", "--json"]);
|
|
282
|
+
const block = (data.blocks ?? []).find((b) => b.isActive) ?? null;
|
|
283
|
+
// collected_at = 이 블록을 실제로 관측한 시각. 서버가 charge_live 신선도 판정에 쓴다 —
|
|
284
|
+
// 수집이 깨진 기기가 캐시된 옛 블록을 5분마다 재업로드해도 최신 블록을 덮지 못한다.
|
|
285
|
+
// 캐시 폴백은 cache.live를 통째로 재사용하므로 이 값이 갱신되지 않고 나이가 보존된다.
|
|
286
|
+
return block ? { ...block, collected_at: new Date().toISOString() } : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 잠에서 깬 직후에는 스케줄러가 Wi-Fi보다 먼저 돌아 fetch가 즉시 "fetch failed"로 죽는다.
|
|
290
|
+
// 그 사이클을 통째로 버리면 다음 5분까지 묵은 캐시가 올라가므로, 네트워크 계층 실패(HTTP
|
|
291
|
+
// 응답을 못 받은 경우)만 짧게 한 번 더 시도한다. 상태 코드가 온 요청은 재시도하지 않는다
|
|
292
|
+
// (401을 두 번 물어봐야 답이 같고, 5분마다 도는 작업이라 재시도는 최소로).
|
|
293
|
+
// 타임아웃 시그널은 반드시 시도마다 새로 만든다 — 호출자가 만든 signal을 두 시도가 나눠 쓰면
|
|
294
|
+
// 1차가 예산 후반에 죽었을 때 2차는 대기 시간만큼 이미 지난 예산을 물려받아 즉시 abort된다.
|
|
295
|
+
// 재시도가 무의미해지는 것도 문제지만, 로그에 남는 원인이 "fetch failed"에서
|
|
296
|
+
// "This operation was aborted"로 바뀌어 진짜 원인(네트워크 미연결)을 가린다.
|
|
297
|
+
async function fetchOnceRetried(fetchFn, url, options, { timeoutMs = 10_000, delayMs = 4000 } = {}) {
|
|
298
|
+
const attempt = () => fetchFn(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
|
|
299
|
+
try {
|
|
300
|
+
return await attempt();
|
|
301
|
+
} catch (e) {
|
|
302
|
+
if (e?.name === "AbortError" || e?.name === "TimeoutError") throw e;
|
|
303
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
304
|
+
return attempt();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Claude Code 자격증명: macOS는 Keychain, Windows/Linux는 ~/.claude/.credentials.json.
|
|
309
|
+
// macOS라도 SSH 세션의 Claude Code는 Keychain에 못 쓰고 파일에 토큰을 갱신하므로
|
|
310
|
+
// 한 곳만 읽으면 낡은 토큰으로 401/429를 반복할 수 있다. 둘 다 읽어 최신 쪽을 쓴다.
|
|
311
|
+
function freshestCredentials(sources) {
|
|
312
|
+
const expiresAt = (c) => Number((c?.claudeAiOauth ?? c)?.expiresAt) || 0;
|
|
313
|
+
return sources.reduce((a, b) => (expiresAt(b) > expiresAt(a) ? b : a));
|
|
256
314
|
}
|
|
257
315
|
|
|
258
|
-
// Claude Code 자격증명: macOS는 Keychain, Windows/Linux는 ~/.claude/.credentials.json
|
|
259
316
|
async function claudeCredentials() {
|
|
317
|
+
const sources = [];
|
|
260
318
|
try {
|
|
261
|
-
|
|
262
|
-
} catch {
|
|
263
|
-
|
|
264
|
-
|
|
319
|
+
sources.push(JSON.parse(await runAsync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"])));
|
|
320
|
+
} catch {}
|
|
321
|
+
try {
|
|
322
|
+
sources.push(JSON.parse(fs.readFileSync(path.join(HOME, ".claude", ".credentials.json"), "utf8")));
|
|
323
|
+
} catch {}
|
|
324
|
+
if (!sources.length) throw new Error("Claude Code 자격증명 없음");
|
|
325
|
+
return freshestCredentials(sources);
|
|
265
326
|
}
|
|
266
327
|
|
|
267
328
|
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회.
|
|
@@ -285,10 +346,10 @@ async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredent
|
|
|
285
346
|
: oauth.subscriptionType
|
|
286
347
|
? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
|
|
287
348
|
: null;
|
|
288
|
-
|
|
349
|
+
// signal은 넘기지 않는다 — fetchOnceRetried가 시도마다 새 10초 예산을 만들어 붙인다
|
|
350
|
+
const res = await fetchOnceRetried(fetchFn, "https://api.anthropic.com/api/oauth/usage", {
|
|
289
351
|
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
290
|
-
|
|
291
|
-
});
|
|
352
|
+
}, { timeoutMs: 10_000 });
|
|
292
353
|
if (!res.ok) {
|
|
293
354
|
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일 가능성.
|
|
294
355
|
// 이 구분이 서버 collect_status로 올라가 앱이 "재로그인 필요"를 안내할 수 있다.
|
|
@@ -305,28 +366,41 @@ async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredent
|
|
|
305
366
|
});
|
|
306
367
|
if (pr.ok) account = accountHash((await pr.json()).account?.uuid);
|
|
307
368
|
} catch {}
|
|
308
|
-
|
|
309
|
-
|
|
369
|
+
// 200인데 수치가 비어 있으면 창을 버린다. 0%로 대신 채우면 그건 서버의 빈 창 가드를
|
|
370
|
+
// 통과하는 "정상 값"이라, 다른 기기가 올린 멀쩡한 게이지를 0으로 덮어버린다.
|
|
371
|
+
const win = (w, mins) => {
|
|
372
|
+
const percent = percentValue(w?.utilization);
|
|
373
|
+
return percent === null ? null : { percent, resets_at: w.resets_at ?? null, window_minutes: mins };
|
|
374
|
+
};
|
|
310
375
|
// limits 배열의 weekly_scoped 항목 = 모델별 주간 한도 (예: "Fable only")
|
|
311
|
-
const extras = (d.limits ?? [])
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
376
|
+
const extras = (d.limits ?? []).flatMap((l) => {
|
|
377
|
+
const name = l?.kind === "weekly_scoped" ? l.scope?.model?.display_name : null;
|
|
378
|
+
const percent = percentValue(l?.percent);
|
|
379
|
+
if (!name || percent === null) return [];
|
|
380
|
+
return [{
|
|
381
|
+
name,
|
|
315
382
|
window: {
|
|
316
|
-
percent
|
|
383
|
+
percent,
|
|
317
384
|
resets_at: l.resets_at ?? null,
|
|
318
385
|
window_minutes: 10080,
|
|
319
|
-
label: `${
|
|
386
|
+
label: `${name} weekly`,
|
|
320
387
|
},
|
|
321
|
-
}
|
|
388
|
+
}];
|
|
389
|
+
});
|
|
390
|
+
const session = dropExpired(win(d.five_hour, 300));
|
|
391
|
+
const weekly = dropExpired(win(d.seven_day, 10080));
|
|
392
|
+
// codexProvider와 같은 규칙: 올릴 창이 하나도 없으면 프로바이더 자체를 올리지 않는다.
|
|
393
|
+
// percentValue 도입 후 이 경로가 실제로 생긴다 (200 응답인데 utilization이 비어 있는 경우).
|
|
394
|
+
// 전부 null인 행을 신선한 스탬프로 올리면 서버에서 빈 카드가 이긴다.
|
|
395
|
+
if (!session && !weekly && !extras.length) return { provider: null, status: "stale" };
|
|
322
396
|
return {
|
|
323
397
|
provider: {
|
|
324
398
|
id: "claude",
|
|
325
399
|
name: "Claude",
|
|
326
400
|
plan,
|
|
327
401
|
account,
|
|
328
|
-
session
|
|
329
|
-
weekly
|
|
402
|
+
session,
|
|
403
|
+
weekly,
|
|
330
404
|
extras: extras.length ? extras : null,
|
|
331
405
|
collected_at: new Date().toISOString(), // usage API 성공 시각
|
|
332
406
|
},
|
|
@@ -367,29 +441,33 @@ function codexAuth() {
|
|
|
367
441
|
// Codex 실시간 조회: Codex CLI 자신이 60초마다 폴링하는 것과 같은 엔드포인트를
|
|
368
442
|
// 로컬 OAuth 토큰으로 호출한다. 토큰 갱신은 하지 않는다 — 만료됐으면 null을 반환하고
|
|
369
443
|
// 스냅샷 폴백에 맡긴다 (CLI가 다음 실행 때 알아서 갱신해 둔다).
|
|
370
|
-
|
|
444
|
+
// fetchFn은 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
445
|
+
async function codexLiveWindows(auth, { fetchFn = fetch } = {}) {
|
|
371
446
|
if (!auth?.accessToken || !auth?.accountId) return null;
|
|
372
447
|
try {
|
|
373
|
-
|
|
448
|
+
// signal은 넘기지 않는다 — fetchOnceRetried가 시도마다 새 10초 예산을 만들어 붙인다
|
|
449
|
+
const res = await fetchOnceRetried(fetchFn, "https://chatgpt.com/backend-api/wham/usage", {
|
|
374
450
|
headers: {
|
|
375
451
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
376
452
|
"chatgpt-account-id": auth.accountId,
|
|
377
453
|
"User-Agent": "charge-connect",
|
|
378
454
|
},
|
|
379
|
-
|
|
380
|
-
});
|
|
455
|
+
}, { timeoutMs: 10_000 });
|
|
381
456
|
if (!res.ok) return null;
|
|
382
457
|
const d = await res.json();
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
458
|
+
// 수치가 비면 0%가 아니라 창 없음 — 0%는 서버의 빈 창 가드를 통과해 게이지를 덮는다
|
|
459
|
+
const win = (w) => {
|
|
460
|
+
const percent = percentValue(w?.used_percent);
|
|
461
|
+
return percent === null
|
|
462
|
+
? null
|
|
463
|
+
: {
|
|
464
|
+
percent,
|
|
387
465
|
resets_at: w.reset_at ? new Date(w.reset_at * 1000).toISOString() : null,
|
|
388
466
|
window_minutes: Number.isFinite(Number(w.limit_window_seconds))
|
|
389
467
|
? Math.round(Number(w.limit_window_seconds) / 60)
|
|
390
468
|
: null,
|
|
391
|
-
}
|
|
392
|
-
|
|
469
|
+
};
|
|
470
|
+
};
|
|
393
471
|
const session = win(d.rate_limit?.primary_window);
|
|
394
472
|
const weekly = win(d.rate_limit?.secondary_window);
|
|
395
473
|
if (!session && !weekly) return null;
|
|
@@ -400,23 +478,36 @@ async function codexLiveWindows(auth) {
|
|
|
400
478
|
}
|
|
401
479
|
}
|
|
402
480
|
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
481
|
+
// 최신 .jsonl에 rate_limits가 없는 경우가 흔하다(막 연 세션, 한도 응답 전에 끝난 세션).
|
|
482
|
+
// 그렇다고 전부 훑으면 세션 로그가 수천 개인 사용자에서 5분 주기를 넘기므로 최근 몇 개만 본다.
|
|
483
|
+
// 5개는 너무 빡빡했다 — 짧은 세션이 연달아 다섯 번이면 여섯 번째의 멀쩡한 스냅샷을 놓치고
|
|
484
|
+
// "관측 실패(error)"로 보고해 앱에 경고가 뜬다. 파일당 읽기는 개별 try로 감싸져 있다.
|
|
485
|
+
const SNAPSHOT_SCAN_FILES = 20;
|
|
486
|
+
// 파일당 읽는 꼬리 크기 — 마지막 rate_limits는 파일 끝에 있다 (전체를 읽으면 수십 MB를 파싱하게 된다)
|
|
487
|
+
const SNAPSHOT_TAIL_BYTES = 2 * 1024 * 1024;
|
|
488
|
+
|
|
489
|
+
// Codex 스냅샷 폴백: 최근 세션 로그(.jsonl)에 기록된 마지막 rate_limits —
|
|
490
|
+
// 마지막으로 Codex를 실제 사용한 시점의 값이라 실시간 조회가 실패했을 때만 쓴다.
|
|
491
|
+
// 반환: null = 볼 스냅샷이 아예 없음(미설치·최근 로그에 rate_limits 없음),
|
|
492
|
+
// { session, weekly, collected_at } = 스냅샷은 있었음. 만료 창은 여기서 이미 걸러지므로
|
|
493
|
+
// 둘 다 null일 수 있고, 그건 "관측은 했는데 리셋이 다 지났다"는 뜻이다(호출자가 stale로 판정).
|
|
494
|
+
// dir은 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
495
|
+
async function codexSnapshotWindows(dir = path.join(HOME, ".codex", "sessions")) {
|
|
407
496
|
if (!fs.existsSync(dir)) return null; // Codex 미설치
|
|
408
|
-
|
|
497
|
+
const files = [];
|
|
409
498
|
(function walk(d) {
|
|
410
499
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
411
500
|
const p = path.join(d, e.name);
|
|
412
501
|
if (e.isDirectory()) walk(p);
|
|
413
502
|
else if (e.name.endsWith(".jsonl")) {
|
|
414
|
-
|
|
415
|
-
|
|
503
|
+
try {
|
|
504
|
+
files.push({ p, m: fs.statSync(p).mtimeMs });
|
|
505
|
+
} catch {}
|
|
416
506
|
}
|
|
417
507
|
}
|
|
418
508
|
})(dir);
|
|
419
|
-
if (!
|
|
509
|
+
if (!files.length) return null;
|
|
510
|
+
files.sort((a, b) => b.m - a.m);
|
|
420
511
|
|
|
421
512
|
const findRL = (o) => {
|
|
422
513
|
if (!o || typeof o !== "object") return null;
|
|
@@ -427,45 +518,96 @@ function codexSnapshotWindows() {
|
|
|
427
518
|
}
|
|
428
519
|
return null;
|
|
429
520
|
};
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
521
|
+
// 세션 로그는 수십 MB까지 자란다(실측 최대 43MB). 통째로 동기 읽기를 하면 그동안 이벤트 루프가
|
|
522
|
+
// 멈춰 진행 중인 fetch의 AbortSignal 타이머가 전부 터진다 — 이 파일 맨 위에서 금지한 바로 그 패턴이다.
|
|
523
|
+
// 그래서 비동기로, 그것도 꼬리 SNAPSHOT_TAIL_BYTES만 읽는다. 우리가 찾는 건 "마지막" rate_limits라
|
|
524
|
+
// 꼬리에 없으면 그 파일의 값은 어차피 낡은 것이고, 더 최근 파일이 우선순위를 갖는다.
|
|
525
|
+
const lastRateLimits = async (file) => {
|
|
526
|
+
let handle = null;
|
|
433
527
|
try {
|
|
434
|
-
|
|
435
|
-
|
|
528
|
+
handle = await fs.promises.open(file, "r");
|
|
529
|
+
const { size } = await handle.stat();
|
|
530
|
+
const start = Math.max(0, size - SNAPSHOT_TAIL_BYTES);
|
|
531
|
+
const buf = Buffer.alloc(Math.min(size, SNAPSHOT_TAIL_BYTES));
|
|
532
|
+
await handle.read(buf, 0, buf.length, start);
|
|
533
|
+
let rl = null;
|
|
534
|
+
for (const line of buf.toString("utf8").split("\n")) {
|
|
535
|
+
if (!line.includes('"rate_limits"')) continue;
|
|
536
|
+
try {
|
|
537
|
+
// 꼬리를 자른 지점의 첫 줄은 조각날 수 있다 — 파싱 실패는 그냥 건너뛴다
|
|
538
|
+
rl = findRL(JSON.parse(line)) ?? rl;
|
|
539
|
+
} catch {}
|
|
540
|
+
}
|
|
541
|
+
return rl;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
} finally {
|
|
545
|
+
await handle?.close().catch(() => {});
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
let found = null;
|
|
549
|
+
for (const f of files.slice(0, SNAPSHOT_SCAN_FILES)) {
|
|
550
|
+
const rl = await lastRateLimits(f.p);
|
|
551
|
+
if (rl) {
|
|
552
|
+
found = { rl, m: f.m };
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
436
555
|
}
|
|
437
|
-
if (!
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
556
|
+
if (!found) return null;
|
|
557
|
+
// 수치가 비면 0%가 아니라 창 없음 — 0%는 서버의 빈 창 가드를 통과해 게이지를 덮는다
|
|
558
|
+
const win = (w) => {
|
|
559
|
+
const percent = percentValue(w?.used_percent);
|
|
560
|
+
return percent === null
|
|
561
|
+
? null
|
|
562
|
+
: {
|
|
563
|
+
percent,
|
|
442
564
|
resets_at: w.resets_at ? new Date(w.resets_at * 1000).toISOString() : null,
|
|
443
565
|
window_minutes: w.window_minutes ?? null,
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
// 만료 판정을 여기서 끝낸다 — 예전엔 dropExpired가 codexProvider에서야 돌아, 리셋이 다 지난
|
|
569
|
+
// 3일 묵은 스냅샷도 "창이 있다"고 통과한 뒤 그 창이 뒤늦게 전부 null이 됐다.
|
|
570
|
+
const session = dropExpired(win(found.rl.primary));
|
|
571
|
+
const weekly = dropExpired(win(found.rl.secondary));
|
|
572
|
+
// collected_at = 그 스냅샷이 들어 있던 파일의 mtime — 스냅샷은 "지금"이 아니라 마지막
|
|
573
|
+
// 사용 시점의 값이므로, 수집 시각을 넣으면 다른 기기의 진짜 최신 값을 신선도 규칙에서 이긴다.
|
|
574
|
+
return { session, weekly, collected_at: new Date(found.m).toISOString() };
|
|
449
575
|
}
|
|
450
576
|
|
|
451
577
|
// 반환: { provider, status } — live 성공 "ok", 스냅샷 폴백 "stale",
|
|
452
|
-
// ~/.codex는 있는데
|
|
453
|
-
|
|
454
|
-
|
|
578
|
+
// ~/.codex는 있는데 아무것도 관측 못 하면 "error", 미설치면 status null(항목 없음).
|
|
579
|
+
// loadAuth/liveWindows/snapshotWindows/hasCodex는 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
580
|
+
async function codexProvider({
|
|
581
|
+
loadAuth = codexAuth,
|
|
582
|
+
liveWindows = codexLiveWindows,
|
|
583
|
+
snapshotWindows = codexSnapshotWindows,
|
|
584
|
+
hasCodex = () => fs.existsSync(path.join(HOME, ".codex")),
|
|
585
|
+
} = {}) {
|
|
586
|
+
if (!hasCodex()) return { provider: null, status: null }; // Codex 미설치
|
|
455
587
|
try {
|
|
456
|
-
const auth =
|
|
457
|
-
const live = await
|
|
458
|
-
const snapshot = live ? null :
|
|
588
|
+
const auth = loadAuth();
|
|
589
|
+
const live = await liveWindows(auth);
|
|
590
|
+
const snapshot = live ? null : await snapshotWindows();
|
|
459
591
|
const windows = live ?? snapshot;
|
|
460
|
-
if (!windows) return { provider: null, status: "error" };
|
|
592
|
+
if (!windows) return { provider: null, status: "error" }; // 실시간·스냅샷 둘 다 관측 실패
|
|
593
|
+
const session = dropExpired(windows.session);
|
|
594
|
+
const weekly = dropExpired(windows.weekly);
|
|
595
|
+
// 리셋이 다 지나 남는 창이 하나도 없으면 프로바이더 자체를 올리지 않는다. 전부 null인 행을
|
|
596
|
+
// 올리면 앱엔 빈 카드가 뜨고, 서버 행의 collected_at이 스냅샷 mtime(며칠 전)으로 되감겨
|
|
597
|
+
// 다른 기기의 묵은 업로드까지 신선도 가드를 통과한다. 대신 캐시 폴백이 마지막 값을 유지한다.
|
|
598
|
+
// 상태는 "error"가 아니다 — 수집은 정상이고 데이터가 낡았을 뿐인데 error로 보고하면
|
|
599
|
+
// 앱이 "재로그인/수집 실패" 경고를 띄운다(앱은 error·auth_expired만 경고로 친다).
|
|
600
|
+
// live 조회에 성공했더라도 여기까지 왔다면 올릴 창이 없으니 "ok"가 아니라 "stale"이다 —
|
|
601
|
+
// "ok"는 이번 사이클에 쓸 수 있는 값을 올렸다는 뜻인데, 실제로 올라가는 건 캐시 폴백뿐이다.
|
|
602
|
+
if (!session && !weekly) return { provider: null, status: "stale" };
|
|
461
603
|
return {
|
|
462
604
|
provider: {
|
|
463
605
|
id: "codex",
|
|
464
606
|
name: "Codex",
|
|
465
607
|
plan: windows.plan ?? auth?.plan ?? null,
|
|
466
608
|
account: auth?.account ?? null,
|
|
467
|
-
session
|
|
468
|
-
weekly
|
|
609
|
+
session,
|
|
610
|
+
weekly,
|
|
469
611
|
extras: null,
|
|
470
612
|
// live면 지금이 관측 시각, 스냅샷 폴백이면 .jsonl mtime이 실제 관측 시각
|
|
471
613
|
collected_at: live ? new Date().toISOString() : snapshot.collected_at,
|
|
@@ -663,6 +805,53 @@ async function collectProviders() {
|
|
|
663
805
|
};
|
|
664
806
|
}
|
|
665
807
|
|
|
808
|
+
// 서버는 (user_id, id, account) 한 행을 업서트하므로, 같은 키를 한 페이로드에 두 번 실으면
|
|
809
|
+
// 한 문장 안에서 같은 행을 두 번 건드려 어느 쪽이 남을지가 배열 순서에 달린다.
|
|
810
|
+
// 더 신선한 쪽만 남긴다 — collected_at이 없으면 나이 미상이라 스탬프가 있는 쪽에 진다.
|
|
811
|
+
function dedupeProviders(providers) {
|
|
812
|
+
const stamp = (p) => {
|
|
813
|
+
const t = Date.parse(p?.collected_at ?? "");
|
|
814
|
+
return Number.isFinite(t) ? t : -Infinity;
|
|
815
|
+
};
|
|
816
|
+
const byKey = new Map();
|
|
817
|
+
for (const p of providers) {
|
|
818
|
+
// 구분자는 NUL 이스케이프 — id·account 어디에도 못 들어가는 문자여야 키가 안 겹친다
|
|
819
|
+
const key = `${p.id}\u0000${p.account ?? ""}`;
|
|
820
|
+
const prev = byKey.get(key);
|
|
821
|
+
// 동률(둘 다 미상 포함)이면 뒤엣것 — 이번 사이클 수집분이 캐시 복원분보다 뒤에 온다
|
|
822
|
+
if (!prev || stamp(p) >= stamp(prev)) byKey.set(key, p);
|
|
823
|
+
}
|
|
824
|
+
return [...byKey.values()];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 이번 사이클 수집 결과에 캐시 복원분을 얹고 업로드 계약(키 유일성)에 맞춰 정리한다.
|
|
828
|
+
function mergeCachedProviders({ providers, cached = [], codexBarComplete = false, statuses = null }) {
|
|
829
|
+
const merged = [...providers];
|
|
830
|
+
// statuses가 null이면 수집이 통째로 죽어 아무것도 판정할 수 없는 경우 — 미상 그대로 둔다
|
|
831
|
+
const nextStatuses = statuses ? { ...statuses } : statuses;
|
|
832
|
+
for (const prev of cached) {
|
|
833
|
+
if (merged.some((p) => p.id === prev.id)) continue;
|
|
834
|
+
// CodexBar가 정상 완료된 경우, 거기서 더 이상 반환하지 않는 항목은 사용자가 끈 것으로 본다.
|
|
835
|
+
if (codexBarComplete && prev.collector_source === "codexbar") continue;
|
|
836
|
+
const sanitized = sanitizeCachedProvider(prev);
|
|
837
|
+
if (!sanitized) continue;
|
|
838
|
+
// 0.1.4 이전 캐시엔 collected_at이 없다 — 없는 채로 보낸다. 서버는 스탬프 없는 업로드를
|
|
839
|
+
// "최신"이 아니라 "나이 미상"으로 보고 기존 행이 충분히 묵었을 때만 받아준다.
|
|
840
|
+
// (예전엔 epoch로 찍어 지게 만들었는데, 그 값이 앱까지 새어 "56년 전"으로 보였다.)
|
|
841
|
+
merged.push(sanitized);
|
|
842
|
+
// CodexBar CLI가 통째로 실패/미검출/타임아웃이면 statuses가 비어 있어 아무도 실패로
|
|
843
|
+
// 기록되지 않는다 — 판정이 없는 복원 항목만 'stale'로 채운다.
|
|
844
|
+
// 정상 수집이 남긴 판정(auth_expired/error 등)은 덮지 않는다.
|
|
845
|
+
if (nextStatuses && !nextStatuses[sanitized.id]) nextStatuses[sanitized.id] = "stale";
|
|
846
|
+
}
|
|
847
|
+
// 계정 식별 실패 시 마지막으로 알려진 계정 해시 유지 (계정 미상('')과 실제 계정 행이 갈라지는 것 방지)
|
|
848
|
+
for (const p of merged) {
|
|
849
|
+
if (!p.account) p.account = cached.find((c) => c.id === p.id)?.account ?? null;
|
|
850
|
+
}
|
|
851
|
+
// dedupe는 account 보정 뒤에 — 보정으로 비로소 같은 키가 되는 경우가 있다
|
|
852
|
+
return { providers: dedupeProviders(merged), statuses: nextStatuses };
|
|
853
|
+
}
|
|
854
|
+
|
|
666
855
|
// 업로드 설정 — 우선순위: CHARGE_* 환경변수(테스트용) → ~/.charge/config.json(페어링)
|
|
667
856
|
function resolveMode() {
|
|
668
857
|
if (process.env.CHARGE_TOKEN && process.env.CHARGE_URL && process.env.CHARGE_ANON) {
|
|
@@ -677,9 +866,10 @@ function resolveMode() {
|
|
|
677
866
|
}
|
|
678
867
|
|
|
679
868
|
// 디바이스 토큰으로 charge_upload RPC 호출 (서버가 본인 행에만 기록)
|
|
680
|
-
|
|
869
|
+
// fetchFn은 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
870
|
+
async function pairedUpload(mode, daily, live, providers, collectStatus = null, { fetchFn = fetch } = {}) {
|
|
681
871
|
const post = (body) =>
|
|
682
|
-
|
|
872
|
+
fetchFn(`${mode.url}/rest/v1/rpc/charge_upload`, {
|
|
683
873
|
method: "POST",
|
|
684
874
|
headers: {
|
|
685
875
|
apikey: mode.anon,
|
|
@@ -700,7 +890,13 @@ async function pairedUpload(mode, daily, live, providers, collectStatus = null)
|
|
|
700
890
|
console.error("charge_upload 구버전 서버 감지(404/PGRST202) — p_collect_status 없이 재시도합니다");
|
|
701
891
|
const { p_collect_status: _omitted, ...legacyBody } = body;
|
|
702
892
|
res = await post(legacyBody);
|
|
703
|
-
if (res.ok)
|
|
893
|
+
if (res.ok) {
|
|
894
|
+
// 이 경로로 올라간 사이클은 collect_status가 서버에 반영되지 않는다(파라미터 자체가 없다).
|
|
895
|
+
// 상태는 매 사이클 통째로 다시 보내므로 서버가 갱신되면 다음 5분에 저절로 복구된다 —
|
|
896
|
+
// 다만 "왜 상태가 비었나"를 로그 없이는 알 수 없어 흔적을 남긴다.
|
|
897
|
+
console.error("구버전 시그니처로 업로드 성공 — collect_status는 이번 사이클 미반영(다음 사이클에 복구)");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
704
900
|
throw new Error(`업로드 실패 (${res.status}): ${await res.text()}`);
|
|
705
901
|
}
|
|
706
902
|
throw new Error(`업로드 실패 (${res.status}): ${text}`);
|
|
@@ -732,6 +928,8 @@ async function main() {
|
|
|
732
928
|
live = liveR.value;
|
|
733
929
|
} else {
|
|
734
930
|
console.error(`live 수집 실패, 이전 값 유지: ${liveR.reason?.message ?? liveR.reason}`);
|
|
931
|
+
// 캐시 블록은 collected_at을 갱신하지 않고 통째로 재사용한다 — 갱신하면 수집이 깨진
|
|
932
|
+
// 기기의 옛 블록이 다른 기기의 진짜 활성 블록을 서버 신선도 규칙에서 이겨버린다.
|
|
735
933
|
live = cache.live ?? null;
|
|
736
934
|
}
|
|
737
935
|
let providerCollection;
|
|
@@ -742,30 +940,17 @@ async function main() {
|
|
|
742
940
|
// 수집이 통째로 죽으면 상태를 알 수 없다 — null로 보내 정직한 미상 처리 (서버도 null로 덮음)
|
|
743
941
|
providerCollection = { providers: [], codexBarComplete: false, statuses: null };
|
|
744
942
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const sanitized = sanitizeCachedProvider(prev);
|
|
754
|
-
if (sanitized) {
|
|
755
|
-
// 0.1.4 이전 캐시엔 collected_at이 없다. 그대로 보내면 서버가 now()로 취급해
|
|
756
|
-
// 나이 미상의 캐시가 건강한 기기의 진짜 최신 데이터를 이겨버린다 — epoch로 스탬프해
|
|
757
|
-
// "언제 것인지 모르는 데이터는 항상 진다"로 만든다 (행 유지에는 지장 없음).
|
|
758
|
-
if (!sanitized.collected_at) sanitized.collected_at = new Date(0).toISOString();
|
|
759
|
-
providers.push(sanitized);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
// 계정 식별 실패 시 마지막으로 알려진 계정 해시 유지 (계정 미상('')과 실제 계정 행이 갈라지는 것 방지)
|
|
763
|
-
for (const p of providers) {
|
|
764
|
-
if (!p.account) p.account = (cache.providers ?? []).find((c) => c.id === p.id)?.account ?? null;
|
|
765
|
-
}
|
|
943
|
+
// 일부 프로바이더만 실패해도 목록에서 사라지지 않게 이전 값으로 채운다 (앱 설정 토글 유지)
|
|
944
|
+
let statuses;
|
|
945
|
+
({ providers, statuses } = mergeCachedProviders({
|
|
946
|
+
providers: providerCollection.providers,
|
|
947
|
+
cached: cache.providers ?? [],
|
|
948
|
+
codexBarComplete: providerCollection.codexBarComplete,
|
|
949
|
+
statuses: providerCollection.statuses,
|
|
950
|
+
}));
|
|
766
951
|
|
|
767
952
|
if (DRY_RUN) {
|
|
768
|
-
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers, collect_status:
|
|
953
|
+
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers, collect_status: statuses }, null, 2));
|
|
769
954
|
console.log(`\n[dry-run] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 예정`);
|
|
770
955
|
return;
|
|
771
956
|
}
|
|
@@ -777,7 +962,7 @@ async function main() {
|
|
|
777
962
|
}
|
|
778
963
|
|
|
779
964
|
const now = new Date().toISOString();
|
|
780
|
-
await pairedUpload(mode, daily, live, providers,
|
|
965
|
+
await pairedUpload(mode, daily, live, providers, statuses);
|
|
781
966
|
|
|
782
967
|
fs.writeFileSync(CACHE_FILE, JSON.stringify({ daily, live, providers }));
|
|
783
968
|
console.log(`[${now}] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 완료`);
|
|
@@ -795,12 +980,22 @@ module.exports = {
|
|
|
795
980
|
accountHash,
|
|
796
981
|
claudeProvider,
|
|
797
982
|
codexBarEntryToProvider,
|
|
983
|
+
codexLiveWindows,
|
|
984
|
+
codexProvider,
|
|
985
|
+
codexSnapshotWindows,
|
|
798
986
|
collectCodexBarProviders,
|
|
987
|
+
collectLive,
|
|
988
|
+
dedupeProviders,
|
|
799
989
|
dropExpired,
|
|
800
990
|
durationLabel,
|
|
991
|
+
fetchOnceRetried,
|
|
992
|
+
freshestCredentials,
|
|
993
|
+
mergeCachedProviders,
|
|
801
994
|
normalizeRateWindow,
|
|
802
995
|
normalizeResetAt,
|
|
996
|
+
pairedUpload,
|
|
803
997
|
parseCodexBarJSON,
|
|
998
|
+
percentValue,
|
|
804
999
|
resolveMode,
|
|
805
1000
|
runAsync,
|
|
806
1001
|
sanitizeCachedProvider,
|