From 9780ec6a7f4286b4b5a87432720a3d00f7fa3c73 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Sun, 16 Aug 2026 14:02:14 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20TRAE=20Work?= =?UTF-8?q?=20CN=20Token=20=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.de.md | 15 +- README.ja.md | 13 +- README.ko.md | 13 +- README.md | 17 +- README.zh-CN.md | 13 +- .../tokentracker-account-devices.ts | 2 +- .../tokentracker-leaderboard-profile.ts | 2 +- dashboard/index.html | 22 +- dashboard/public/llms.txt | 5 +- dashboard/src/lib/provider-display.js | 1 + dashboard/src/lib/provider-display.test.js | 7 + .../ui/dashboard/components/ProviderIcon.jsx | 21 + .../components/ProviderIcon.test.jsx | 14 + dashboard/src/ui/marketing/agent-logos.js | 1 + docs/PRIVACY.md | 7 +- package.json | 2 +- scripts/ops/account-usage-grouped-rpc.sql | 2 +- scripts/ops/leaderboard-usage-grouped-rpc.sql | 2 +- src/commands/init.js | 3 +- src/commands/sync.js | 69 +++ src/lib/rollout.js | 304 ++++++++++ src/lib/source-metadata.js | 2 +- src/lib/trae-cn-config.js | 433 +++++++++++++ test/discovery-metadata.test.js | 36 +- test/source-metadata.test.js | 54 ++ test/sync-trae-cn.test.js | 260 ++++++++ test/trae-cn-config.test.js | 570 ++++++++++++++++++ test/trae-cn-parser.test.js | 504 ++++++++++++++++ 28 files changed, 2324 insertions(+), 70 deletions(-) create mode 100644 src/lib/trae-cn-config.js create mode 100644 test/source-metadata.test.js create mode 100644 test/sync-trae-cn.test.js create mode 100644 test/trae-cn-config.test.js create mode 100644 test/trae-cn-parser.test.js diff --git a/README.de.md b/README.de.md index d889aa706..0d7a76c32 100644 --- a/README.de.md +++ b/README.de.md @@ -6,7 +6,7 @@ ### Sieh genau, was du für KI ausgibst – über jedes CLI hinweg -Sammle automatisch Token-Zahlen von **31 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein Cloud-Konto, keine API-Keys, kein Setup – nur ein Befehl. +Sammle automatisch Token-Zahlen von **32 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein TokenTracker-Konto, keine API-Keys, kein Setup – nur ein Befehl. [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -51,7 +51,7 @@ Das war's. Beim ersten Start werden Hooks installiert, deine Daten synchronisier - 📊 Ein lokales Dashboard auf `localhost:7680` mit Nutzungstrends, Modellaufschlüsselung, Kostenanalyse - 🔌 Auto-erkannte Hooks für jedes installierte KI-Tool -- 🏠 100 % lokal – kein Konto, keine API-Keys, keine Netzwerkaufrufe (außer optionalem Leaderboard) +- 🏠 Local-first – kein TokenTracker-Konto und keine API-Keys; optionale Netzwerkanfragen umfassen das Leaderboard und die schreibgeschützte TRAE Work CN-Nutzungsabfrage - 🧩 *Optional:* Ein Skills-Tab zum Durchsuchen von 250+ öffentlichen Skills – synchronisiert über Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes > **Möchtest du eine native Desktop-App?** @@ -88,8 +88,8 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De ## ✨ Features -- 🔌 **31 KI-Tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness -- 🏠 **100 % lokal** — Token-Daten verlassen nie deinen Rechner. Kein Konto, keine API-Keys. +- 🔌 **32 KI-Tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN +- 🏠 **Local-first** — Token-Daten bleiben auf deinem Rechner, außer bei der schreibgeschützten TRAE Work CN-Nutzungsabfrage. Kein TokenTracker-Konto, keine API-Keys; die Abfrage verwendet die Sitzung der lokal angemeldeten App und liest nur Nutzungsmetadaten. - 🚀 **Zero Config** — Hooks installieren sich beim ersten Start automatisch. Von null zum Dashboard in 30 Sekunden. - 📊 **Schönes Dashboard** — Nutzungstrends, Kostenaufschlüsselung nach Modell, GitHub-ähnliche Aktivitäts-Heatmap, Projektzuordnung - 🖥️ **Native Desktop-App** — macOS Menüleiste (+ Widgets) und Windows System Tray, jeweils mit eingebautem Server und Dashboard in einer nativen WebView @@ -194,6 +194,7 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De | **Qoder** | ✅ Auto | Passiver SQLite-Reader (`Qoder/SharedClientCache/cache/db/local.db`; liest nur assistant-`token_info`, trennt Cache-Eingaben und liest keine Prompts oder Antworten) sowie Plan Credits und Ultimate-Gratisaufrufe aus der lokalen Qoder-Sitzung | | **AnythingLLM Desktop** | ✅ Auto | Passiver SQLite-Reader (`anythingllm-desktop/storage/anythingllm.db`, nur Token-Metriken pro Nachricht) | | **Claude Science** | ✅ Auto | Passiver SQLite-Reader (`~/.claude-science/operon-cli.db`, nur die Token-Zähler der `frames`-Tabelle; keine Prompts, Artefakte oder Forschungsinhalte). Kein natives Windows-Build — unter Windows läuft die App in WSL und wird von dort gelesen. | +| **TRAE Work CN** | ✅ Auto | Liest unter macOS die Session-Token-Nutzung der lokal angemeldeten App; die interne API kann sich ändern | > **Muss ich Plugins oder Hooks manuell installieren?** Nein. `tokentracker` (oder `tokentracker init`) erledigt alles beim ersten Start: > - **Hook-basiert** (Claude Code, Codex, Gemini, Every Code, CodeBuddy, WorkBuddy, Grok Build) — wir schreiben einen SessionEnd-Hook oder TOML-Notify-Eintrag in die Konfiguration des Tools. @@ -210,11 +211,11 @@ Fehlt dein Tool? [Erstelle ein Issue](https://github.com/xiufengsun/TokenTracker ## 🆚 Warum TokenTracker? -> **Suchst du eine ccusage-Alternative mit GUI?** TokenTracker unterstützt 31 Tools (nicht nur Claude Code), bietet native macOS- und Windows-Apps + Desktop-Widgets und dedupliziert Token-Datensätze korrekt über alle Provider hinweg – damit deine Zahlen mit dem Billing der Provider übereinstimmen. +> **Suchst du eine ccusage-Alternative mit GUI?** TokenTracker unterstützt 32 Tools (nicht nur Claude Code), bietet native macOS- und Windows-Apps + Desktop-Widgets und dedupliziert Token-Datensätze korrekt über alle Provider hinweg – damit deine Zahlen mit dem Billing der Provider übereinstimmen. | | **TokenTracker** | ccusage | Cursor Stats | |---|---|---|---| -| **Unterstützte KI-Tools** | **31** | 1 (Claude) | 1 (Cursor) | +| **Unterstützte KI-Tools** | **32** | 1 (Claude) | 1 (Cursor) | | **Lokal, kein Konto** | ✅ | ✅ | ❌ | | **Native Desktop-App** | ✅ macOS + Windows | ❌ | ❌ | | **Desktop-Widgets** | ✅ 4 Widgets | ❌ | ❌ | @@ -229,7 +230,7 @@ Fehlt dein Tool? [Erstelle ein Issue](https://github.com/xiufengsun/TokenTracker ```mermaid flowchart LR - A["KI-Coding-Tools
Claude · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"] + A["KI-Coding-Tools
Claude · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"] A -->|Hooks lösen aus| B[Token Tracker] B -->|Logs parsen
30-Min-UTC-Buckets| C[(Lokales SQLite)] C --> D[Web-Dashboard] diff --git a/README.ja.md b/README.ja.md index 6b9481e2b..8bd89b2b8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -6,7 +6,7 @@ ### AI に使ったコストを正確に把握 — すべての CLI を横断して -**31 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。クラウドアカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。 +**32 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。TokenTracker アカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。 [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -50,7 +50,7 @@ npx tokentracker-cli **30 秒で手に入るもの:** - 📊 `localhost:7680` のローカルダッシュボードで、使用トレンド、モデル別内訳、コスト分析が見える - 🔌 インストール済みの対応 AI ツールすべてに対する hook を自動検出 -- 🏠 100% ローカル — アカウント不要、API キー不要、ネットワーク通信なし(オプションのリーダーボードを除く) +- 🏠 ローカルファースト — TokenTracker アカウント不要、API キー不要。オプションのネットワーク通信にはリーダーボードと TRAE Work CN の読み取り専用の使用量リクエストがあります - 🧩 *オプション:* 250+ の公開 Skill を閲覧して Claude · Codex · Gemini · OpenCode · Hermes 間で同期できる Skills タブ > **ネイティブのデスクトップアプリが欲しい?** @@ -88,8 +88,8 @@ brew install xiufengsun/tokentracker/tokentracker ## ✨ 機能 -- 🔌 **31 種類の AI ツールを標準対応** — Claude Code、Codex CLI、Cursor、Gemini CLI、Antigravity、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness -- 🏠 **100% ローカル** — トークンデータがマシンから外に出ることはありません。アカウント不要、API キー不要。 +- 🔌 **32 種類の AI ツールを標準対応** — Claude Code、Codex CLI、Cursor、Gemini CLI、Antigravity、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN +- 🏠 **ローカルファースト** — TRAE Work CN の読み取り専用の使用量リクエストを除き、トークンデータはマシンから外に出ません。TokenTracker アカウント不要、API キー不要。このリクエストはローカルでサインイン済みのアプリセッションを使い、使用量メタデータのみを読み取ります。 - 🚀 **ゼロコンフィグ** — Hook は初回実行で自動インストール。0 からダッシュボードまで 30 秒。 - 📊 **美しいダッシュボード** — 使用トレンド、モデル別コスト内訳、GitHub スタイルのアクティビティヒートマップ、プロジェクト別の帰属表示 - 🖥️ **ネイティブデスクトップアプリ** — macOS メニューバー(ウィジェット付き)と Windows システムトレイ。それぞれ組み込みサーバーとネイティブ WebView のダッシュボードを備えます @@ -195,6 +195,7 @@ brew install xiufengsun/tokentracker/tokentracker | **Qoder** | ✅ 自動 | パッシブ SQLite リーダー (`Qoder/SharedClientCache/cache/db/local.db`; assistant の `token_info` のみを読み、キャッシュ入力を分離。prompt/response は読みません) と、Qoder ローカルセッションからの Plan Credits / Ultimate 無料呼び出し上限 | | **AnythingLLM Desktop** | ✅ 自動 | パッシブな SQLite リーダー (`anythingllm-desktop/storage/anythingllm.db`、メッセージごとの token 指標のみ) | | **Claude Science** | ✅ 自動 | パッシブな SQLite リーダー (`~/.claude-science/operon-cli.db`、`frames` テーブルの token カウンタのみ。prompt・成果物・研究内容は読みません)。ネイティブ Windows 版はなく、Windows では WSL 内で動作するアプリを読み取ります。 | +| **TRAE Work CN** | ✅ 自動 | macOS のローカルサインイン済みアプリから session-token 使用量を読み取ります。内部 API は変更される可能性があります | > **プラグインや hook を手動でインストールする必要はありますか?** いいえ。`tokentracker`(または `tokentracker init`)が初回実行ですべて処理します: > - **Hook ベース**のツール (Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**) — ツール自身の設定に SessionEnd hook または TOML notify エントリーを書き込みます。 @@ -214,7 +215,7 @@ brew install xiufengsun/tokentracker/tokentracker | | **TokenTracker** | ccusage | Cursor stats | |--------------------------|:---:|:---:|:---:| -| **対応 AI ツール数** | **31** | 1 (Claude) | 1 (Cursor) | +| **対応 AI ツール数** | **32** | 1 (Claude) | 1 (Cursor) | | **ローカルファースト、アカウント不要** | ✅ | ✅ | ❌ | | **ネイティブデスクトップアプリ** | ✅ macOS + Windows | ❌ | ❌ | | **デスクトップウィジェット** | ✅ 4 種類 | ❌ | ❌ | @@ -226,7 +227,7 @@ brew install xiufengsun/tokentracker/tokentracker ```mermaid flowchart LR - A["AI coding tools
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"] + A["AI coding tools
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"] A -->|hooks trigger| B[Token Tracker] B -->|parse logs
30-min UTC buckets| C[(Local SQLite)] C --> D[Web Dashboard] diff --git a/README.ko.md b/README.ko.md index 394c9c4f0..d7406d361 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ ### 모든 CLI에서 AI에 쓰는 비용을 정확히 파악 -**31개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. 클라우드 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝. +**32개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. TokenTracker 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝. [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -50,7 +50,7 @@ npx tokentracker-cli **30초 안에 얻는 것:** - 📊 사용 추세, 모델별 분석, 비용 분석을 보여주는 `localhost:7680`의 로컬 대시보드 - 🔌 설치된 모든 지원 AI 도구에 대한 hook 자동 감지 -- 🏠 100% 로컬 — 계정 없음, API Key 없음, 네트워크 호출 없음 (옵션 리더보드 제외) +- 🏠 로컬 우선 — TokenTracker 계정과 API Key가 필요 없습니다. 옵션 네트워크 요청에는 리더보드와 TRAE Work CN의 읽기 전용 사용량 요청이 포함됩니다 - 🧩 *옵션:* 250+개의 공개 Skill을 둘러보고 Claude · Codex · Gemini · OpenCode · Hermes 간에 동기화할 수 있는 Skills 탭 > **네이티브 데스크톱 앱이 필요하다면?** @@ -88,8 +88,8 @@ brew install xiufengsun/tokentracker/tokentracker ## ✨ 기능 -- 🔌 **31개의 AI 도구 기본 지원** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness -- 🏠 **100% 로컬** — 토큰 데이터가 기기를 떠나지 않습니다. 계정 없음, API Key 없음. +- 🔌 **32개의 AI 도구 기본 지원** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN +- 🏠 **로컬 우선** — TRAE Work CN의 읽기 전용 사용량 요청을 제외하면 토큰 데이터가 기기를 떠나지 않습니다. TokenTracker 계정과 API Key가 필요 없으며, 이 요청은 로컬 로그인 앱 세션을 사용해 사용량 메타데이터만 읽습니다. - 🚀 **제로 설정** — 첫 실행 시 Hook 자동 설치. 0에서 대시보드까지 30초. - 📊 **아름다운 대시보드** — 사용 추세, 모델별 비용 분석, GitHub 스타일 활동 히트맵, 프로젝트 귀속 정보 - 🖥️ **네이티브 데스크톱 앱** — macOS 메뉴바(위젯 포함)와 Windows 시스템 트레이. 각각 임베디드 서버와 네이티브 WebView 대시보드를 제공합니다 @@ -195,6 +195,7 @@ brew install xiufengsun/tokentracker/tokentracker | **Qoder** | ✅ 자동 | 패시브 SQLite 리더 (`Qoder/SharedClientCache/cache/db/local.db`; assistant `token_info`만 읽고 캐시 입력을 분리하며 prompt/response는 읽지 않음) 및 Qoder 로컬 세션의 Plan Credits / Ultimate 무료 호출 한도 | | **AnythingLLM Desktop** | ✅ 자동 | 패시브 SQLite 리더 (`anythingllm-desktop/storage/anythingllm.db`, 메시지별 token 지표만 읽음) | | **Claude Science** | ✅ 자동 | 패시브 SQLite 리더 (`~/.claude-science/operon-cli.db`, `frames` 테이블의 token 카운터만 읽으며 prompt·산출물·연구 내용은 읽지 않음). 네이티브 Windows 빌드가 없어 Windows에서는 WSL 안에서 실행되는 앱을 읽습니다. | +| **TRAE Work CN** | ✅ 자동 | macOS에서 로컬 로그인된 앱의 session-token 사용량을 읽습니다. 내부 API는 변경될 수 있습니다 | > **플러그인이나 hook을 수동으로 설치해야 하나요?** 아니요. `tokentracker` (또는 `tokentracker init`)가 첫 실행에서 모든 것을 처리합니다: > - **Hook 기반** 도구 (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — 도구 자체의 설정에 SessionEnd hook 또는 TOML notify 엔트리를 작성합니다. @@ -214,7 +215,7 @@ brew install xiufengsun/tokentracker/tokentracker | | **TokenTracker** | ccusage | Cursor stats | |--------------------------|:---:|:---:|:---:| -| **지원하는 AI 도구 수** | **31** | 1 (Claude) | 1 (Cursor) | +| **지원하는 AI 도구 수** | **32** | 1 (Claude) | 1 (Cursor) | | **로컬 우선, 계정 불필요** | ✅ | ✅ | ❌ | | **네이티브 데스크톱 앱** | ✅ macOS + Windows | ❌ | ❌ | | **데스크톱 위젯** | ✅ 4종 | ❌ | ❌ | @@ -226,7 +227,7 @@ brew install xiufengsun/tokentracker/tokentracker ```mermaid flowchart LR - A["AI coding tools
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"] + A["AI coding tools
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"] A -->|hooks trigger| B[Token Tracker] B -->|parse logs
30-min UTC buckets| C[(Local SQLite)] C --> D[Web Dashboard] diff --git a/README.md b/README.md index b3cd67cad..21a6dce28 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ ### Track every AI token — then bring your usage to life -An accurate, local-first token usage and cost dashboard for **31 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No cloud account, no API keys, no setup. +An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No TokenTracker account, no API keys, no setup. + +> **TRAE Work CN note:** On macOS, TokenTracker reads real session-token usage from the locally signed-in TRAE Work CN app. Its internal API may change. [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -85,7 +87,7 @@ That's it. First run installs hooks, syncs your data, and opens the dashboard at **What you get in 30 seconds:** - 📊 A local dashboard at `localhost:7680` with usage trends, model breakdown, cost analysis - 🔌 Auto-detected hooks for every supported AI tool you have installed -- 🏠 100% local — no account, no API keys, no network calls (except optional leaderboard) +- 🏠 Local-first — no TokenTracker account or API keys; optional network requests include the leaderboard and the read-only TRAE Work CN usage request - 🧩 *Optional:* a Skills tab that browses 250+ public skills and syncs them across Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes > **Want a native desktop app?** @@ -138,8 +140,8 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag ## ✨ Features -- 🔌 **31 AI tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness -- 🏠 **100% local** — Token data never leaves your machine. No account, no API keys. +- 🔌 **32 AI tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN +- 🏠 **Local-first** — Token data stays on your machine except for the read-only TRAE Work CN usage request. No TokenTracker account, no API keys; the request uses the locally signed-in app session and reads usage metadata only. - 🚀 **Zero config** — Hooks auto-install on first run. From zero to dashboard in 30 seconds. - 📊 **Beautiful dashboard** — Usage trends, cost breakdowns by model, GitHub-style activity heatmap, project attribution - 🖥️ **Native desktop app** — macOS menu bar (+ widgets) and Windows system tray, each with an embedded server and the dashboard in a native webview @@ -258,6 +260,7 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag | **AnythingLLM Desktop** | ✅ Auto | Passive SQLite reader (`anythingllm-desktop/storage/anythingllm.db`; reads per-message token metrics only, never prompts or responses) | | **Claude Science** | ✅ Auto | Passive SQLite reader (`~/.claude-science/operon-cli.db`; reads the `frames` table's token counters only, never prompts, artifacts or research content). No native Windows build — on Windows the app runs inside WSL and is read from there. | | **DeepSeek Harness** | ✅ Auto | Passive session reader (`~/.dsh/sessions/**/session.jsonl[.zstd]`; parses session header and assistant events, handles multi-frame zstd decompression) | +| **TRAE Work CN** | ✅ Auto | macOS session-token usage from the locally signed-in app; reads its internal API, which may change | > **Do I need to install any plugin or hook manually?** No. `tokentracker` (or `tokentracker init`) handles everything on first run: > - **Hook-based** tools (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — we write a SessionEnd hook or TOML notify entry into the tool's own config. @@ -276,11 +279,11 @@ Missing your tool? [Open an issue](https://github.com/xiufengsun/TokenTracker/is ## 🆚 Why TokenTracker? -> **Looking for a ccusage alternative with a GUI?** TokenTracker covers 31 tools (not just Claude Code), adds native macOS and Windows apps + desktop widgets, and de-duplicates token records correctly across providers — so your numbers match the providers' own billing. +> **Looking for a ccusage alternative with a GUI?** TokenTracker covers 32 tools (not just Claude Code), adds native macOS and Windows apps + desktop widgets, and de-duplicates token records correctly across providers — so your numbers match the providers' own billing. | | **TokenTracker** | ccusage | Cursor stats | |--------------------------|:---:|:---:|:---:| -| **AI tools supported** | **31** | 1 (Claude) | 1 (Cursor) | +| **AI tools supported** | **32** | 1 (Claude) | 1 (Cursor) | | **Local-first, no account** | ✅ | ✅ | ❌ | | **Native desktop app** | ✅ macOS + Windows | ❌ | ❌ | | **Desktop widgets** | ✅ 4 widgets | ❌ | ❌ | @@ -295,7 +298,7 @@ Missing your tool? [Open an issue](https://github.com/xiufengsun/TokenTracker/is ```mermaid flowchart LR - A["AI coding tools
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi Code · CodeBuddy · WorkBuddy · Grok Build · Kilo CLI · Kilo Code
Antigravity · oh-my-pi · pi · Craft · Roo · Zed · Goose · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science"] + A["AI coding tools
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi Code · CodeBuddy · WorkBuddy · Grok Build · Kilo CLI · Kilo Code
Antigravity · oh-my-pi · pi · Craft · Roo · Zed · Goose · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"] A -->|hooks trigger| B[Token Tracker] B -->|parse logs
30-min UTC buckets| C[(Local SQLite)] C --> D[Web Dashboard] diff --git a/README.zh-CN.md b/README.zh-CN.md index 657b89f81..b849b5ce6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ ### 跨所有 CLI,看清你到底在 AI 上花了多少钱 -自动采集 **31 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要云账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。 +自动采集 **32 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要 TokenTracker 账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。 [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -50,7 +50,7 @@ npx tokentracker-cli **30 秒能看到什么:** - 📊 本地 Dashboard(`localhost:7680`)—— 用量趋势、模型明细、成本分析 - 🔌 自动识别并挂接你电脑上所有已安装的 AI 工具 -- 🏠 100% 本地运行 —— 无账号、无 API Key、无网络请求(排行榜是可选的) +- 🏠 本地优先运行 —— 无需 TokenTracker 账号或 API Key;可选网络请求包括排行榜和 TRAE Work CN 的只读用量请求 - 🧩 *可选:* Skills 面板——浏览 250+ 公开 skill,并在 Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes 之间同步 > **想要原生桌面 App?** @@ -90,8 +90,8 @@ brew install xiufengsun/tokentracker/tokentracker ## ✨ 特性 -- 🔌 **开箱即用支持 31 款 AI 工具** —— Claude Code、Codex CLI、Cursor、Gemini CLI、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Antigravity、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness -- 🏠 **100% 本地** —— Token 数据绝不离开你的机器。无账号、无 API Key +- 🔌 **开箱即用支持 32 款 AI 工具** —— Claude Code、Codex CLI、Cursor、Gemini CLI、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Antigravity、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN +- 🏠 **本地优先** —— Token 数据留在你的机器上,TRAE Work CN 的只读用量请求除外。无需 TokenTracker 账号或 API Key;该请求使用本地已登录应用的现有会话授权,仅读取用量元数据。 - 🚀 **零配置** —— 首次运行自动安装所有 hook。30 秒从零到 Dashboard - 📊 **漂亮的 Dashboard** —— 用量趋势、按模型的成本分解、GitHub 风格活跃度热力图、按项目归因 - 🖥️ **原生桌面 App** —— macOS 菜单栏(含桌面小组件)与 Windows 系统托盘,各自内嵌服务 + 原生 WebView Dashboard @@ -199,6 +199,7 @@ brew install xiufengsun/tokentracker/tokentracker | **AnythingLLM Desktop** | ✅ 自动 | 被动 SQLite 读取(`anythingllm-desktop/storage/anythingllm.db`;只读取每条消息的 token 指标,不读取 prompt 或回复) | | **Claude Science** | ✅ 自动 | 被动 SQLite 读取(`~/.claude-science/operon-cli.db`;只读取 `frames` 表的 token 计数,不读取 prompt、产物或研究内容)。没有原生 Windows 版——Windows 上该应用运行在 WSL 内,从 WSL 中读取。 | | **DeepSeek Harness** | ✅ 自动 | 被动读取会话日志(`~/.dsh/sessions/**/session.jsonl[.zstd]`;解析会话头部与 assistant 事件,支持多帧 zstd 解压) | +| **TRAE Work CN** | ✅ 自动 | macOS 上读取本地登录的 TRAE Work CN 应用的 session-token 用量;内部 API 可能变化 | > **需要手动装什么插件 / hook 吗?** 不需要。`tokentracker`(或 `tokentracker init`)第一次跑的时候会全部搞定: > - **基于 hook 的工具**(Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**)—— 我们把 SessionEnd hook 或 TOML notify 条目写入它们自己的配置文件 @@ -219,7 +220,7 @@ brew install xiufengsun/tokentracker/tokentracker | | **TokenTracker** | ccusage | Cursor 自带统计 | |--------------------------|:---:|:---:|:---:| -| **支持的 AI 工具数** | **31** | 1(Claude) | 1(Cursor) | +| **支持的 AI 工具数** | **32** | 1(Claude) | 1(Cursor) | | **本地优先,无需账号** | ✅ | ✅ | ❌ | | **原生桌面 App** | ✅ macOS + Windows | ❌ | ❌ | | **桌面小组件** | ✅ 4 个小组件 | ❌ | ❌ | @@ -231,7 +232,7 @@ brew install xiufengsun/tokentracker/tokentracker ```mermaid flowchart LR - A["AI 编码工具
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"] + A["AI 编码工具
Claude Code · Codex · Cursor · Gemini · Kiro
OpenCode · OpenClaw · Every Code · Hermes · Copilot
Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose
Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"] A -->|hook 触发| B[Token Tracker] B -->|解析日志
30 分钟 UTC 桶| C[(本地 SQLite)] C --> D[Web Dashboard] diff --git a/dashboard/edge-patches/tokentracker-account-devices.ts b/dashboard/edge-patches/tokentracker-account-devices.ts index 9e7178104..b68a579fb 100644 --- a/dashboard/edge-patches/tokentracker-account-devices.ts +++ b/dashboard/edge-patches/tokentracker-account-devices.ts @@ -85,7 +85,7 @@ interface GroupedRow { // branch intentionally ignores p_device_ids, so a per-device query would add // the user's ENTIRE account-level total to EVERY device (the "N identical // devices" skew). A device breakdown must therefore exclude them. -const ACCOUNT_LEVEL_SOURCES = new Set(["cursor"]); +const ACCOUNT_LEVEL_SOURCES = new Set(["cursor", "trae-cn"]); async function sumDeviceTokens( client: ReturnType, diff --git a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts b/dashboard/edge-patches/tokentracker-leaderboard-profile.ts index 1fd8d9a1a..fa0a0804c 100644 --- a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts +++ b/dashboard/edge-patches/tokentracker-leaderboard-profile.ts @@ -418,7 +418,7 @@ function canonicalSource(s: string) { // RPC, and tokentracker-leaderboard-refresh.ts // (parity: test/account-source-parity.test.js). // deno-lint-ignore no-unused-vars -const ACCOUNT_LEVEL_SOURCES = new Set(["cursor"]); +const ACCOUNT_LEVEL_SOURCES = new Set(["cursor", "trae-cn"]); // ─────────────────────────── Window bounds ────────────────────────── function normalizeTimeZone(value: string | null): string | null { diff --git a/dashboard/index.html b/dashboard/index.html index 3dc01370b..946f6c9ec 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -123,7 +123,7 @@ "price": "0", "priceCurrency": "USD" }, - "description": "Local-first AI coding usage tracker for 31 tools, with a desktop pet, four widgets, achievements, native macOS and Windows apps, accurate cross-provider deduplication, and no prompt collection." + "description": "Local-first AI coding usage tracker for 32 tools, with a desktop pet, four widgets, achievements, native macOS and Windows apps, accurate cross-provider deduplication, and no prompt collection." }, { "@type": "WebSite", @@ -194,7 +194,8 @@ { "@type": "ListItem", "position": 28, "name": "Qoder" }, { "@type": "ListItem", "position": 29, "name": "AnythingLLM Desktop" }, { "@type": "ListItem", "position": 30, "name": "Claude Science" }, - { "@type": "ListItem", "position": 31, "name": "DeepSeek Harness" } + { "@type": "ListItem", "position": 31, "name": "DeepSeek Harness" }, + { "@type": "ListItem", "position": 32, "name": "TRAE Work CN" } ] }, { @@ -212,7 +213,7 @@ "@type": "Quest "name": "Which AI coding CLIs does Token Tracker support?", "acceptedAnswer": { "@type": "Answer", - "text": "Token Tracker supports 31 AI coding tools, including Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, and DeepSeek Harness." + "text": "Token Tracker supports 32 AI coding tools, including Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, and TRAE Work CN." } }, { @@ -244,7 +245,7 @@ "name": "How do I monitor AI token usage and cost across tools?", "acceptedAnswer": { "@type": "Answer", - "text": "Install Token Tracker with npx tokentracker-cli. It is an AI token usage and cost tracker that monitors token consumption and spend across all 31 supported tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking." + "text": "Install Token Tracker with npx tokentracker-cli. It is an AI token usage and cost tracker that monitors token consumption and spend across all 32 supported tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking." } }, { @@ -266,7 +267,7 @@

Token Tracker: monitor AI token usage and cost with a local dashboard and desktop companion

Token Tracker is a free, open-source, local-first dashboard that automatically tracks and monitors token usage - and cost across 31 AI coding tools — including Claude Code, OpenAI Codex, Cursor, Gemini CLI, and DeepSeek Harness. A desktop pet + and cost across 32 AI coding tools — including Claude Code, OpenAI Codex, Cursor, Gemini CLI, and TRAE Work CN. A desktop pet reacts to real token activity, four desktop widgets keep usage visible, and achievements turn coding history into progress. Native macOS and Windows apps sit on top of accurate cross-provider deduplication. Token counts only — never prompts or conversations. @@ -291,7 +292,7 @@

What you get after installing

  • Desktop pet — a pixel companion that reacts to real token burn, streaks, and rest.
  • Four desktop widgets — usage, activity heatmap, top models, and limits at a glance.
  • Achievements — unlock milestones from local and optional account-wide activity.
  • -
  • Unified usage dashboard — input, output, cached, and cache-creation tokens across 31 AI coding tools.
  • +
  • Unified usage dashboard — input, output, cached, and cache-creation tokens across 32 AI coding tools.
  • Cost analysis — per-model pricing across 2,200+ models with an offline snapshot.
  • Usage limits view — live quota and subscription status for 13 providers, with last-good caching for local providers such as Antigravity and Qoder.
  • Service Status page — live operational and incident status from eight official provider status pages.
  • @@ -302,11 +303,11 @@

    What you get after installing

    Privacy by design: token counts only, never prompts or conversation content. Local-first; cloud sync is optional and powers only the public leaderboard.

    -

    Supported AI coding tools (31)

    +

    Supported AI coding tools (32)

    Use Token Tracker as a Claude Code token tracker, Codex token tracker, Cursor token tracker, Gemini token tracker, OpenCode token tracker, GitHub Copilot token tracker, or DeepSeek Harness token tracker — one dashboard - covers all 31 tools below. + covers all 32 tools below.

    • Claude Code — Anthropic's official coding agent CLI; integrated via SessionEnd hook.
    • @@ -340,6 +341,7 @@

      Supported AI coding tools (31)

    • AnythingLLM Desktop — passive per-message token metrics reader.
    • Claude Science — passive reader for the frames table of operon-cli.db.
    • DeepSeek Harness — passive session reader (~/.dsh/sessions/**/session.jsonl[.zstd]).
    • +
    • TRAE Work CN — macOS signed-in app session-token reader; its internal API may change.

    Who should use Token Tracker

    @@ -370,7 +372,7 @@

    How do I install Token Tracker?

    Run npx tokentracker-cli. The CLI auto-detects your AI coding tools, installs hooks, and opens the dashboard.

    Which AI coding tools does Token Tracker support?

    -

    Token Tracker supports 31 tools: Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, and DeepSeek Harness.

    +

    Token Tracker supports 32 tools: Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, and TRAE Work CN.

    Is Token Tracker free and open source?

    Yes. Source on GitHub at github.com/xiufengsun/TokenTracker; npm package tokentracker-cli.

    @@ -382,7 +384,7 @@

    What does Token Tracker track?

    Input, output, cached, and cache-creation tokens per model, project, and time window. It also surfaces usage limits for 13 providers, including Claude, Codex, Cursor, Gemini, Antigravity, Qoder, and Qoder CN.

    How do I monitor AI token usage and cost across tools?

    -

    Token Tracker is an AI token usage and cost tracker: install it with npx tokentracker-cli, and it monitors token consumption and spend across all 31 tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking.

    00+ models so you can see cost by model, project, and time window without manual tracking.

    +

    Token Tracker is an AI token usage and cost tracker: install it with npx tokentracker-cli, and it monitors token consumption and spend across all 32 tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking.

    Does Token Tracker work offline?

    Yes. Token Tracker is local-first. All parsing, aggregation, and dashboard rendering happen on your machine. Cloud sync is optional and powers only the public leaderboard.

    diff --git a/dashboard/public/llms.txt b/dashboard/public/llms.txt index fb13873e2..b7d28abc0 100644 --- a/dashboard/public/llms.txt +++ b/dashboard/public/llms.txt @@ -1,7 +1,7 @@ # Token Tracker LLMs Index Token Tracker is a free, open-source, local-first AI coding usage and cost -tracker for 31 tools. Its distinctive desktop experience includes a pixel pet +tracker for 32 tools. Its distinctive desktop experience includes a pixel pet powered by real token activity, four desktop widgets, and achievements. The same data powers a cross-platform CLI, a native macOS menu bar app, a native Windows system tray app, and a dashboard. It records token counts and @@ -23,7 +23,7 @@ timestamps, never prompts, responses, or source code. - [npm package](https://www.npmjs.com/package/tokentracker-cli) - [Latest release](https://github.com/xiufengsun/TokenTracker/releases/latest) -## Supported AI coding tools (31) +## Supported AI coding tools (32) - Claude Code - Codex CLI - Cursor @@ -55,6 +55,7 @@ timestamps, never prompts, responses, or source code. - AnythingLLM Desktop - Claude Science - DeepSeek Harness +- TRAE Work CN ## Capabilities - Desktop pet that reacts to real token burn, streaks, and rest diff --git a/dashboard/src/lib/provider-display.js b/dashboard/src/lib/provider-display.js index c80e75286..c1b985f29 100644 --- a/dashboard/src/lib/provider-display.js +++ b/dashboard/src/lib/provider-display.js @@ -6,6 +6,7 @@ const SPECIAL_PROVIDER_NAMES = { pianthropic: "Pi · Anthropic", pigithubcopilot: "Pi · GitHub Copilot", picopilot: "Pi · Copilot", + traecn: "TRAE Work CN", }; const SPECIAL_PROVIDER_COPY_KEYS = { diff --git a/dashboard/src/lib/provider-display.test.js b/dashboard/src/lib/provider-display.test.js index c83ba5d09..611a82e84 100644 --- a/dashboard/src/lib/provider-display.test.js +++ b/dashboard/src/lib/provider-display.test.js @@ -29,4 +29,11 @@ describe("formatProviderDisplayName", () => { expect(formatProviderDisplayName("dsh")).toBe("DeepSeek Harness"); expect(formatProviderDisplayName("deepseek")).toBe("DeepSeek Harness"); }); + + it.each(["trae-cn", "TRAE-CN", "Trae_Cn"])( + "formats %s as TRAE Work CN", + (value) => { + expect(formatProviderDisplayName(value)).toBe("TRAE Work CN"); + }, + ); }); diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx index 5c8986ae5..f419d1bc4 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx @@ -117,6 +117,26 @@ function CodeBuddyIcon({ size = 16, className = "" }) { ); } +// Compact local monogram for TRAE Work CN. It is deliberately not a claimed +// official asset: the single-color mark stays legible in the existing 16–20px +// slots and inherits currentColor in both themes. +function TraeCnIcon({ size = 16, className = "" }) { + return ( + + ); +} + function HermesIcon({ size = 16, className = "" }) { return ( @@ -313,6 +333,7 @@ const PROVIDER_ICON_MAP = { "PI-GITHUB-COPILOT": PiIcon, "PI-COPILOT": PiIcon, ROOCODE: RoocodeIcon, + "TRAE-CN": TraeCnIcon, ZED: ZedIcon, }; diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx index 22adc68ab..f8e4dc2a1 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx @@ -77,6 +77,20 @@ describe("ProviderIcon", () => { expect(icon?.querySelector('path[fill="currentColor"]')).not.toBeNull(); } }); + + it("renders the compact TRAE CN mark instead of the unknown-provider placeholder", () => { + const { container } = render(); + const icon = container.querySelector('svg[data-brand="trae-cn"]'); + + expect(icon).not.toBeNull(); + expect(icon).toHaveAttribute("width", "20"); + expect(icon).toHaveAttribute("height", "20"); + expect(icon).toHaveAttribute("aria-hidden", "true"); + expect(icon).toHaveAttribute("fill", "currentColor"); + expect(icon?.querySelector("path")).not.toBeNull(); + expect(icon?.querySelector("circle")).toBeNull(); + expect(container.querySelector(".text-oai-gray-400")).toBeNull(); + }); }); it("renders the Qoder CN green-crescent mark from its own asset", () => { diff --git a/dashboard/src/ui/marketing/agent-logos.js b/dashboard/src/ui/marketing/agent-logos.js index 3ddaf597f..5481d3e7b 100644 --- a/dashboard/src/ui/marketing/agent-logos.js +++ b/dashboard/src/ui/marketing/agent-logos.js @@ -33,4 +33,5 @@ export const AGENT_LOGOS = [ { id: 29, name: "AnythingLLM", provider: "anythingllm" }, { id: 30, name: "Claude Science", provider: "claude-science" }, { id: 31, name: "DeepSeek Harness", provider: "dsh" }, + { id: 32, name: "TRAE Work CN", provider: "trae-cn" }, ]; diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 01a372298..2f5bf6ac5 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -10,14 +10,14 @@ This document lists **every** network request the software can make, what each o ## 1. What never leaves your machine -TokenTracker's parsers extract numbers and timestamps only. +TokenTracker's parsers extract numbers and timestamps only. TRAE Work CN is a narrow exception to the local-only source model: during a usage sync, TokenTracker transmits the existing sign-in authorization from the locally signed-in app to TRAE's internal API for a read-only usage request. That authorization is never persisted or logged. -**Never read, never recorded anywhere:** +**Apart from the disclosed TRAE Work CN authorization, never read or recorded anywhere:** - **Prompts, responses, and conversation bodies** - **File contents** from your projects - **Commit messages and diffs** — Git attribution runs `git log` locally, uses the subject line only to detect reverts, and keeps nothing -- **API keys, cookies, and session tokens** belonging to your AI providers +- **API keys, cookies, and session tokens** belonging to your AI providers are never persisted or logged by TokenTracker **Recorded locally, never uploaded:** @@ -54,6 +54,7 @@ To erase everything TokenTracker knows about you, delete that directory. `tokent | **Anonymous heartbeat** | `srctyff5.us-east.insforge.app` | A one-way hash of the machine id, app version, OS platform, and shell (`cli` / `mac` / `win`). Nothing else. | At most once per day | | **Dashboard analytics** | `us.i.posthog.com` (PostHog) | Pageviews and explicitly instrumented feature events, plus which shell you use. Autocapture and session recording are **off**; browser Do-Not-Track is respected. | While the dashboard is open | | **Provider quota reads** | The provider's own API (`api.anthropic.com`, `chatgpt.com`, `cursor.com`, `api.github.com`, `api.kimi.com`, `api.z.ai`, `qoder.com`, `qoder.com.cn`, `openapi.qoder.sh`, `openapi.qoder.com.cn`, `cloudcode-pa.googleapis.com`, …) | Whatever that provider's own endpoint requires, authenticated with the credentials **that provider already stored on your machine**. These requests go directly from your machine to the provider — they never pass through our servers, and we never see the response. | While quota bars are visible | +| **TRAE Work CN usage read** | TRAE's internal API | Transmits the existing sign-in authorization from the locally signed-in TRAE Work CN app to TRAE; reads usage metadata only. TokenTracker never persists or logs the auth token or prompt/response content. | During TRAE Work CN usage sync | | **GitHub star count** | `api.github.com` | Nothing but the request itself (public repo metadata) | On dashboard load | | **Update check** | `api.github.com` | Nothing but the request itself | Windows: once at launch. macOS: only when you click "Check for Updates" | diff --git a/package.json b/package.json index d564ff1f9..5f00e9213 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tokentracker-cli", "version": "0.88.9", - "description": "Local-first AI coding token usage and cost tracker for 31 tools, with a dashboard, desktop pet, widgets, achievements, and native macOS/Windows apps.", + "description": "Local-first AI coding token usage and cost tracker for 32 tools, with a dashboard, desktop pet, widgets, achievements, and native macOS/Windows apps.", "main": "src/cli.js", "bin": { "tokentracker-cli": "bin/tracker.js", diff --git a/scripts/ops/account-usage-grouped-rpc.sql b/scripts/ops/account-usage-grouped-rpc.sql index 7cc741e97..97e07347a 100644 --- a/scripts/ops/account-usage-grouped-rpc.sql +++ b/scripts/ops/account-usage-grouped-rpc.sql @@ -90,7 +90,7 @@ AS $func$ ), -- Account-level source list — keep in sync with src/lib/source-metadata.js. cfg AS ( - SELECT ARRAY['cursor']::text[] AS account_sources + SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources ), -- Stage 1: canonicalize to the raw hour grain. hourly AS ( diff --git a/scripts/ops/leaderboard-usage-grouped-rpc.sql b/scripts/ops/leaderboard-usage-grouped-rpc.sql index d8589a67d..5762abd68 100644 --- a/scripts/ops/leaderboard-usage-grouped-rpc.sql +++ b/scripts/ops/leaderboard-usage-grouped-rpc.sql @@ -62,7 +62,7 @@ SET hash_mem_multiplier TO '4' AS $func$ WITH cfg AS ( -- Keep in sync with src/lib/source-metadata.js ACCOUNT_LEVEL_SOURCES. - SELECT ARRAY['cursor']::text[] AS account_sources + SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources ), -- Hour-grain canonical rows. TWO passes total (DISTINCT + the per_usm GROUP BY) -- so the whole-history 'total' refresh stays within the edge's 30s budget -- diff --git a/src/commands/init.js b/src/commands/init.js index 0abdcc7d1..28c41d1b6 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -102,7 +102,7 @@ const DIVIDER = "----------------------------------------------"; const DEFAULT_DASHBOARD_URL = "https://www.tokentracker.cc"; // Single source of truth for the welcome screen's provider count + sample list. -// test/discovery-metadata.test.js keeps this aligned with public 27-tool copy. +// test/discovery-metadata.test.js keeps this aligned with public 32-tool copy. const SUPPORTED_PROVIDERS = [ "Claude Code", "Codex CLI", @@ -135,6 +135,7 @@ const SUPPORTED_PROVIDERS = [ "AnythingLLM Desktop", "Claude Science", "DeepSeek Harness", + "TRAE Work CN", ]; async function cmdInit(argv) { diff --git a/src/commands/sync.js b/src/commands/sync.js index 8d067060d..3d8f5b07c 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -110,6 +110,7 @@ const { resolveDroidModel, resolveDshSessionFiles, parseDshIncremental, + parseTraeCnApiIncremental, bucketKey, toUtcHalfHourStart, totalsKey, @@ -132,6 +133,10 @@ const { parseCursorCsv, } = require("../lib/cursor-config"); const { purgeProjectUsage } = require("../lib/project-usage-purge"); +const { + fetchTraeCnUsageWithAuth, + resolveTraeCnStoragePath, +} = require("../lib/trae-cn-config"); const { isCodexSessionCursorPath, isCursorStoreRetry, @@ -289,6 +294,7 @@ const AUTO_SYNC_SOURCES = new Set([ "qoder-cn", "reasonix", "roocode", + "trae-cn", "workbuddy", "zcode", "zed", @@ -450,6 +456,15 @@ async function cmdSync(argv, context = {}) { const lockWaitOptions = context && typeof context === "object" ? context.lockWaitOptions : undefined; + // Narrow test-only seam for TRAE Work CN: deterministic injected fetch + a + // fixed "now" for the rolling range. Production keeps the global fetch and + // current time; no public CLI flag/config is introduced. + const traeCnFetchImpl = context && typeof context === "object" + ? context.traeCnFetchImpl + : undefined; + const traeCnNowMs = context && typeof context === "object" + ? context.traeCnNowMs + : undefined; const syncDiagnostics = diagnostics && typeof diagnostics === "object" ? diagnostics : null; const home = os.homedir(); const { trackerDir } = await resolveTrackerPaths({ home }); @@ -1661,6 +1676,58 @@ async function cmdSync(argv, context = {}) { } } + // ── Trae Work CN (国内版) — account-level usage API ── + // Phase C: a simple explicit rolling 30-day window captured once per sync + // (no cursor checkpoints / full-history import / adaptive splitting / page + // retries / background requests in this phase). Runs only for non-lightweight + // syncs where the source is allowed AND the CN storage path resolves — which + // excludes both ordinary background and `--auto --background + // --all-local-sources`. Fetching goes through the storage-backed Phase A + // helper so its single 401/403 reread/retry is used; an empty response is a + // successful no-op; any fetch/page/schema/auth failure skips the parser and + // leaves prior data untouched while unrelated providers continue. + let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; + if ( + !isBackgroundLightweightSync && + sourceAllowed("trae-cn") && + resolveTraeCnStoragePath({ env: process.env, home }) + ) { + const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now(); + const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch; + const endTime = Math.floor(nowMs / 1000); + const startTime = Math.max(1, endTime - 30 * 24 * 60 * 60); + try { + if (progress?.enabled) { + progress.start(`Fetching TRAE Work CN usage...`); + } + const traeCnUsage = await fetchTraeCnUsageWithAuth({ + start_time: startTime, + end_time: endTime, + fetchImpl, + env: process.env, + home, + }); + const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : []; + if (traeCnSessions.length > 0) { + if (progress?.enabled) { + progress.start( + `Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber( + traeCnSessions.length, + )} records | buckets 0`, + ); + } + traeCnResult = await parseTraeCnApiIncremental({ + sessions: traeCnSessions, + cursors, + queuePath, + onProgress: makeProviderProgress("TRAE Work CN"), + }); + } + } catch (err) { + warnProviderParseFailure("TRAE Work CN", err, opts); + } + } + // ── Kiro (SQLite-based, with JSONL fallback; dual-install aware) ── let kiroResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; if (sourceAllowed("kiro")) { @@ -2554,6 +2621,7 @@ async function cmdSync(argv, context = {}) { qoderCnResult.recordsProcessed + claudeScienceResult.recordsProcessed + cursorResult.recordsProcessed + + traeCnResult.recordsProcessed + kiroResult.recordsProcessed + kiroCliResult.recordsProcessed + hermesResult.recordsProcessed + @@ -2588,6 +2656,7 @@ async function cmdSync(argv, context = {}) { qoderCnResult.bucketsQueued + claudeScienceResult.bucketsQueued + cursorResult.bucketsQueued + + traeCnResult.bucketsQueued + kiroResult.bucketsQueued + kiroCliResult.bucketsQueued + hermesResult.bucketsQueued + diff --git a/src/lib/rollout.js b/src/lib/rollout.js index ef1f54c4e..32f126bb0 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16378,6 +16378,309 @@ function readTraeEntitlementFromStorage(storagePath) { }; } +// ───────────────────────────────────────────────────────────────────────────── +// Trae Work CN (国内版) — usage API incremental parser. +// +// TRAE Work CN's usage API reports per-session rows that are NOT append-only: +// a session can be re-reported with corrected token totals, a different model, +// or a shifted time bucket. This parser keeps the last normalized contribution +// per session_id under `cursors.traeCn` and reconciles by subtracting the +// previous contribution from its old bucket before adding the new one, so +// corrections retract the stale tuple instead of stacking. Only the normalized +// contribution is persisted — no raw API rows, cost/credits, auth, refresh +// tokens, or prompt previews. Source is always `trae-cn`. +const TRAE_CN_SOURCE = "trae-cn"; +const TRAE_CN_STATE_VERSION = 1; +const TRAE_CN_UNKNOWN_MODEL = "trae-cn-unknown"; + +function normalizeTraeCnState(raw) { + if (raw === undefined || raw === null) { + return { version: TRAE_CN_STATE_VERSION, sessions: {}, updatedAt: null }; + } + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Trae CN cursor state is malformed."); + } + if (raw.version !== TRAE_CN_STATE_VERSION) { + throw new Error(`Trae CN cursor state version ${raw.version} is not supported.`); + } + if (!raw.sessions || typeof raw.sessions !== "object" || Array.isArray(raw.sessions)) { + throw new Error("Trae CN cursor sessions are malformed."); + } + return { + version: TRAE_CN_STATE_VERSION, + sessions: { ...raw.sessions }, + updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, + }; +} + +function isValidTraeCnTotals(totals) { + if (!totals || typeof totals !== "object") return false; + for (const key of [ + "input_tokens", + "cached_input_tokens", + "cache_creation_input_tokens", + "output_tokens", + "reasoning_output_tokens", + "total_tokens", + "billable_total_tokens", + "conversation_count", + ]) { + if (!Number.isSafeInteger(totals[key]) || totals[key] < 0) return false; + } + return true; +} + +// Canonical UTC half-hour bucketStart produced by toUtcHalfHourStart: +// "YYYY-MM-DDTHH:00:00.000Z" or "YYYY-MM-DDTHH:30:00.000Z". +function isValidTraeCnBucketStart(value) { + if (typeof value !== "string") return false; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:(00|30):00\.000Z$/.test(value)) return false; + const dt = new Date(value); + return Number.isFinite(dt.getTime()) && dt.toISOString() === value; +} + +// Validate a stored prior contribution before it is ever subtracted, so a +// tampered or corrupt cursor fails closed instead of silently rewinding buckets. +// Enforces this provider's fixed canonical invariant (all totals safe +// nonnegative integers, reasoning=0, conversation_count=1, +// total=input+cached+cacheCreation+output, billable=total) plus a canonical +// half-hour UTC bucketStart. +function validateTraeCnStoredContribution(entry) { + if (!entry || typeof entry !== "object") { + throw new Error("Trae CN stored session contribution is malformed."); + } + if ( + typeof entry.model !== "string" || + !entry.model.trim() || + entry.model.includes("|") + ) { + throw new Error("Trae CN stored session model is malformed."); + } + if (!isValidTraeCnBucketStart(entry.bucketStart)) { + throw new Error("Trae CN stored session bucket is malformed."); + } + if (!isValidTraeCnTotals(entry.totals)) { + throw new Error("Trae CN stored session totals are malformed."); + } + const { totals } = entry; + if (totals.reasoning_output_tokens !== 0 || totals.conversation_count !== 1) { + throw new Error("Trae CN stored session totals are malformed."); + } + const sum = + totals.input_tokens + + totals.cached_input_tokens + + totals.cache_creation_input_tokens + + totals.output_tokens; + if (totals.total_tokens !== sum || totals.billable_total_tokens !== totals.total_tokens) { + throw new Error("Trae CN stored session totals are malformed."); + } +} + +// Token fields may live in `row.extra_info` (object or JSON string) or at the +// top level of the row. Missing values are NOT coerced to zero. +function traeCnExtraInfo(row) { + const extra = row?.extra_info; + if (extra && typeof extra === "object" && !Array.isArray(extra)) return extra; + if (typeof extra === "string") { + try { + const parsed = JSON.parse(extra); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch (_e) {} + } + return null; +} + +function traeCnTokenField(row, extra, key) { + if (extra && extra[key] !== undefined && extra[key] !== null) return extra[key]; + if (row && row[key] !== undefined && row[key] !== null) return row[key]; + return undefined; +} + +// Normalize one raw session row into the canonical contribution, or throw +// before any cursor/bucket mutation happens. Returns +// { sessionId, model, bucketStart, totals }. +function normalizeTraeCnSession(row) { + const sessionId = typeof row?.session_id === "string" ? row.session_id.trim() : ""; + if (!sessionId) { + throw new Error("Trae CN session row is missing session_id."); + } + let model; + if (row?.model_name === undefined || row?.model_name === null) { + model = TRAE_CN_UNKNOWN_MODEL; + } else if (typeof row.model_name === "string") { + model = row.model_name.trim() || TRAE_CN_UNKNOWN_MODEL; + } else { + throw new Error("Trae CN session row has an invalid model_name."); + } + if (model.includes("|")) { + throw new Error("Trae CN session row has an unsupported model name."); + } + if (!Number.isSafeInteger(row?.usage_time) || row.usage_time <= 0) { + throw new Error("Trae CN session row has an invalid usage_time."); + } + const bucketStart = toUtcHalfHourStart(row.usage_time * 1000); + if (!bucketStart) { + throw new Error("Trae CN session row has an invalid usage_time."); + } + const extra = traeCnExtraInfo(row); + const fields = [ + ["input_token", traeCnTokenField(row, extra, "input_token")], + ["output_token", traeCnTokenField(row, extra, "output_token")], + ["cache_read_token", traeCnTokenField(row, extra, "cache_read_token")], + ["cache_write_token", traeCnTokenField(row, extra, "cache_write_token")], + ]; + for (const [label, value] of fields) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Trae CN session row has an invalid ${label}.`); + } + } + const total = fields[0][1] + fields[1][1] + fields[2][1] + fields[3][1]; + if (!Number.isSafeInteger(total)) { + throw new Error("Trae CN session row token totals overflow."); + } + return { + sessionId, + model, + bucketStart, + totals: { + input_tokens: fields[0][1], + output_tokens: fields[1][1], + cached_input_tokens: fields[2][1], + cache_creation_input_tokens: fields[3][1], + reasoning_output_tokens: 0, + total_tokens: total, + billable_total_tokens: total, + conversation_count: 1, + }, + }; +} + +// Fail closed instead of letting subtractTotals clamp a stored prior +// contribution that exceeds its source bucket (a corruption signal). +function assertTraeCnBucketCovers(bucketTotals, previousTotals) { + for (const key of [ + "input_tokens", + "cached_input_tokens", + "cache_creation_input_tokens", + "output_tokens", + "reasoning_output_tokens", + "total_tokens", + "billable_total_tokens", + "conversation_count", + ]) { + if ((bucketTotals[key] || 0) < (previousTotals[key] || 0)) { + throw new Error("Trae CN state corruption: stored contribution exceeds its bucket totals."); + } + } +} + +async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgress } = {}) { + if (!Array.isArray(sessions)) { + throw new Error("Trae CN sessions must be an array."); + } + if (!cursors || typeof cursors !== "object" || Array.isArray(cursors)) { + throw new Error("Trae CN cursors must be a writable object."); + } + await ensureDir(path.dirname(queuePath)); + + // Validate persisted state up front (fresh absent state initializes version + // 1; malformed state / unexpected version fails closed instead of resetting). + // Deep-clone the normalized working states so reconciliation and enqueue + // mutations never touch `cursors` before the final assignment succeeds. + const hourlyState = structuredClone(normalizeHourlyState(cursors?.hourly)); + const traeCnState = structuredClone(normalizeTraeCnState(cursors?.traeCn)); + + if (sessions.length === 0) { + // Empty payload is a successful no-op: it never erases prior contributions. + return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; + } + + // Full prevalidation + in-payload dedupe BEFORE any cursor/bucket mutation. + const bySession = new Map(); + for (const row of sessions) { + const normalized = normalizeTraeCnSession(row); + const existing = bySession.get(normalized.sessionId); + if (existing) { + const identical = + existing.model === normalized.model && + existing.bucketStart === normalized.bucketStart && + totalsKey(existing.totals) === totalsKey(normalized.totals); + if (!identical) { + throw new Error("Trae CN session rows contain conflicting contributions."); + } + continue; // exact duplicate within one payload is accepted once + } + bySession.set(normalized.sessionId, normalized); + } + + const touchedBuckets = new Set(); + const cb = typeof onProgress === "function" ? onProgress : null; + const total = sessions.length; + let index = 0; + let eventsAggregated = 0; + + // Validate EVERY existing stored contribution up front — not just sessions + // present in the incoming payload — so an unrelated malformed entry fails + // closed before any reconciliation mutation. + for (const sessionId of Object.keys(traeCnState.sessions)) { + validateTraeCnStoredContribution(traeCnState.sessions[sessionId]); + } + + // Deterministic: process unique session ids in sorted order. + const sessionIds = [...bySession.keys()].sort(); + for (const sessionId of sessionIds) { + const current = bySession.get(sessionId); + const previousEntry = traeCnState.sessions[sessionId]; + + const unchanged = + previousEntry && + totalsKey(previousEntry.totals) === totalsKey(current.totals) && + previousEntry.bucketStart === current.bucketStart && + previousEntry.model === current.model; + if (unchanged) { + index += 1; + if (cb) cb({ index, total, eventsAggregated, bucketsQueued: touchedBuckets.size }); + continue; + } + + if (previousEntry) { + const oldBucket = getHourlyBucket( + hourlyState, + TRAE_CN_SOURCE, + previousEntry.model, + previousEntry.bucketStart, + ); + assertTraeCnBucketCovers(oldBucket.totals, previousEntry.totals); + subtractTotals(oldBucket.totals, previousEntry.totals); + touchedBuckets.add(bucketKey(TRAE_CN_SOURCE, previousEntry.model, previousEntry.bucketStart)); + } + + const bucket = getHourlyBucket(hourlyState, TRAE_CN_SOURCE, current.model, current.bucketStart); + addTotals(bucket.totals, current.totals); + touchedBuckets.add(bucketKey(TRAE_CN_SOURCE, current.model, current.bucketStart)); + + traeCnState.sessions[sessionId] = { + model: current.model, + bucketStart: current.bucketStart, + totals: { ...current.totals }, + updatedAt: new Date().toISOString(), + }; + eventsAggregated += 1; + index += 1; + if (cb) cb({ index, total, eventsAggregated, bucketsQueued: touchedBuckets.size }); + } + + const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }); + const updatedAt = new Date().toISOString(); + hourlyState.updatedAt = updatedAt; + traeCnState.updatedAt = updatedAt; + // Assign cursor state only after enqueue succeeds. + cursors.hourly = hourlyState; + cursors.traeCn = traeCnState; + + return { recordsProcessed: total, eventsAggregated, bucketsQueued }; +} + // ───────────────────────────────────────────────────────────────────────────── // DeepSeek Harness (dsh) — passive reader of the harness's session logs. // @@ -17186,6 +17489,7 @@ module.exports = { resolveTraePath, resolveTraeStoragePath, readTraeEntitlementFromStorage, + parseTraeCnApiIncremental, // DeepSeek Harness (dsh) — passive session-log reader resolveDshHome, resolveDshSessionFiles, diff --git a/src/lib/source-metadata.js b/src/lib/source-metadata.js index bc659b630..06d8bf182 100644 --- a/src/lib/source-metadata.js +++ b/src/lib/source-metadata.js @@ -1,4 +1,4 @@ -const ACCOUNT_LEVEL_SOURCES = new Set(["cursor"]); +const ACCOUNT_LEVEL_SOURCES = new Set(["cursor", "trae-cn"]); function normalizeSource(value) { return String(value || "").trim().toLowerCase(); diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js new file mode 100644 index 000000000..b7124f465 --- /dev/null +++ b/src/lib/trae-cn-config.js @@ -0,0 +1,433 @@ +"use strict"; + +/** + * TRAE Work CN (国内版) config + usage fetch. Phase A: macOS + test-injected + * paths only. Resolves the CN home, reads the `iCubeAuthInfo://icube.cloudide` + * blob from User/globalStorage/storage.json (plaintext JSON or Base64 tc v5), + * and talks to the TRAE CN usage API. JWTs / refresh tokens are never + * persisted, never logged, and never put into error messages; a + * present-but-unreadable blob fails closed with a generic error. + * + * tc v5 blob: [6-byte magic "tc\x05\x10\x00\x00"][32-byte salt][AES-128-CBC ct] + * kdf_buf = SHA-512(salt) || (JG xor KG) // 128 bytes + * kdf_out = SHA-512(kdf_buf) // 64 bytes + * key = kdf_out[0..16], iv = kdf_out[16..32] + * plaintext = [SHA-512(json)][json] // integrity prefix stripped + */ + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const TRAE_CN_HOME_ENV = "TOKENTRACKER_TRAE_CN_HOME"; +const TRAE_CN_APP_DIR = "TRAE SOLO CN"; +const TRAE_CN_AUTH_KEY = "iCubeAuthInfo://icube.cloudide"; + +const TRAE_CN_USAGE_URL = "https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session"; +const TRAE_CN_USAGE_TYPE = [7]; +const TRAE_CN_USAGE_PAGE_SIZE = 20; +const TRAE_CN_USAGE_MAX_PAGES = 100; +const TRAE_CN_USAGE_DELAY_MS = 300; +const TRAE_CN_USAGE_TIMEOUT_MS = 30 * 1000; + +const TRAE_CN_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00]); +const TRAE_CN_SALT_LEN = 32; +const TRAE_CN_HASH_LEN = 64; +const TRAE_CN_AES_KEY_LEN = 16; +const TRAE_CN_IV_LEN = 16; + +// JG / KG — the two halves of the hardcoded (obfuscated) password in the TRAE +// client's byteCrypto.js. Changing these breaks every existing blob. +const TRAE_CN_JG = Buffer.from([ + 82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, + 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, + 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, + 109, 139, 209, 37, +]); +const TRAE_CN_KG = Buffer.from([ + 31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, + 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, + 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, + 85, 33, 12, 125, +]); + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +function resolveTraeCnHome({ env = process.env, home = os.homedir(), platform = process.platform } = {}) { + const override = env[TRAE_CN_HOME_ENV]; + if (typeof override === "string" && override.trim()) { + return path.resolve(override.trim()); + } + if (platform === "darwin") { + return path.join(home, "Library", "Application Support", TRAE_CN_APP_DIR); + } + // ponytail: this phase is macOS + test-injected paths only. + return null; +} + +function resolveTraeCnStoragePath(options = {}) { + const home = resolveTraeCnHome(options); + return home ? path.join(home, "User", "globalStorage", "storage.json") : null; +} + +// --------------------------------------------------------------------------- +// tc v5 decryption +// --------------------------------------------------------------------------- + +function traeCnHardcodedPassword() { + const pw = Buffer.alloc(64); + for (let i = 0; i < 64; i++) pw[i] = TRAE_CN_JG[i] ^ TRAE_CN_KG[i]; + return pw; +} + +function deriveTraeCnKeyIv(salt) { + if (!Buffer.isBuffer(salt) || salt.length !== TRAE_CN_SALT_LEN) { + throw new Error("Trae CN auth salt is malformed."); + } + const kdfBuf = Buffer.concat([ + crypto.createHash("sha512").update(salt).digest(), + traeCnHardcodedPassword(), + ]); + const kdfOut = crypto.createHash("sha512").update(kdfBuf).digest(); + return { + key: kdfOut.subarray(0, TRAE_CN_AES_KEY_LEN), + iv: kdfOut.subarray(TRAE_CN_AES_KEY_LEN, TRAE_CN_AES_KEY_LEN + TRAE_CN_IV_LEN), + }; +} + +/** Decrypt a raw tc v5 blob; returns the JSON bytes (integrity prefix stripped). */ +function decryptTraeCnBlob(blob) { + const minLen = TRAE_CN_MAGIC.length + TRAE_CN_SALT_LEN + 16; + if (!Buffer.isBuffer(blob) || blob.length < minLen) { + throw new Error("Trae CN auth blob is too short."); + } + if (!blob.subarray(0, TRAE_CN_MAGIC.length).equals(TRAE_CN_MAGIC)) { + throw new Error("Trae CN auth blob has an unknown format."); + } + const salt = blob.subarray(TRAE_CN_MAGIC.length, TRAE_CN_MAGIC.length + TRAE_CN_SALT_LEN); + const ciphertext = blob.subarray(TRAE_CN_MAGIC.length + TRAE_CN_SALT_LEN); + if (ciphertext.length % 16 !== 0) { + throw new Error("Trae CN auth blob is not block-aligned."); + } + const { key, iv } = deriveTraeCnKeyIv(salt); + const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv); // PKCS7 default + let plaintext; + try { + plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + } catch (_error) { + throw new Error("Trae CN auth blob could not be decrypted."); + } + if (plaintext.length < TRAE_CN_HASH_LEN) { + throw new Error("Trae CN auth blob is missing its integrity digest."); + } + const expected = plaintext.subarray(0, TRAE_CN_HASH_LEN); + const data = plaintext.subarray(TRAE_CN_HASH_LEN); + const actual = crypto.createHash("sha512").update(data).digest(); + if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) { + throw new Error("Trae CN auth blob failed its integrity check."); + } + return data; +} + +/** Decrypt a Base64-encoded tc v5 blob and return the plaintext UTF-8 string. */ +function decryptTraeCnBase64(value) { + const blob = Buffer.from(String(value).trim(), "base64"); + return decryptTraeCnBlob(blob).toString("utf8"); +} + +// --------------------------------------------------------------------------- +// Credential reading +// --------------------------------------------------------------------------- + +/** + * Parse a stored auth value: a plaintext JSON object / JSON string, or a + * Base64-encoded tc v5 blob. Fails closed with a credential-safe error. + */ +function parseTraeCnAuthValue(value) { + if (value && typeof value === "object") { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error("Trae CN auth data is empty."); + } + if (trimmed.startsWith("{")) { + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (_error) { + throw new Error("Trae CN auth JSON is malformed."); + } + if (!parsed || typeof parsed !== "object") { + throw new Error("Trae CN auth JSON is malformed."); + } + return parsed; + } + let decrypted; + try { + decrypted = decryptTraeCnBase64(trimmed); + } catch (error) { + if (error && error.message.startsWith("Trae CN auth")) throw error; + throw new Error("Trae CN auth payload is malformed."); + } + let parsed; + try { + parsed = JSON.parse(decrypted); + } catch (_error) { + throw new Error("Trae CN auth payload is malformed."); + } + if (!parsed || typeof parsed !== "object") { + throw new Error("Trae CN auth payload is malformed."); + } + return parsed; + } + throw new Error("Trae CN auth data is missing or malformed."); +} + +/** + * Read + decrypt the auth object from storage.json. Returns null when the + * file or the auth key is absent (not configured). Throws a credential-safe + * error when present data is malformed. + */ +function readTraeCnAuthFromStorage({ env, home, platform, fsModule = fs } = {}) { + const storagePath = resolveTraeCnStoragePath({ env, home, platform }); + if (!storagePath) return null; + let raw; + try { + raw = fsModule.readFileSync(storagePath, "utf8"); + } catch (_error) { + return null; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (_error) { + throw new Error("Trae CN storage.json is not valid JSON."); + } + const value = parsed && typeof parsed === "object" ? parsed[TRAE_CN_AUTH_KEY] : undefined; + if (value === undefined || value === null) return null; + return parseTraeCnAuthValue(value); +} + +/** Extract a nonempty JWT from a parsed auth object. */ +function extractTraeCnToken(auth) { + const token = auth && typeof auth === "object" ? auth.token : undefined; + if (typeof token === "string" && token.trim()) { + return token.trim(); + } + throw new Error("Trae CN auth data has no token."); +} + +// --------------------------------------------------------------------------- +// Usage API +// --------------------------------------------------------------------------- + +function usageError(message, { status, apiCode } = {}) { + const error = new Error(message); + if (status !== undefined) error.status = status; + if (apiCode !== undefined) error.apiCode = apiCode; + if (status === 401 || status === 403) error.code = "AUTH_EXPIRED"; + return error; +} + +function assertUsageParams({ start_time, end_time, page_size }) { + if (!Number.isInteger(start_time) || start_time <= 0) { + throw new Error("Trae CN usage start_time must be a positive integer epoch second."); + } + if (!Number.isInteger(end_time) || end_time <= 0) { + throw new Error("Trae CN usage end_time must be a positive integer epoch second."); + } + if (start_time > end_time) { + throw new Error("Trae CN usage start_time must not be after end_time."); + } + if (!Number.isInteger(page_size) || page_size <= 0 || page_size > TRAE_CN_USAGE_PAGE_SIZE) { + throw new Error( + `Trae CN usage page_size must be a positive integer no greater than ${TRAE_CN_USAGE_PAGE_SIZE}.`, + ); + } +} + +/** + * Fetch one page of TRAE CN usage. Returns { sessions, total, page_num, page_size } + * where `total` is null when the API omits it. Errors never contain the JWT + * or the raw request/response body. + */ +async function fetchTraeCnUsagePage({ + jwt, + start_time, + end_time, + page_num, + page_size = TRAE_CN_USAGE_PAGE_SIZE, + url = TRAE_CN_USAGE_URL, + fetchImpl = fetch, + timeoutMs = TRAE_CN_USAGE_TIMEOUT_MS, +} = {}) { + if (typeof jwt !== "string" || !jwt.trim()) { + throw new Error("Trae CN usage request requires a token."); + } + if (!Number.isInteger(page_num) || page_num < 1) { + throw new Error("Trae CN usage page_num must be a positive integer."); + } + assertUsageParams({ start_time, end_time, page_size }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response; + try { + response = await fetchImpl(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Cloud-IDE-JWT ${jwt.trim()}`, + }, + body: JSON.stringify({ + usage_type: TRAE_CN_USAGE_TYPE, + start_time, + end_time, + page_num, + page_size, + }), + signal: controller.signal, + }); + } catch (_error) { + throw new Error("Trae CN usage API request failed."); + } finally { + clearTimeout(timer); + } + if (response.status === 401 || response.status === 403) { + throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status }); + } + if (!response.ok) { + throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status }); + } + let parsed; + try { + parsed = await response.json(); + } catch (_error) { + throw new Error("Trae CN usage API returned a non-JSON response."); + } + const apiCode = parsed?.data?.code ?? parsed?.code; + if (apiCode !== undefined && Number(apiCode) !== 0) { + throw usageError(`Trae CN usage API returned error code ${apiCode}.`, { + status: response.status, + apiCode: Number(apiCode), + }); + } + // Documented wrapper: data.user_usage_group_by_sessions, or the top-level + // equivalent. Only this module parses the wrapper; token rows/buckets are + // normalized elsewhere. A body without the session-array wrapper fails + // closed as a schema error instead of being misread as an empty result; + // an explicit empty array remains valid. + const data = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed; + if (!Array.isArray(data?.user_usage_group_by_sessions)) { + throw new Error("Trae CN usage API response is missing the session list."); + } + const sessions = data.user_usage_group_by_sessions; + const rawTotal = data?.total; + const total = rawTotal === undefined || rawTotal === null ? null : Number(rawTotal); + return { sessions, total, page_num, page_size }; +} + +/** + * Fetch usage across pages serially. Starts at the 1-based page_num, at most + * maxPages pages, with a delay between pages (pass delayMs = 0 to disable in + * tests). Stops at the stated total when present, otherwise on an empty page. + * Throws if maxPages is reached while another page would still be needed. + */ +async function fetchTraeCnUsage({ + jwt, + start_time, + end_time, + page_num = 1, + page_size = TRAE_CN_USAGE_PAGE_SIZE, + fetchImpl = fetch, + maxPages = TRAE_CN_USAGE_MAX_PAGES, + delayMs = TRAE_CN_USAGE_DELAY_MS, + pageFetcher = fetchTraeCnUsagePage, +} = {}) { + const sessions = []; + let total = null; + let pagesFetched = 0; + let page = page_num; + for (;;) { + if (pagesFetched >= maxPages) { + // A further page would be needed but the cap prevents it: fail closed + // rather than return a partial snapshot that looks authoritative. + throw new Error("Trae CN usage pagination exceeded the maximum page count."); + } + const result = await pageFetcher({ jwt, start_time, end_time, page_num: page, page_size, fetchImpl }); + pagesFetched += 1; + total = result.total; + if (Array.isArray(result.sessions)) sessions.push(...result.sessions); + if (result.sessions.length === 0) break; + if (total !== null && sessions.length >= total) break; + if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)); + page += 1; + } + return { sessions, total, pages_fetched: pagesFetched }; +} + +/** + * High-level storage-backed fetch: read the JWT from storage.json, fetch + * usage, and on a 401/403 re-read + re-decrypt storage.json and retry exactly + * once. Never persists or logs the JWT / refresh token; no refreshToken flow. + */ +async function fetchTraeCnUsageWithAuth({ + start_time, + end_time, + page_num = 1, + page_size = TRAE_CN_USAGE_PAGE_SIZE, + fetchImpl = fetch, + delayMs = TRAE_CN_USAGE_DELAY_MS, + maxPages = TRAE_CN_USAGE_MAX_PAGES, + readAuth = readTraeCnAuthFromStorage, + usageFetcher = fetchTraeCnUsage, + ...storageOptions +} = {}) { + let auth = readAuth(storageOptions); + let jwt = auth ? extractTraeCnToken(auth) : null; + if (!jwt) { + throw new Error("Trae CN credentials are not configured."); + } + const run = () => + usageFetcher({ jwt, start_time, end_time, page_num, page_size, fetchImpl, delayMs, maxPages }); + try { + return await run(); + } catch (error) { + const authFailure = + error?.code === "AUTH_EXPIRED" || error?.status === 401 || error?.status === 403; + if (!authFailure) throw error; + // Session may have rotated since the first read; re-read storage once. + auth = readAuth(storageOptions); + jwt = auth ? extractTraeCnToken(auth) : null; + if (!jwt) throw error; + return await run(); + } +} + +module.exports = { + TRAE_CN_HOME_ENV, + TRAE_CN_APP_DIR, + TRAE_CN_AUTH_KEY, + TRAE_CN_USAGE_URL, + TRAE_CN_USAGE_TYPE, + TRAE_CN_USAGE_PAGE_SIZE, + TRAE_CN_USAGE_MAX_PAGES, + TRAE_CN_USAGE_DELAY_MS, + TRAE_CN_MAGIC, + resolveTraeCnHome, + resolveTraeCnStoragePath, + traeCnHardcodedPassword, + deriveTraeCnKeyIv, + decryptTraeCnBlob, + decryptTraeCnBase64, + parseTraeCnAuthValue, + readTraeCnAuthFromStorage, + extractTraeCnToken, + fetchTraeCnUsagePage, + fetchTraeCnUsage, + fetchTraeCnUsageWithAuth, +}; diff --git a/test/discovery-metadata.test.js b/test/discovery-metadata.test.js index ea66285ae..2327a380c 100644 --- a/test/discovery-metadata.test.js +++ b/test/discovery-metadata.test.js @@ -9,14 +9,14 @@ const ROOT = path.resolve(__dirname, ".."); const read = (relativePath) => fs.readFileSync(path.join(ROOT, relativePath), "utf8"); const README_EXPECTATIONS = [ - ["README.md", /31 AI coding tools/, /\|\s+\*\*AI tools supported\*\*\s+\|\s+\*\*31\*\*/, /Rate-limit tracking.*✅ 13 providers/], - ["README.zh-CN.md", /31 款 AI 编码工具/, /\|\s+\*\*支持的 AI 工具数\*\*\s+\|\s+\*\*31\*\*/, /限额追踪.*✅ 13 家 provider/], - ["README.ja.md", /31 種類の AI コーディングツール/, /\|\s+\*\*対応 AI ツール数\*\*\s+\|\s+\*\*31\*\*/, /レート制限トラッキング.*✅ 13 プロバイダー/], - ["README.ko.md", /31개의 AI 코딩 도구/, /\|\s+\*\*지원하는 AI 도구 수\*\*\s+\|\s+\*\*31\*\*/, /레이트 제한 추적.*✅ 13개 프로바이더/], - ["README.de.md", /31 KI-Coding-Tools/, /\|\s+\*\*Unterstützte KI-Tools\*\*\s+\|\s+\*\*31\*\*/, /Rate-Limit-Tracking.*✅ 13 Provider/], + ["README.md", /32 AI coding tools/, /\|\s+\*\*AI tools supported\*\*\s+\|\s+\*\*32\*\*/, /Rate-limit tracking.*✅ 13 providers/], + ["README.zh-CN.md", /32 款 AI 编码工具/, /\|\s+\*\*支持的 AI 工具数\*\*\s+\|\s+\*\*32\*\*/, /限额追踪.*✅ 13 家 provider/], + ["README.ja.md", /32 種類の AI コーディングツール/, /\|\s+\*\*対応 AI ツール数\*\*\s+\|\s+\*\*32\*\*/, /レート制限トラッキング.*✅ 13 プロバイダー/], + ["README.ko.md", /32개의 AI 코딩 도구/, /\|\s+\*\*지원하는 AI 도구 수\*\*\s+\|\s+\*\*32\*\*/, /레이트 제한 추적.*✅ 13개 프로바이더/], + ["README.de.md", /32 KI-Coding-Tools/, /\|\s+\*\*Unterstützte KI-Tools\*\*\s+\|\s+\*\*32\*\*/, /Rate-Limit-Tracking.*✅ 13 Provider/], ]; -test("public discovery surfaces describe all 31 supported tools", () => { +test("public discovery surfaces describe all 32 supported tools", () => { for (const [file, countPattern, comparisonPattern, limitCountPattern] of README_EXPECTATIONS) { const source = read(file); assert.match(source, countPattern, `${file} has the current provider count`); @@ -25,12 +25,14 @@ test("public discovery surfaces describe all 31 supported tools", () => { assert.match(source, /AnythingLLM Desktop/, `${file} lists AnythingLLM Desktop`); assert.match(source, /Qoder/, `${file} lists Qoder`); assert.match(source, /DeepSeek Harness/, `${file} lists DeepSeek Harness`); + assert.match(source, /TRAE Work CN/, `${file} lists TRAE Work CN`); assert.match(source, limitCountPattern, `${file} rate-limit row carries the current usage-limits provider count`); } const index = read("dashboard/index.html"); assert.doesNotMatch(index, /13 AI coding/); - assert.match(index, /Supported AI coding tools \(31\)/); + assert.match(index, /Supported AI coding tools \(32\)/); + assert.match(index, /TRAE Work CN/); assert.match(index, /Desktop pet/); assert.match(index, /Four desktop widgets/); assert.match(index, /Achievements/); @@ -38,41 +40,43 @@ test("public discovery surfaces describe all 31 supported tools", () => { assert.match(index, /usage limits for 13 providers/i); const llms = read("dashboard/public/llms.txt"); - assert.match(llms, /Supported AI coding tools \(31\)/); + assert.match(llms, /Supported AI coding tools \(32\)/); + assert.match(llms, /TRAE Work CN/); assert.match(llms, /desktop pet/i); assert.match(llms, /four desktop widgets/i); assert.match(llms, /achievements/i); }); -test("marketing logo wall includes the same 31 product integrations", () => { +test("marketing logo wall includes the same 32 product integrations", () => { const source = read("dashboard/src/ui/marketing/agent-logos.js"); const providers = [...source.matchAll(/provider:\s*"([^"]+)"/g)].map((match) => match[1]); - assert.equal(providers.length, 31); - assert.equal(new Set(providers).size, 31); + assert.equal(providers.length, 32); + assert.equal(new Set(providers).size, 32); - for (const provider of ["every-code", "reasonix", "kilocode", "roocode", "zed", "goose", "droid", "qoder", "anythingllm", "dsh"]) { + for (const provider of ["every-code", "reasonix", "kilocode", "roocode", "zed", "goose", "droid", "qoder", "anythingllm", "dsh", "trae-cn"]) { assert.ok(providers.includes(provider), `logo wall includes ${provider}`); } }); -test("CLI onboarding advertises the same 31 supported integrations", () => { +test("CLI onboarding advertises the same 32 supported integrations", () => { const source = read("src/commands/init.js"); const block = source.match(/const SUPPORTED_PROVIDERS = \[([\s\S]*?)\];/); assert.ok(block, "init defines SUPPORTED_PROVIDERS"); const providers = [...block[1].matchAll(/^\s*"([^"]+)",?$/gm)].map((match) => match[1]); - assert.equal(providers.length, 31); - assert.equal(new Set(providers).size, 31); + assert.equal(providers.length, 32); + assert.equal(new Set(providers).size, 32); assert.ok(providers.includes("Droid")); assert.ok(providers.includes("AnythingLLM Desktop")); assert.ok(providers.includes("Qoder")); assert.ok(providers.includes("Reasonix")); assert.ok(providers.includes("DeepSeek Harness")); + assert.ok(providers.includes("TRAE Work CN")); }); test("npm metadata carries the current product hook", () => { const pkg = JSON.parse(read("package.json")); - assert.match(pkg.description, /31 tools/); + assert.match(pkg.description, /32 tools/); assert.match(pkg.description, /desktop pet/); assert.ok(pkg.keywords.includes("desktop-widget")); assert.ok(pkg.keywords.includes("ai-coding-tools")); diff --git a/test/source-metadata.test.js b/test/source-metadata.test.js new file mode 100644 index 000000000..560823989 --- /dev/null +++ b/test/source-metadata.test.js @@ -0,0 +1,54 @@ +/** + * Source-metadata classification tests. Verifies TRAE Work CN is an + * account-level source (case-insensitively) and that personal scope filters it + * out while preserving local sources. + */ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + getSourceScope, + isAccountLevelSource, + normalizeUsageScope, + filterRowsByUsageScope, + listExcludedSources, +} = require("../src/lib/source-metadata"); + +test("trae-cn is classified as an account-level source case-insensitively", () => { + assert.equal(getSourceScope("trae-cn"), "account"); + assert.equal(getSourceScope("TRAE-CN"), "account"); + assert.equal(getSourceScope(" Trae-Cn "), "account"); + assert.equal(isAccountLevelSource("trae-cn"), true); + // Pre-existing classification is preserved. + assert.equal(getSourceScope("cursor"), "account"); + assert.equal(getSourceScope("codex"), "local"); + assert.equal(getSourceScope("claude"), "local"); +}); + +test("personal scope filters account-level trae-cn rows while preserving local sources", () => { + const rows = [ + { source: "trae-cn", total_tokens: 10 }, + { source: "TRAE-CN", total_tokens: 20 }, + { source: "codex", total_tokens: 30 }, + { source: "claude", total_tokens: 40 }, + ]; + const personal = filterRowsByUsageScope(rows, "personal"); + assert.deepEqual(personal, [ + { source: "codex", total_tokens: 30 }, + { source: "claude", total_tokens: 40 }, + ]); + assert.equal(filterRowsByUsageScope(rows, "all").length, 4); + assert.equal(normalizeUsageScope("personal"), "personal"); +}); + +test("listExcludedSources reports trae-cn as excluded from personal scope", () => { + const excluded = listExcludedSources( + [{ source: "trae-cn" }, { source: "TRAE-CN" }, { source: "codex" }], + "personal", + ); + assert.deepEqual(excluded, [ + { source: "trae-cn", source_scope: "account", reason: "account_level_source" }, + ]); +}); diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js new file mode 100644 index 000000000..b7647b5af --- /dev/null +++ b/test/sync-trae-cn.test.js @@ -0,0 +1,260 @@ +/** + * TRAE Work CN sync wiring tests. + * + * All synthetic: no live API, no real JWT. Exercises the non-background auto + * sync block (rolling 30-day range, storage-backed auth, single parser call) + * and the gating that keeps every background / all-local variant from fetching. + * The injected fetch + fixed now come through cmdSync's narrow context seam + * (context.traeCnFetchImpl / context.traeCnNowMs). + */ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { cmdSync } = require("../src/commands/sync"); +const { getSourceScope } = require("../src/lib/source-metadata"); + +const TRAE_CN_AUTH_KEY = "iCubeAuthInfo://icube.cloudide"; +const TRAE_CN_USAGE_URL = "https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session"; +const NOW_MS = 1_800_000_000_000; +const ROLLING_DAYS = 30 * 24 * 60 * 60; + +function tokenCountLine({ ts, totalTokens }) { + const usage = { + input_tokens: totalTokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: totalTokens, + }; + return JSON.stringify({ + type: "event_msg", + timestamp: ts, + payload: { + type: "token_count", + info: { last_token_usage: usage, total_token_usage: usage }, + }, + }); +} + +async function withTempTraeEnv(fn) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-trae-sync-")); + const traeCnHome = path.join(home, "trae-cn-data"); + const saved = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + CODEX_HOME: process.env.CODEX_HOME, + CODE_HOME: process.env.CODE_HOME, + GEMINI_HOME: process.env.GEMINI_HOME, + OPENCODE_HOME: process.env.OPENCODE_HOME, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + TOKENTRACKER_REASONIX_HOME: process.env.TOKENTRACKER_REASONIX_HOME, + REASONIX_STATE_HOME: process.env.REASONIX_STATE_HOME, + TOKENTRACKER_DEVICE_TOKEN: process.env.TOKENTRACKER_DEVICE_TOKEN, + TOKENTRACKER_INSFORGE_BASE_URL: process.env.TOKENTRACKER_INSFORGE_BASE_URL, + TOKENTRACKER_OPENCLAW_HOME: process.env.TOKENTRACKER_OPENCLAW_HOME, + TOKENTRACKER_OPENCLAW_AGENT_ID: process.env.TOKENTRACKER_OPENCLAW_AGENT_ID, + TOKENTRACKER_OPENCLAW_PREV_SESSION_ID: process.env.TOKENTRACKER_OPENCLAW_PREV_SESSION_ID, + TOKENTRACKER_OPENCLAW_SESSION_KEY: process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY, + TOKENTRACKER_TRAE_CN_HOME: process.env.TOKENTRACKER_TRAE_CN_HOME, + TOKENTRACKER_WSL_MODE: process.env.TOKENTRACKER_WSL_MODE, + }; + try { + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.CODEX_HOME = path.join(home, ".codex"); + process.env.CODE_HOME = path.join(home, ".code"); + process.env.GEMINI_HOME = path.join(home, ".gemini"); + process.env.OPENCODE_HOME = path.join(home, ".opencode"); + process.env.XDG_DATA_HOME = path.join(home, ".local", "share"); + process.env.TOKENTRACKER_OPENCLAW_HOME = path.join(home, ".openclaw"); + process.env.TOKENTRACKER_TRAE_CN_HOME = traeCnHome; + delete process.env.TOKENTRACKER_REASONIX_HOME; + delete process.env.REASONIX_STATE_HOME; + delete process.env.TOKENTRACKER_DEVICE_TOKEN; + delete process.env.TOKENTRACKER_INSFORGE_BASE_URL; + delete process.env.TOKENTRACKER_OPENCLAW_AGENT_ID; + delete process.env.TOKENTRACKER_OPENCLAW_PREV_SESSION_ID; + delete process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY; + delete process.env.TOKENTRACKER_WSL_MODE; + fs.mkdirSync(path.join(traeCnHome, "User", "globalStorage"), { recursive: true }); + return await fn({ home, traeCnHome }); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(home, { recursive: true, force: true }); + } +} + +function writeTraeCnStorage(traeCnHome, auth = { token: "fake-jwt-token", refreshToken: "synthetic" }) { + fs.writeFileSync( + path.join(traeCnHome, "User", "globalStorage", "storage.json"), + JSON.stringify({ [TRAE_CN_AUTH_KEY]: auth }), + ); +} + +function readQueueRows(home) { + const queuePath = path.join(home, ".tokentracker", "tracker", "queue.jsonl"); + if (!fs.existsSync(queuePath)) return []; + return fs.readFileSync(queuePath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function readCursors(home) { + const cursorsPath = path.join(home, ".tokentracker", "tracker", "cursors.json"); + if (!fs.existsSync(cursorsPath)) return {}; + return JSON.parse(fs.readFileSync(cursorsPath, "utf8")); +} + +function traeFetchOnce(body, requests = []) { + return async (url, options) => { + requests.push({ url, options }); + return { status: 200, ok: true, json: async () => body }; + }; +} + +function halfHourBucket(epochSeconds) { + const d = new Date(epochSeconds * 1000); + const half = d.getUTCMinutes() >= 30 ? 30 : 0; + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), half, 0, 0), + ).toISOString(); +} + +function sampleSession(usageTime) { + return { + session_id: "s1", + model_name: "doubao-pro", + usage_time: usageTime, + input_token: 100, + output_token: 10, + cache_read_token: 0, + cache_write_token: 0, + }; +} + +test("non-background auto trae-cn sync fetches the exact rolling range and queues the row", async () => { + await withTempTraeEnv(async ({ home, traeCnHome }) => { + writeTraeCnStorage(traeCnHome); + const endTime = Math.floor(NOW_MS / 1000); + const startTime = Math.max(1, endTime - ROLLING_DAYS); + const usageTime = startTime + 3600; + const requests = []; + const fetchImpl = traeFetchOnce( + { user_usage_group_by_sessions: [sampleSession(usageTime)], total: 1 }, + requests, + ); + + await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS }); + + assert.equal(requests.length, 1, "single page fetch"); + assert.equal(requests[0].url, TRAE_CN_USAGE_URL); + assert.equal(requests[0].options.method, "POST"); + assert.equal(requests[0].options.headers["Content-Type"], "application/json"); + assert.equal(requests[0].options.headers.Authorization, "Cloud-IDE-JWT fake-jwt-token"); + const body = JSON.parse(requests[0].options.body); + assert.deepEqual(body.usage_type, [7]); + assert.equal(body.start_time, startTime); + assert.equal(body.end_time, endTime); + assert.equal(body.page_num, 1); + assert.equal(body.page_size, 20); + + const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn"); + assert.equal(traeRows.length, 1); + assert.equal(traeRows[0].model, "doubao-pro"); + assert.equal(traeRows[0].input_tokens, 100); + assert.equal(traeRows[0].output_tokens, 10); + assert.equal(traeRows[0].total_tokens, 110); + assert.equal(traeRows[0].conversation_count, 1); + assert.equal(traeRows[0].hour_start, halfHourBucket(usageTime)); + + const cursors = readCursors(home); + assert.equal(cursors.traeCn.version, 1); + assert.equal(cursors.traeCn.sessions["s1"].totals.input_tokens, 100); + assert.equal(cursors.traeCn.sessions["s1"].bucketStart, halfHourBucket(usageTime)); + assert.equal(getSourceScope("trae-cn"), "account", "source-scope account"); + }); +}); + +test("repeated fixed-now sync is idempotent (no token growth / no new queue row)", async () => { + await withTempTraeEnv(async ({ home, traeCnHome }) => { + writeTraeCnStorage(traeCnHome); + const endTime = Math.floor(NOW_MS / 1000); + const fetchImpl = traeFetchOnce({ + user_usage_group_by_sessions: [sampleSession(endTime - 86400)], + total: 1, + }); + await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS }); + + const queuePath = path.join(home, ".tokentracker", "tracker", "queue.jsonl"); + const before = fs.readFileSync(queuePath, "utf8"); + await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS }); + assert.equal(fs.readFileSync(queuePath, "utf8"), before, "queue unchanged on repeat"); + + const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn"); + assert.equal(traeRows.length, 1); + assert.equal(traeRows[0].total_tokens, 110); + }); +}); + +test("background and all-local syncs never call the TRAE CN fetch", async () => { + await withTempTraeEnv(async ({ home, traeCnHome }) => { + writeTraeCnStorage(traeCnHome); + let fetchCalls = 0; + const fetchImpl = async () => { + fetchCalls += 1; + throw new Error("should not fetch"); + }; + const opts = { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS }; + + await cmdSync(["--auto", "--background"], opts); + assert.equal(fetchCalls, 0); + await cmdSync(["--auto", "--background", "--all-local-sources"], opts); + assert.equal(fetchCalls, 0); + await cmdSync(["--auto", "--background", "--source=trae-cn"], opts); + assert.equal(fetchCalls, 0); + + assert.equal( + readQueueRows(home).some((row) => row.source === "trae-cn"), + false, + ); + }); +}); + +test("failed TRAE CN fetch skips parser/queue/cursor mutation while other providers continue", async () => { + await withTempTraeEnv(async ({ home, traeCnHome }) => { + writeTraeCnStorage(traeCnHome); + const fetchImpl = async () => { + throw new Error("network down"); + }; + const opts = { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS }; + + // Scoped run: failing fetch must not create trae-cn queue rows or cursor state. + await cmdSync(["--auto", "--source=trae-cn"], opts); + assert.equal(readQueueRows(home).filter((row) => row.source === "trae-cn").length, 0); + assert.equal(readCursors(home).traeCn, undefined); + + // Full run: an unrelated local provider still parses while trae-cn is skipped. + const codexHome = process.env.CODEX_HOME; + fs.mkdirSync(path.join(codexHome, "sessions", "2026", "06", "30"), { recursive: true }); + fs.writeFileSync( + path.join(codexHome, "sessions", "2026", "06", "30", "rollout-2026-06-30T00-00-00-aaaa-ffff.jsonl"), + tokenCountLine({ ts: "2026-06-30T00:00:00.000Z", totalTokens: 31 }) + "\n", + "utf8", + ); + await cmdSync(["--auto"], opts); + const rows = readQueueRows(home); + assert.ok(rows.some((row) => row.source === "codex"), "unrelated provider continued"); + assert.ok(!rows.some((row) => row.source === "trae-cn"), "no trae-cn rows after failure"); + assert.equal(readCursors(home).traeCn, undefined); + }); +}); diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js new file mode 100644 index 000000000..d447e34ec --- /dev/null +++ b/test/trae-cn-config.test.js @@ -0,0 +1,570 @@ +/** + * TRAE Work CN config tests. Synthetic data only — no real JWTs / fixtures. + * + * The encrypted-tc-v5 coverage relies on a frozen static fixture (FIXTURE_B64) + * produced once with the documented KDF + a fixed 32-byte salt, plus an + * independent encryptor in this file that writes its own KDF. Neither imports + * production `deriveTraeCnKeyIv`, so a broken production KDF fails the + * decrypt assertions instead of being hidden by test-only encryption. + */ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { + TRAE_CN_HOME_ENV, + TRAE_CN_AUTH_KEY, + TRAE_CN_USAGE_URL, + TRAE_CN_USAGE_DELAY_MS, + resolveTraeCnHome, + resolveTraeCnStoragePath, + decryptTraeCnBase64, + parseTraeCnAuthValue, + readTraeCnAuthFromStorage, + extractTraeCnToken, + fetchTraeCnUsagePage, + fetchTraeCnUsage, + fetchTraeCnUsageWithAuth, +} = require("../src/lib/trae-cn-config"); + +const CN_APP_DIR = "TRAE SOLO CN"; + +// Frozen fixture: encrypted with the documented KDF, fixed 32-byte salt. +const FIXTURE_B64 = + "dGMFEAAA3q2+796tvu/erb7v3q2+796tvu/erb7v3q2+796tvu/43q0BL3Fo+gI3mEsCEdP2c66sO1HLQB7bKIlHUL7xJg0PVJHLSzn/gDVQmH7Ksh0wflKSFHtHgz7rwynP4aOOx3CPgDR5lQ8neR7CWouH94Dh99HlsWwqo29etbVBVbTU18KIS74UPyA5ASAWnYHqKPQSfv8uCn6GOMfOyJPNPMI6Yz666PBNVFTastLi70ThZ6Lb/Gs+bjyTDrQnSfjDnDJPinjJejWWlboznbnME/rNkmc2LLMftj8jGqorIq4="; +const FIXTURE_TOKEN = "fake.jwt.eyJzdWIiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5fQ.sig"; +const FIXTURE_AUTH = { + token: FIXTURE_TOKEN, + refreshToken: "synthetic-refresh", + userId: "user-123", +}; + +// Independent encryptor — own copy of the documented constants + KDF. +const TEST_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00]); +const TEST_JG = Buffer.from([ + 82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, + 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, + 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, + 109, 139, 209, 37, +]); +const TEST_KG = Buffer.from([ + 31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, + 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, + 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, + 85, 33, 12, 125, +]); + +function testPassword() { + const pw = Buffer.alloc(64); + for (let i = 0; i < 64; i++) pw[i] = TEST_JG[i] ^ TEST_KG[i]; + return pw; +} + +function testDerive(salt) { + const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testPassword()]); + const out = crypto.createHash("sha512").update(kdfBuf).digest(); + return { key: out.subarray(0, 16), iv: out.subarray(16, 32) }; +} + +function testEncrypt(plainText, salt = Buffer.alloc(32, 0x11)) { + const plain = Buffer.from(plainText, "utf8"); + const { key, iv } = testDerive(salt); + const hash = crypto.createHash("sha512").update(plain).digest(); + const cipher = crypto.createCipheriv("aes-128-cbc", key, iv); + const ct = Buffer.concat([cipher.update(Buffer.concat([hash, plain])), cipher.final()]); + return Buffer.concat([TEST_MAGIC, salt, ct]); +} + +function makeTraeCnHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), "trae-cn-test-")); +} + +function writeStorage(traeHome, value, { authKey = TRAE_CN_AUTH_KEY } = {}) { + const dir = path.join(traeHome, "User", "globalStorage"); + fs.mkdirSync(dir, { recursive: true }); + const storagePath = path.join(dir, "storage.json"); + const payload = {}; + if (value !== undefined) payload[authKey] = value; + fs.writeFileSync(storagePath, JSON.stringify(payload)); + return storagePath; +} + +function jsonResponse(status, body) { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + }; +} + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +test("resolveTraeCnHome honors TOKENTRACKER_TRAE_CN_HOME override", () => { + assert.equal( + resolveTraeCnHome({ env: { [TRAE_CN_HOME_ENV]: "/custom/cn-home" } }), + path.resolve("/custom/cn-home"), + ); + // Whitespace-only override falls back to the platform default. + const fallback = resolveTraeCnHome({ env: { [TRAE_CN_HOME_ENV]: " " }, platform: "darwin", home: "/Users/t" }); + assert.equal(fallback, path.join("/Users/t", "Library", "Application Support", CN_APP_DIR)); +}); + +test("resolveTraeCnHome resolves the macOS default and rejects other platforms", () => { + const home = "/Users/tester"; + assert.equal( + resolveTraeCnHome({ platform: "darwin", home }), + path.join(home, "Library", "Application Support", CN_APP_DIR), + ); + // Phase A is macOS + test-injected paths only. + assert.equal(resolveTraeCnHome({ platform: "win32", home, env: {} }), null); + assert.equal(resolveTraeCnHome({ platform: "linux", home, env: {} }), null); +}); + +test("resolveTraeCnStoragePath points at User/globalStorage/storage.json", () => { + const override = "/tmp/cn-install"; + assert.equal( + resolveTraeCnStoragePath({ env: { [TRAE_CN_HOME_ENV]: override } }), + path.join(path.resolve(override), "User", "globalStorage", "storage.json"), + ); + assert.equal( + resolveTraeCnStoragePath({ platform: "darwin", home: "/Users/tester", env: {} }), + path.join("/Users/tester", "Library", "Application Support", CN_APP_DIR, "User", "globalStorage", "storage.json"), + ); + assert.equal(resolveTraeCnStoragePath({ platform: "win32", home: "/Users/tester", env: {} }), null); +}); + +// --------------------------------------------------------------------------- +// Storage auth reading: plaintext + encrypted tc v5 +// --------------------------------------------------------------------------- + +test("frozen tc v5 fixture decrypts to the expected auth (broken-KDF detector)", () => { + assert.deepEqual(parseTraeCnAuthValue(FIXTURE_B64), FIXTURE_AUTH); + assert.equal(JSON.parse(decryptTraeCnBase64(FIXTURE_B64)).token, FIXTURE_TOKEN); +}); + +test("independent-encryptor round trip decrypts through production", () => { + const blob = testEncrypt(JSON.stringify({ token: "roundtrip.jwt.abc", refreshToken: "x" })); + assert.deepEqual(parseTraeCnAuthValue(blob.toString("base64")), { token: "roundtrip.jwt.abc", refreshToken: "x" }); +}); + +test("readTraeCnAuthFromStorage reads plaintext JSON object auth", () => { + const home = makeTraeCnHome(); + writeStorage(home, { token: "plain.jwt.1", refreshToken: "r1" }); + assert.deepEqual( + readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), + { token: "plain.jwt.1", refreshToken: "r1" }, + ); +}); + +test("readTraeCnAuthFromStorage reads plaintext JSON string auth", () => { + const home = makeTraeCnHome(); + writeStorage(home, JSON.stringify({ token: "plain.jwt.2", refreshToken: "r2" })); + assert.deepEqual( + readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), + { token: "plain.jwt.2", refreshToken: "r2" }, + ); +}); + +test("readTraeCnAuthFromStorage reads the encrypted tc v5 blob", () => { + const home = makeTraeCnHome(); + writeStorage(home, FIXTURE_B64); + const auth = readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }); + assert.equal(auth.token, FIXTURE_TOKEN); + assert.equal(extractTraeCnToken(auth), FIXTURE_TOKEN); +}); + +test("readTraeCnAuthFromStorage returns null when not configured", () => { + const home = makeTraeCnHome(); + const storagePath = path.join(home, "User", "globalStorage", "storage.json"); + assert.equal(readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), null); + fs.mkdirSync(path.dirname(storagePath), { recursive: true }); + fs.writeFileSync(storagePath, JSON.stringify({})); + assert.equal(readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), null); +}); + +// --------------------------------------------------------------------------- +// Malformed / tampered rejection (credential-safe errors) +// --------------------------------------------------------------------------- + +test("malformed storage.json fails closed with a credential-safe error", () => { + const home = makeTraeCnHome(); + const storagePath = path.join(home, "User", "globalStorage", "storage.json"); + fs.mkdirSync(path.dirname(storagePath), { recursive: true }); + fs.writeFileSync(storagePath, "{not json"); + assert.throws( + () => readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), + /not valid JSON/, + ); +}); + +test("malformed plaintext JSON fails closed", () => { + const home = makeTraeCnHome(); + writeStorage(home, "{broken"); + assert.throws( + () => readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), + /auth JSON is malformed/, + ); +}); + +test("tampered ciphertext is rejected and leaks nothing", () => { + const blob = Buffer.from(FIXTURE_B64, "base64"); + blob[blob.length - 16] ^= 0x01; // flip a byte in the ciphertext + const tampered = blob.toString("base64"); + assert.throws(() => parseTraeCnAuthValue(tampered)); + try { + parseTraeCnAuthValue(tampered); + assert.fail("expected throw"); + } catch (error) { + assert.ok(!error.message.includes(FIXTURE_TOKEN), "error must not contain the token"); + assert.ok(!error.message.includes(tampered), "error must not contain the blob"); + } +}); + +test("wrong magic / too-short / bad base64 are rejected", () => { + const blob = Buffer.from(FIXTURE_B64, "base64"); + blob[0] = 0x78; // "x" + assert.throws(() => parseTraeCnAuthValue(blob.toString("base64")), /unknown format/); + assert.throws(() => parseTraeCnAuthValue(Buffer.alloc(16).toString("base64")), /too short|unknown format/); + assert.throws(() => parseTraeCnAuthValue("not-base64@#$"), /Trae CN auth/); + assert.throws(() => parseTraeCnAuthValue(""), /empty|malformed/); + assert.throws(() => parseTraeCnAuthValue(42), /missing or malformed/); +}); + +test("extractTraeCnToken extracts a nonempty JWT and rejects empty/missing", () => { + assert.equal(extractTraeCnToken({ token: " abc.jwt.xyz " }), "abc.jwt.xyz"); + assert.throws(() => extractTraeCnToken({ token: "" }), /has no token/); + assert.throws(() => extractTraeCnToken({}), /has no token/); + assert.throws(() => extractTraeCnToken(null), /has no token/); +}); + +// --------------------------------------------------------------------------- +// Usage API page fetch +// --------------------------------------------------------------------------- + +const JWT = "test.jwt.usage-123"; +const START = 1700000000; +const END = 1700086400; + +test("fetchTraeCnUsagePage sends the exact request", async () => { + let captured; + const fetchImpl = async (url, options) => { + captured = { url, options }; + return jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }], total: 1 }); + }; + await fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl }); + assert.equal(captured.url, TRAE_CN_USAGE_URL); + assert.equal(captured.options.method, "POST"); + assert.equal(captured.options.headers["Content-Type"], "application/json"); + assert.equal(captured.options.headers.Authorization, `Cloud-IDE-JWT ${JWT}`); + assert.deepEqual(JSON.parse(captured.options.body), { + usage_type: [7], + start_time: START, + end_time: END, + page_num: 1, + page_size: 20, + }); +}); + +test("fetchTraeCnUsagePage parses both documented wrappers", async () => { + const topLevel = await fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }, { id: 2 }], total: 2 }), + }); + assert.equal(topLevel.sessions.length, 2); + assert.equal(topLevel.total, 2); + + const nested = await fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { data: { user_usage_group_by_sessions: [{ id: 3 }], total: 1 } }), + }); + assert.equal(nested.sessions.length, 1); + assert.equal(nested.total, 1); + + const noTotal = await fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [{ id: 9 }] }), + }); + assert.equal(noTotal.sessions.length, 1); + assert.equal(noTotal.total, null); +}); + +test("fetchTraeCnUsagePage fails closed when the session wrapper is missing", async () => { + const bodies = [ + {}, + { data: {} }, + { data: { other: 1 } }, + { user_usage_group_by_sessions: "not-an-array" }, + { data: { user_usage_group_by_sessions: null } }, + ]; + for (const body of bodies) { + await assert.rejects( + fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(200, body) }), + (error) => { + assert.match(error.message, /missing the session list/); + assert.ok(!error.message.includes(JWT), "schema error must not leak the JWT"); + return true; + }, + ); + } +}); + +test("fetchTraeCnUsagePage accepts an explicit empty session array", async () => { + const result = await fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [], total: 0 }), + }); + assert.deepEqual(result.sessions, []); + assert.equal(result.total, 0); +}); + +test("fetchTraeCnUsagePage validates start_time / end_time / page_size locally", async () => { + const ok = () => async () => jsonResponse(200, { user_usage_group_by_sessions: [] }); + const page = (overrides) => + fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: ok(), ...overrides }); + + await assert.rejects(page({ start_time: 0 }), /start_time must be a positive integer/); + await assert.rejects(page({ start_time: -1 }), /start_time must be a positive integer/); + await assert.rejects(page({ start_time: 1.5 }), /start_time must be a positive integer/); + await assert.rejects(page({ start_time: Infinity }), /start_time must be a positive integer/); + await assert.rejects(page({ start_time: "1700000000" }), /start_time must be a positive integer/); + + await assert.rejects(page({ end_time: 0 }), /end_time must be a positive integer/); + await assert.rejects(page({ end_time: 1.5 }), /end_time must be a positive integer/); + + await assert.rejects(page({ start_time: END, end_time: START }), /start_time must not be after end_time/); + + await assert.rejects(page({ page_size: 0 }), /page_size must be a positive integer no greater than 20/); + await assert.rejects(page({ page_size: -5 }), /page_size must be a positive integer no greater than 20/); + await assert.rejects(page({ page_size: 21 }), /page_size must be a positive integer no greater than 20/); + await assert.rejects(page({ page_size: 2.5 }), /page_size must be a positive integer no greater than 20/); + + const valid = await page({ page_size: 20, start_time: START, end_time: END }); + assert.deepEqual(valid.sessions, []); + const smallPage = await page({ page_size: 5 }); + assert.equal(smallPage.page_size, 5); +}); + +test("fetchTraeCnUsagePage errors are useful and credential-safe", async () => { + await assert.rejects( + fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(401, {}) }), + (error) => { + assert.match(error.message, /HTTP 401/); + assert.equal(error.status, 401); + assert.equal(error.code, "AUTH_EXPIRED"); + assert.ok(!error.message.includes(JWT)); + return true; + }, + ); + + await assert.rejects( + fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(500, {}) }), + (error) => { + assert.match(error.message, /HTTP 500/); + assert.ok(!error.message.includes(JWT)); + return true; + }, + ); + + await assert.rejects( + fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(200, { code: 10001, data: {} }) }), + (error) => { + assert.match(error.message, /error code 10001/); + assert.equal(error.apiCode, 10001); + assert.ok(!error.message.includes(JWT)); + return true; + }, + ); + + await assert.rejects( + fetchTraeCnUsagePage({ jwt: "", start_time: START, end_time: END, page_num: 1 }), + /requires a token/, + ); + await assert.rejects( + fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 0 }), + /positive integer/, + ); +}); + +test("fetchTraeCnUsagePage sanitizes transport exceptions containing the JWT", async () => { + const jwtCanary = "jwt-transport-canary"; + await assert.rejects( + fetchTraeCnUsagePage({ + jwt: jwtCanary, + start_time: START, + end_time: END, + page_num: 1, + fetchImpl: async (_url, options) => { + throw new Error(`request failed ${jwtCanary} ${options.headers.Authorization}`); + }, + }), + (error) => { + assert.equal(error.message, "Trae CN usage API request failed."); + assert.ok(!error.message.includes(jwtCanary)); + return true; + }, + ); +}); + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +function pagedFetch({ rowsPerPage, total } = {}) { + const requestedPages = []; + const fetchImpl = async (_url, options) => { + const body = JSON.parse(options.body); + requestedPages.push(body.page_num); + const rows = Array.from({ length: rowsPerPage(body) }, (_v, i) => ({ id: body.page_num * 1000 + i })); + const responseBody = { user_usage_group_by_sessions: rows }; + if (total !== undefined) responseBody.total = total; + return jsonResponse(200, responseBody); + }; + return { fetchImpl, requestedPages }; +} + +test("fetchTraeCnUsage paginates serially and stops at the stated total", async () => { + const total = 45; + const { fetchImpl, requestedPages } = pagedFetch({ + rowsPerPage: (body) => { + const remaining = total - (body.page_num - 1) * body.page_size; + return Math.max(0, Math.min(body.page_size, remaining)); + }, + total, + }); + const result = await fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0 }); + assert.deepEqual(requestedPages, [1, 2, 3]); + assert.equal(result.sessions.length, 45); + assert.equal(result.total, 45); + assert.equal(result.pages_fetched, 3); +}); + +test("fetchTraeCnUsage stops on an empty page when total is absent", async () => { + const requestedPages = []; + const fetchImpl = async (_url, options) => { + const body = JSON.parse(options.body); + requestedPages.push(body.page_num); + const rows = body.page_num === 1 ? [{ id: 1 }] : []; + return jsonResponse(200, { user_usage_group_by_sessions: rows }); + }; + const result = await fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0 }); + assert.deepEqual(requestedPages, [1, 2]); + assert.equal(result.sessions.length, 1); + assert.equal(result.total, null); + assert.equal(result.pages_fetched, 2); +}); + +test("fetchTraeCnUsage fails closed when maxPages is reached and more is needed", async () => { + const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 20 }); + await assert.rejects( + fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 3 }), + /exceeded the maximum page count/, + ); + // Never returns a partial snapshot: all 3 cap pages were fetched, then it + // throws because page 4 would still be needed. + assert.deepEqual(requestedPages, [1, 2, 3]); +}); + +test("fetchTraeCnUsage succeeds when maxPages exactly covers the total", async () => { + const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 10, total: 30 }); + const result = await fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 3 }); + assert.deepEqual(requestedPages, [1, 2, 3]); + assert.equal(result.sessions.length, 30); + assert.equal(result.pages_fetched, 3); +}); + +test("production inter-page delay constant is 300ms (tests inject delayMs: 0)", () => { + assert.equal(TRAE_CN_USAGE_DELAY_MS, 300); +}); + +// --------------------------------------------------------------------------- +// Storage-backed fetch with single 401/403 retry +// --------------------------------------------------------------------------- + +test("fetchTraeCnUsageWithAuth retries exactly once on 401 by re-reading storage", async () => { + const home = makeTraeCnHome(); + const storagePath = writeStorage(home, { token: "rotated.jwt.2", refreshToken: "r2" }); + const authCalls = []; + const usageCalls = []; + const readAuth = () => { + authCalls.push(fs.readFileSync(storagePath, "utf8")); + return readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }); + }; + const usageFetcher = async ({ jwt }) => { + usageCalls.push(jwt); + if (usageCalls.length === 1) { + const error = new Error("Trae CN usage API returned HTTP 401."); + error.status = 401; + error.code = "AUTH_EXPIRED"; + throw error; + } + return { sessions: [{ id: 7 }], total: 1, pages_fetched: 1, truncated: false }; + }; + const result = await fetchTraeCnUsageWithAuth({ + start_time: START, + end_time: END, + fetchImpl: async () => jsonResponse(200, {}), + delayMs: 0, + readAuth, + usageFetcher, + env: { [TRAE_CN_HOME_ENV]: home }, + platform: "darwin", + }); + assert.equal(usageCalls.length, 2); + assert.equal(usageCalls[0], "rotated.jwt.2"); + assert.equal(usageCalls[1], "rotated.jwt.2"); + assert.equal(authCalls.length, 2, "storage re-read once"); + assert.equal(result.sessions[0].id, 7); +}); + +test("fetchTraeCnUsageWithAuth never retries more than once", async () => { + const usageCalls = []; + const usageFetcher = async () => { + usageCalls.push(1); + const error = new Error("Trae CN usage API returned HTTP 403."); + error.status = 403; + error.code = "AUTH_EXPIRED"; + throw error; + }; + await assert.rejects( + fetchTraeCnUsageWithAuth({ + start_time: START, end_time: END, fetchImpl: async () => jsonResponse(200, {}), delayMs: 0, + readAuth: () => ({ token: "a.jwt.1", refreshToken: "r" }), + usageFetcher, + }), + /HTTP 403/, + ); + assert.equal(usageCalls.length, 2, "exactly two attempts"); +}); + +test("fetchTraeCnUsageWithAuth does not retry on non-auth errors", async () => { + const usageCalls = []; + const usageFetcher = async () => { + usageCalls.push(1); + const error = new Error("Trae CN usage API returned HTTP 500."); + error.status = 500; + throw error; + }; + await assert.rejects( + fetchTraeCnUsageWithAuth({ + start_time: START, end_time: END, fetchImpl: async () => jsonResponse(200, {}), delayMs: 0, + readAuth: () => ({ token: "a.jwt.1", refreshToken: "r" }), + usageFetcher, + }), + /HTTP 500/, + ); + assert.equal(usageCalls.length, 1); +}); + +test("fetchTraeCnUsageWithAuth fails when credentials are missing", async () => { + await assert.rejects( + fetchTraeCnUsageWithAuth({ start_time: START, end_time: END, readAuth: () => null, usageFetcher: async () => ({}) }), + /credentials are not configured/, + ); +}); diff --git a/test/trae-cn-parser.test.js b/test/trae-cn-parser.test.js new file mode 100644 index 000000000..ba4b98baf --- /dev/null +++ b/test/trae-cn-parser.test.js @@ -0,0 +1,504 @@ +/** + * Trae Work CN usage-API incremental parser tests. + * + * Synthetic rows only. Verifies the parser's contract: + * - raw normalization / prevalidation before any cursor/bucket mutation + * - per-session reconciliation (subtract old contribution, add new) with a + * versioned cursors.traeCn state and source "trae-cn" + * - fail-closed corruption / malformed-state handling + */ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { parseTraeCnApiIncremental } = require("../src/lib/rollout"); + +function tempQueue() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-trae-cn-")); + return { + dir, + queuePath: path.join(dir, "queue.jsonl"), + }; +} + +function queueRows(queuePath) { + if (!fs.existsSync(queuePath)) return []; + return fs.readFileSync(queuePath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function lastTraeRow(queuePath, model, hourStart) { + return queueRows(queuePath) + .filter( + (row) => + row.source === "trae-cn" && + row.model === model && + (hourStart === undefined || row.hour_start === hourStart), + ) + .at(-1); +} + +// Compare only the deterministic, meaningful state (drops updatedAt timestamps). +function stripState(cursors) { + const buckets = {}; + for (const [key, bucket] of Object.entries(cursors.hourly?.buckets || {})) { + buckets[key] = bucket?.totals; + } + const sessions = {}; + for (const [id, entry] of Object.entries(cursors.traeCn?.sessions || {})) { + sessions[id] = { model: entry.model, bucketStart: entry.bucketStart, totals: entry.totals }; + } + return { hourlyBuckets: buckets, sessions }; +} + +const T1 = 1_700_000_000; // 2023-11-14T22:13:20Z -> 22:00 bucket +const T2 = 1_700_002_000; // 2023-11-14T22:46:40Z -> 22:30 bucket +const B1 = "2023-11-14T22:00:00.000Z"; +const B2 = "2023-11-14T22:30:00.000Z"; + +function sessionRow(overrides = {}) { + return { + session_id: "s1", + model_name: "doubao-pro", + usage_time: T1, + input_token: 100, + output_token: 10, + cache_read_token: 0, + cache_write_token: 0, + ...overrides, + }; +} + +test("first insert maps direct and extra_info shapes into canonical buckets", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + let progressCalls = 0; + const onProgress = () => { + progressCalls += 1; + }; + const direct = sessionRow({ session_id: "s-direct", cache_read_token: 30, cache_write_token: 40 }); + const extra = { + session_id: "s-extra", + model_name: "doubao-lite", + usage_time: T2, + extra_info: { input_token: 5, output_token: 6, cache_read_token: 7, cache_write_token: 8 }, + }; + + const result = await parseTraeCnApiIncremental({ sessions: [direct, extra], cursors, queuePath, onProgress }); + assert.equal(result.recordsProcessed, 2); + assert.equal(result.eventsAggregated, 2); + assert.ok(result.bucketsQueued >= 1); + assert.ok(progressCalls > 0, "progress callback fires"); + + const rows = queueRows(queuePath).filter((row) => row.source === "trae-cn"); + const pro = rows.find((row) => row.model === "doubao-pro"); + const lite = rows.find((row) => row.model === "doubao-lite"); + assert.ok(pro, "direct row queued"); + assert.ok(lite, "extra_info row queued"); + + assert.equal(pro.hour_start, B1); + assert.deepEqual( + { + input: pro.input_tokens, + cached: pro.cached_input_tokens, + cacheCreation: pro.cache_creation_input_tokens, + output: pro.output_tokens, + reasoning: pro.reasoning_output_tokens, + total: pro.total_tokens, + billable: pro.billable_total_tokens, + conv: pro.conversation_count, + }, + { input: 100, cached: 30, cacheCreation: 40, output: 10, reasoning: 0, total: 180, billable: 180, conv: 1 }, + ); + + assert.equal(lite.hour_start, B2); + assert.equal(lite.input_tokens, 5); + assert.equal(lite.output_tokens, 6); + assert.equal(lite.cached_input_tokens, 7); + assert.equal(lite.cache_creation_input_tokens, 8); + assert.equal(lite.total_tokens, 26); + + assert.equal(cursors.traeCn.version, 1); + assert.equal(cursors.traeCn.sessions["s-direct"].model, "doubao-pro"); + assert.equal(cursors.traeCn.sessions["s-direct"].bucketStart, B1); + assert.equal(cursors.traeCn.sessions["s-direct"].totals.input_tokens, 100); + assert.equal(cursors.traeCn.sessions["s-extra"].bucketStart, B2); +}); + +test("identical payload twice appends no queue row and no token growth", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const row = sessionRow(); + + const first = await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath }); + assert.equal(first.eventsAggregated, 1); + const before = fs.readFileSync(queuePath, "utf8"); + + const second = await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath }); + assert.equal(second.recordsProcessed, 1); + assert.equal(second.eventsAggregated, 0); + assert.equal(second.bucketsQueued, 0); + assert.equal(fs.readFileSync(queuePath, "utf8"), before, "queue unchanged"); +}); + +test("growth and downward correction preserve a shared-bucket sibling", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const a = sessionRow({ session_id: "A" }); + const b = sessionRow({ session_id: "B", input_token: 200, output_token: 20 }); + + await parseTraeCnApiIncremental({ sessions: [a, b], cursors, queuePath }); + + const grow = await parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "A", input_token: 150 })], cursors, queuePath }); + assert.equal(grow.eventsAggregated, 1); + let latest = lastTraeRow(queuePath, "doubao-pro"); + assert.equal(latest.input_tokens, 200 + 150, "sibling B kept while A grows"); + assert.equal(latest.output_tokens, 20 + 10); + + const shrink = await parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "A", input_token: 60 })], cursors, queuePath }); + assert.equal(shrink.eventsAggregated, 1); + latest = lastTraeRow(queuePath, "doubao-pro"); + assert.equal(latest.input_tokens, 200 + 60, "sibling B kept while A shrinks"); + assert.equal(latest.total_tokens, 200 + 20 + 60 + 10); + assert.equal(cursors.traeCn.sessions.B.totals.input_tokens, 200, "sibling contribution preserved in cursor"); +}); + +test("model and time-bucket moves retract the old tuple and write the new one", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const row = sessionRow(); + + await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath }); + assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 110); + + // Model move: same bucket, different model. + const moved = await parseTraeCnApiIncremental({ sessions: [sessionRow({ model_name: "doubao-lite" })], cursors, queuePath }); + assert.equal(moved.eventsAggregated, 1); + assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 0, "old model tuple retracted to zero"); + assert.equal(lastTraeRow(queuePath, "doubao-lite", B1).total_tokens, 110); + + // Time-bucket move: same model, shifted half-hour bucket. + const shifted = await parseTraeCnApiIncremental({ sessions: [sessionRow({ model_name: "doubao-lite", usage_time: T2 })], cursors, queuePath }); + assert.equal(shifted.eventsAggregated, 1); + assert.equal(lastTraeRow(queuePath, "doubao-lite", B1).total_tokens, 0, "old time tuple retracted to zero"); + assert.equal(lastTraeRow(queuePath, "doubao-lite", B2).total_tokens, 110); +}); + +test("missing model_name values use the TRAE sentinel and reconcile without changing totals", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const missing = sessionRow({ session_id: "missing-model" }); + delete missing.model_name; + const nullModel = sessionRow({ session_id: "null-model", model_name: null }); + const blankModel = sessionRow({ session_id: "blank-model", model_name: " ", mode: "" }); + + await parseTraeCnApiIncremental({ sessions: [missing, nullModel, blankModel], cursors, queuePath }); + assert.equal(cursors.traeCn.sessions["missing-model"].model, "trae-cn-unknown"); + assert.equal(cursors.traeCn.sessions["null-model"].model, "trae-cn-unknown"); + assert.equal(cursors.traeCn.sessions["blank-model"].model, "trae-cn-unknown"); + assert.equal(lastTraeRow(queuePath, "trae-cn-unknown", B1).total_tokens, 330); + + await parseTraeCnApiIncremental({ + sessions: [sessionRow({ session_id: "transition", model_name: "doubao-pro" })], + cursors, + queuePath, + }); + await parseTraeCnApiIncremental({ + sessions: [sessionRow({ session_id: "transition", model_name: "" })], + cursors, + queuePath, + }); + assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 0); + assert.equal(lastTraeRow(queuePath, "trae-cn-unknown", B1).total_tokens, 440); + + await parseTraeCnApiIncremental({ + sessions: [sessionRow({ session_id: "transition", model_name: "doubao-pro" })], + cursors, + queuePath, + }); + assert.equal(lastTraeRow(queuePath, "trae-cn-unknown", B1).total_tokens, 330); + assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 110); +}); + +test("reordered payload and identical duplicate session records yield the same result", async (t) => { + const dirA = tempQueue(); + const dirB = tempQueue(); + t.after(() => { + fs.rmSync(dirA.dir, { recursive: true, force: true }); + fs.rmSync(dirB.dir, { recursive: true, force: true }); + }); + const cursorsA = {}; + const cursorsB = {}; + const a = sessionRow({ session_id: "A", input_token: 10, output_token: 1 }); + const b = sessionRow({ session_id: "B", model_name: "doubao-lite", usage_time: T2, input_token: 20, output_token: 2 }); + + await parseTraeCnApiIncremental({ sessions: [a, b], cursors: cursorsA, queuePath: dirA.queuePath }); + await parseTraeCnApiIncremental({ sessions: [b, a], cursors: cursorsB, queuePath: dirB.queuePath }); + assert.deepEqual(stripState(cursorsB), stripState(cursorsA), "order does not change the result"); + + // Identical duplicate session records are accepted once within one payload. + const dirC = tempQueue(); + t.after(() => fs.rmSync(dirC.dir, { recursive: true, force: true })); + const cursorsC = {}; + const dup = await parseTraeCnApiIncremental({ + sessions: [a, { ...a }], + cursors: cursorsC, + queuePath: dirC.queuePath, + }); + assert.equal(dup.recordsProcessed, 2); + assert.equal(dup.eventsAggregated, 1, "exact duplicate accepted once"); + assert.equal(cursorsC.traeCn.sessions.A.totals.input_tokens, 10); +}); + +test("conflicting duplicate ids and malformed rows fail before any mutation", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const canary = "session-id-canary"; + const row = sessionRow({ session_id: canary }); + + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [row, sessionRow({ session_id: canary, input_token: 200 })], cursors, queuePath }), + (error) => { + assert.match(error.message, /conflicting contributions/); + assert.ok(!error.message.includes(canary)); + return true; + }, + ); + assert.equal(cursors.hourly, undefined, "no hourly state assigned"); + assert.equal(cursors.traeCn, undefined, "no traeCn state assigned"); + assert.equal(fs.existsSync(queuePath), false, "no queue rows written"); + + // Malformed rows: missing token field must not be coerced to zero. + const missing = sessionRow({ session_id: canary }); + delete missing.cache_read_token; + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [missing], cursors, queuePath }), + (error) => { + assert.match(error.message, /invalid cache_read_token/); + assert.ok(!error.message.includes(canary)); + return true; + }, + ); + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, cache_read_token: "lots" })], cursors, queuePath }), + (error) => { + assert.match(error.message, /invalid cache_read_token/); + assert.ok(!error.message.includes(canary)); + return true; + }, + ); + // Model names containing "|" are rejected because bucket keys are unescaped. + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, model_name: "bad|model" })], cursors, queuePath }), + (error) => { + assert.match(error.message, /unsupported model name/); + assert.ok(!error.message.includes(canary)); + return true; + }, + ); + // Invalid usage_time / sessions / cursors. + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, usage_time: -5 })], cursors, queuePath }), + (error) => { + assert.match(error.message, /invalid usage_time/); + assert.ok(!error.message.includes(canary)); + return true; + }, + ); + await assert.rejects(parseTraeCnApiIncremental({ sessions: "nope", cursors, queuePath }), /sessions must be an array/); + await assert.rejects(parseTraeCnApiIncremental({ sessions: [], cursors: null, queuePath }), /cursors must be a writable object/); + + assert.equal(cursors.hourly, undefined); + assert.equal(cursors.traeCn, undefined); + assert.equal(fs.existsSync(queuePath), false); +}); + +test("empty payload is a no-op and preserves existing contributions", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const row = sessionRow(); + + await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath }); + const before = fs.readFileSync(queuePath, "utf8"); + + const empty = await parseTraeCnApiIncremental({ sessions: [], cursors, queuePath }); + assert.equal(empty.recordsProcessed, 0); + assert.equal(empty.eventsAggregated, 0); + assert.equal(empty.bucketsQueued, 0); + assert.equal(fs.readFileSync(queuePath, "utf8"), before, "no queue change"); + assert.equal(cursors.traeCn.sessions["s1"].totals.input_tokens, 100, "contribution preserved"); + assert.equal(lastTraeRow(queuePath, "doubao-pro").total_tokens, 110); +}); + +test("cursors survive a serialize/parse restart and a later correction", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + let cursors = {}; + const row = sessionRow(); + + await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath }); + cursors = JSON.parse(JSON.stringify(cursors)); + + // Identical re-run after restart is a no-op. + const before = fs.readFileSync(queuePath, "utf8"); + const noop = await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath }); + assert.equal(noop.eventsAggregated, 0); + assert.equal(noop.bucketsQueued, 0); + assert.equal(fs.readFileSync(queuePath, "utf8"), before); + + // Correction after restart retracts the old tuple and writes the new one. + const corrected = await parseTraeCnApiIncremental({ sessions: [sessionRow({ input_token: 120 })], cursors, queuePath }); + assert.equal(corrected.eventsAggregated, 1); + const latest = lastTraeRow(queuePath, "doubao-pro"); + assert.equal(latest.input_tokens, 120); + assert.equal(latest.total_tokens, 130); +}); + +test("unexpected cursor version fails closed instead of resetting", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = { traeCn: { version: 99, sessions: {} } }; + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [sessionRow()], cursors, queuePath }), + /version 99 is not supported/, + ); + assert.equal(cursors.traeCn.version, 99, "malformed state is not reset"); +}); + +test("a stored contribution exceeding its bucket fails closed without mutation", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const bucketKey = `trae-cn|doubao-pro|${B1}`; + const cursors = { + hourly: { + version: 3, + buckets: { + [bucketKey]: { + totals: { + input_tokens: 5, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + output_tokens: 5, + reasoning_output_tokens: 0, + total_tokens: 10, + billable_total_tokens: 10, + conversation_count: 1, + }, + queuedKey: null, + }, + }, + }, + traeCn: { + version: 1, + sessions: { + "session-id-canary": { + model: "doubao-pro", + bucketStart: B1, + totals: { + input_tokens: 100, + output_tokens: 10, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 110, + billable_total_tokens: 110, + conversation_count: 1, + }, + updatedAt: "x", + }, + }, + }, + }; + const before = JSON.stringify(cursors); + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "session-id-canary", input_token: 120 })], cursors, queuePath }), + (error) => { + assert.match(error.message, /corruption/); + assert.ok(!error.message.includes("session-id-canary")); + return true; + }, + ); + assert.equal(JSON.stringify(cursors), before, "cursor state untouched"); + assert.equal(fs.existsSync(queuePath), false); +}); + +test("failed enqueue leaves the serialized cursors byte-identical", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const row = sessionRow(); + + await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath }); + const before = JSON.stringify(cursors); + + // Corrected snapshot whose queuePath is an existing directory: enqueue's + // appendFile fails (EISDIR) after reconciliation already mutated the working + // state. The deep-cloned working states must keep cursors untouched. + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [sessionRow({ input_token: 120 })], cursors, queuePath: dir }), + ); + assert.equal(JSON.stringify(cursors), before, "cursors byte-identical after failed enqueue"); +}); + +test("an unrelated malformed stored session fails a new valid payload before mutation", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = { + traeCn: { + version: 1, + sessions: { + good: { + model: "doubao-pro", + bucketStart: B1, + totals: { + input_tokens: 100, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + output_tokens: 10, + reasoning_output_tokens: 0, + total_tokens: 110, + billable_total_tokens: 110, + conversation_count: 1, + }, + updatedAt: "x", + }, + bad: { + model: "doubao-lite", + bucketStart: B1, + totals: { + input_tokens: 1, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + output_tokens: 1, + reasoning_output_tokens: 1, // violates the canonical invariant + total_tokens: 2, + billable_total_tokens: 2, + conversation_count: 1, + }, + updatedAt: "x", + }, + }, + }, + }; + const before = JSON.stringify(cursors); + await assert.rejects( + parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "new-session" })], cursors, queuePath }), + /stored session totals are malformed/, + ); + assert.equal(JSON.stringify(cursors), before, "no mutation on malformed stored state"); + assert.equal(fs.existsSync(queuePath), false, "no queue rows written"); +}); From 79ab6b1bcfeb5fe19402b4221adedc18ae7f39cb Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Sun, 16 Aug 2026 14:26:42 +0800 Subject: [PATCH 02/19] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20TRAE=20CN=20C?= =?UTF-8?q?odeQL=20=E5=AE=89=E5=85=A8=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/trae-cn-config.js | 5 +++-- test/trae-cn-config.test.js | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js index b7124f465..2eafe0289 100644 --- a/src/lib/trae-cn-config.js +++ b/src/lib/trae-cn-config.js @@ -91,6 +91,7 @@ function deriveTraeCnKeyIv(salt) { crypto.createHash("sha512").update(salt).digest(), traeCnHardcodedPassword(), ]); + // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage. const kdfOut = crypto.createHash("sha512").update(kdfBuf).digest(); return { key: kdfOut.subarray(0, TRAE_CN_AES_KEY_LEN), @@ -262,7 +263,6 @@ async function fetchTraeCnUsagePage({ end_time, page_num, page_size = TRAE_CN_USAGE_PAGE_SIZE, - url = TRAE_CN_USAGE_URL, fetchImpl = fetch, timeoutMs = TRAE_CN_USAGE_TIMEOUT_MS, } = {}) { @@ -277,10 +277,11 @@ async function fetchTraeCnUsagePage({ const timer = setTimeout(() => controller.abort(), timeoutMs); let response; try { - response = await fetchImpl(url, { + response = await fetchImpl(TRAE_CN_USAGE_URL, { method: "POST", headers: { "Content-Type": "application/json", + // codeql[js/file-access-to-http]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted. Authorization: `Cloud-IDE-JWT ${jwt.trim()}`, }, body: JSON.stringify({ diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js index d447e34ec..b8d11dcba 100644 --- a/test/trae-cn-config.test.js +++ b/test/trae-cn-config.test.js @@ -67,6 +67,7 @@ function testPassword() { function testDerive(salt) { const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testPassword()]); + // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage. const out = crypto.createHash("sha512").update(kdfBuf).digest(); return { key: out.subarray(0, 16), iv: out.subarray(16, 32) }; } @@ -258,7 +259,14 @@ test("fetchTraeCnUsagePage sends the exact request", async () => { captured = { url, options }; return jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }], total: 1 }); }; - await fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl }); + await fetchTraeCnUsagePage({ + jwt: JWT, + start_time: START, + end_time: END, + page_num: 1, + url: "https://attacker.example/redirect", + fetchImpl, + }); assert.equal(captured.url, TRAE_CN_USAGE_URL); assert.equal(captured.options.method, "POST"); assert.equal(captured.options.headers["Content-Type"], "application/json"); From 9e59c13967eb90ba543e758724055ba4c678c82a Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Sun, 16 Aug 2026 14:43:19 +0800 Subject: [PATCH 03/19] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20TRAE=20CN=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.de.md | 2 +- README.ja.md | 6 +- README.ko.md | 6 +- README.md | 4 +- README.zh-CN.md | 2 +- dashboard/src/content/copy.csv | 1 + dashboard/src/content/i18n/zh-TW/core.json | 1 + dashboard/src/content/i18n/zh/core.json | 1 + dashboard/src/lib/provider-display.js | 3 +- dashboard/src/lib/provider-display.test.js | 4 +- dashboard/src/ui/marketing/agent-logos.js | 4 +- docs/PRIVACY.md | 4 +- src/commands/sync.js | 9 +- src/lib/trae-cn-config.js | 109 +++++++++++---------- test/discovery-metadata.test.js | 43 +++++++- test/sync-trae-cn.test.js | 56 +++++++++++ test/trae-cn-config.test.js | 65 ++++++++++++ 17 files changed, 247 insertions(+), 73 deletions(-) diff --git a/README.de.md b/README.de.md index 0d7a76c32..429353207 100644 --- a/README.de.md +++ b/README.de.md @@ -194,7 +194,7 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De | **Qoder** | ✅ Auto | Passiver SQLite-Reader (`Qoder/SharedClientCache/cache/db/local.db`; liest nur assistant-`token_info`, trennt Cache-Eingaben und liest keine Prompts oder Antworten) sowie Plan Credits und Ultimate-Gratisaufrufe aus der lokalen Qoder-Sitzung | | **AnythingLLM Desktop** | ✅ Auto | Passiver SQLite-Reader (`anythingllm-desktop/storage/anythingllm.db`, nur Token-Metriken pro Nachricht) | | **Claude Science** | ✅ Auto | Passiver SQLite-Reader (`~/.claude-science/operon-cli.db`, nur die Token-Zähler der `frames`-Tabelle; keine Prompts, Artefakte oder Forschungsinhalte). Kein natives Windows-Build — unter Windows läuft die App in WSL und wird von dort gelesen. | -| **TRAE Work CN** | ✅ Auto | Liest unter macOS die Session-Token-Nutzung der lokal angemeldeten App; die interne API kann sich ändern | +| **TRAE Work CN** | ✅ Auto | Liest bei vorhandener lokaler TRAE Work CN-Anmeldung während eines zulässigen Syncs außerhalb des Hintergrundbetriebs unter macOS die Session-Token-Nutzung der angemeldeten App; die interne API kann sich ändern | > **Muss ich Plugins oder Hooks manuell installieren?** Nein. `tokentracker` (oder `tokentracker init`) erledigt alles beim ersten Start: > - **Hook-basiert** (Claude Code, Codex, Gemini, Every Code, CodeBuddy, WorkBuddy, Grok Build) — wir schreiben einen SessionEnd-Hook oder TOML-Notify-Eintrag in die Konfiguration des Tools. diff --git a/README.ja.md b/README.ja.md index 8bd89b2b8..2fd494bf7 100644 --- a/README.ja.md +++ b/README.ja.md @@ -186,6 +186,7 @@ brew install xiufengsun/tokentracker/tokentracker | **Antigravity** | ✅ 自動 | パッシブなトランスクリプトリーダー (`~/.gemini/{antigravity,antigravity-ide,antigravity-cli}/brain/**/transcript.jsonl`) | | **pi** (`@mariozechner/pi-coding-agent`) | ✅ 自動 | パッシブリーダー (`~/.pi/agent/sessions/**/*.jsonl`) | | **Craft Agents** | ✅ 自動 | パッシブなセッションリーダー (`~/.craft-agent` + workspace session logs) | +| **Reasonix** | ✅ 自動 | パッシブなテレメトリリーダー (`~/.reasonix/**/*.jsonl.telemetry.json`) | | **Roo Code** (VS Code 拡張) | ✅ 自動 | パッシブな `ui_messages.json` リーダー (`rooveterinaryinc.roo-cline`) | | **Zed Agent** | ✅ 自動 | パッシブな SQLite リーダー (`threads.db`、hosted `zed.dev` models only) | | **Goose** (Block) | ✅ 自動 | パッシブな SQLite リーダー (`sessions.db`、cumulative deltas) | @@ -195,12 +196,13 @@ brew install xiufengsun/tokentracker/tokentracker | **Qoder** | ✅ 自動 | パッシブ SQLite リーダー (`Qoder/SharedClientCache/cache/db/local.db`; assistant の `token_info` のみを読み、キャッシュ入力を分離。prompt/response は読みません) と、Qoder ローカルセッションからの Plan Credits / Ultimate 無料呼び出し上限 | | **AnythingLLM Desktop** | ✅ 自動 | パッシブな SQLite リーダー (`anythingllm-desktop/storage/anythingllm.db`、メッセージごとの token 指標のみ) | | **Claude Science** | ✅ 自動 | パッシブな SQLite リーダー (`~/.claude-science/operon-cli.db`、`frames` テーブルの token カウンタのみ。prompt・成果物・研究内容は読みません)。ネイティブ Windows 版はなく、Windows では WSL 内で動作するアプリを読み取ります。 | -| **TRAE Work CN** | ✅ 自動 | macOS のローカルサインイン済みアプリから session-token 使用量を読み取ります。内部 API は変更される可能性があります | +| **DeepSeek Harness** | ✅ 自動 | パッシブなセッションリーダー (`~/.dsh/sessions/**/session.jsonl[.zstd]`、セッションヘッダーと assistant イベントを解析し、複数フレームの zstd 展開に対応) | +| **TRAE Work CN** | ✅ 自動 | ローカル TRAE Work CN のサインイン認証がある場合、実行可能な非バックグラウンド同期中に macOS のサインイン済みアプリから session-token 使用量を読み取ります。内部 API は変更される可能性があります | > **プラグインや hook を手動でインストールする必要はありますか?** いいえ。`tokentracker`(または `tokentracker init`)が初回実行ですべて処理します: > - **Hook ベース**のツール (Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**) — ツール自身の設定に SessionEnd hook または TOML notify エントリーを書き込みます。 > - **プラグインベース**のツール (OpenCode、**OpenClaw**) — プラグインは npm パッケージ内に同梱されています。OpenClaw のセッションプラグインは `~/.tokentracker/tracker/openclaw-plugin/openclaw-session-sync/` にあり、OpenClaw 自身の CLI でリンクして有効化したうえで、同期を起動するセッション終了イベントを許可するために `hooks.allowConversationAccess=true` を設定します。ダウンロードもドラッグ&ドロップも不要です。 -> - **パッシブリーダー** (Cursor、Kiro、Hermes、Kimi Code、Copilot、**Grok Build**、**oh-my-pi**、**pi**、**Craft Agents**、**Kilo CLI**、**Kilo Code**、**Roo Code**、**Antigravity**、**Zed Agent**、**Goose**、**Droid**、**Mimo Code**、**ZCode**、**AnythingLLM Desktop**、**Claude Science**) — これらのツールには何もインストールしません。ツールがすでに出力しているファイル (SQLite DB、JSONL、OTEL エクスポート、session logs) を読むだけです。Copilot App / CLI の使用量は `~/.copilot/session-store.db` からリクエスト単位で読み取ります。`data.db` は旧データ移行時のベースラインとして一度だけ使い、store が正規ソースになった後は監視専用です。Chat 拡張と旧 CLI は引き続き OTEL を使用し、重複するリクエストは TokenTracker が一度だけ集計します。移行前の混在 App/CLI 履歴でモデルを安全に分離できない残量は、推測したリクエストモデルではなく `github-copilot-legacy` の集計値として保持します。 +> - **パッシブリーダー** (Cursor、Kiro、Hermes、Kimi Code、Copilot、**Grok Build**、**oh-my-pi**、**pi**、**Craft Agents**、**Reasonix**、**Kilo CLI**、**Kilo Code**、**Roo Code**、**Antigravity**、**Zed Agent**、**Goose**、**Droid**、**Mimo Code**、**ZCode**、**AnythingLLM Desktop**、**Claude Science**、**DeepSeek Harness**) — これらのツールには何もインストールしません。ツールがすでに出力しているファイル (SQLite DB、JSONL、OTEL エクスポート、session logs) を読むだけです。Copilot App / CLI の使用量は `~/.copilot/session-store.db` からリクエスト単位で読み取ります。`data.db` は旧データ移行時のベースラインとして一度だけ使い、store が正規ソースになった後は監視専用です。Chat 拡張と旧 CLI は引き続き OTEL を使用し、重複するリクエストは TokenTracker が一度だけ集計します。移行前の混在 App/CLI 履歴でモデルを安全に分離できない残量は、推測したリクエストモデルではなく `github-copilot-legacy` の集計値として保持します。 > - **Grok Build の推定** — 現在のローカルテレメトリは `updates.jsonl` の累積 `totalTokens` を公開していますが、安定したプロンプト/出力/キャッシュの内訳はありません。`signals.json` は `contextTokensUsed` のスナップショットを使ったフォールバックとして残っています。コールごとの利用詳細が利用可能になるまで、TokenTracker は Grok のコストを推定します。 > > いつでも `tokentracker status` を実行すれば、各統合の状態を確認できます。`skipped` と表示されている場合、`detail` 列にその理由が示されます(例: ツール CLI が `PATH` にない、設定が読めない)。 diff --git a/README.ko.md b/README.ko.md index d7406d361..77e5de392 100644 --- a/README.ko.md +++ b/README.ko.md @@ -186,6 +186,7 @@ brew install xiufengsun/tokentracker/tokentracker | **Antigravity** | ✅ 자동 | 패시브 트랜스크립트 리더 (`~/.gemini/{antigravity,antigravity-ide,antigravity-cli}/brain/**/transcript.jsonl`) | | **pi** (`@mariozechner/pi-coding-agent`) | ✅ 자동 | 패시브 리더 (`~/.pi/agent/sessions/**/*.jsonl`) | | **Craft Agents** | ✅ 자동 | 패시브 세션 리더 (`~/.craft-agent` + workspace session logs) | +| **Reasonix** | ✅ 자동 | 패시브 텔레메트리 리더 (`~/.reasonix/**/*.jsonl.telemetry.json`) | | **Roo Code** (VS Code 확장) | ✅ 자동 | 패시브 `ui_messages.json` 리더 (`rooveterinaryinc.roo-cline`) | | **Zed Agent** | ✅ 자동 | 패시브 SQLite 리더 (`threads.db`, hosted `zed.dev` models only) | | **Goose** (Block) | ✅ 자동 | 패시브 SQLite 리더 (`sessions.db`, cumulative deltas) | @@ -195,12 +196,13 @@ brew install xiufengsun/tokentracker/tokentracker | **Qoder** | ✅ 자동 | 패시브 SQLite 리더 (`Qoder/SharedClientCache/cache/db/local.db`; assistant `token_info`만 읽고 캐시 입력을 분리하며 prompt/response는 읽지 않음) 및 Qoder 로컬 세션의 Plan Credits / Ultimate 무료 호출 한도 | | **AnythingLLM Desktop** | ✅ 자동 | 패시브 SQLite 리더 (`anythingllm-desktop/storage/anythingllm.db`, 메시지별 token 지표만 읽음) | | **Claude Science** | ✅ 자동 | 패시브 SQLite 리더 (`~/.claude-science/operon-cli.db`, `frames` 테이블의 token 카운터만 읽으며 prompt·산출물·연구 내용은 읽지 않음). 네이티브 Windows 빌드가 없어 Windows에서는 WSL 안에서 실행되는 앱을 읽습니다. | -| **TRAE Work CN** | ✅ 자동 | macOS에서 로컬 로그인된 앱의 session-token 사용량을 읽습니다. 내부 API는 변경될 수 있습니다 | +| **DeepSeek Harness** | ✅ 자동 | 패시브 세션 리더 (`~/.dsh/sessions/**/session.jsonl[.zstd]`, 세션 헤더와 assistant 이벤트를 파싱하고 멀티 프레임 zstd 압축 해제를 지원) | +| **TRAE Work CN** | ✅ 자동 | 로컬 TRAE Work CN 인증이 있을 때 실행 가능한 비백그라운드 동기화 중에 macOS의 로그인된 앱에서 session-token 사용량을 읽습니다. 내부 API는 변경될 수 있습니다 | > **플러그인이나 hook을 수동으로 설치해야 하나요?** 아니요. `tokentracker` (또는 `tokentracker init`)가 첫 실행에서 모든 것을 처리합니다: > - **Hook 기반** 도구 (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — 도구 자체의 설정에 SessionEnd hook 또는 TOML notify 엔트리를 작성합니다. > - **플러그인 기반** 도구 (OpenCode, **OpenClaw**) — 플러그인은 npm 패키지 안에 포함되어 있습니다. OpenClaw 세션 플러그인은 `~/.tokentracker/tracker/openclaw-plugin/openclaw-session-sync/`에 있으며, OpenClaw 자체 CLI로 링크하고 활성화한 뒤 동기화를 트리거하는 세션 종료 이벤트를 허용하도록 `hooks.allowConversationAccess=true`를 설정합니다. 다운로드, 드래그 앤 드롭 불필요. -> - **패시브 리더** (Cursor, Kiro, Hermes, Kimi Code, Copilot, **Grok Build**, **oh-my-pi**, **pi**, **Craft Agents**, **Kilo CLI**, **Kilo Code**, **Roo Code**, **Antigravity**, **Zed Agent**, **Goose**, **Droid**, **Mimo Code**, **ZCode**, **AnythingLLM Desktop**, **Claude Science**) — 이들 도구에는 아무것도 설치하지 않습니다. 도구가 이미 생성하는 파일 (SQLite DB, JSONL, OTEL export, session logs)만 읽습니다. Copilot App / CLI 사용량은 `~/.copilot/session-store.db`에서 요청별로 읽습니다. `data.db`는 레거시 마이그레이션 기준선으로 한 번만 사용하며 store가 정식 소스가 된 뒤에는 관찰 전용으로 유지됩니다. Chat 확장과 이전 CLI는 계속 OTEL을 사용하며, TokenTracker가 겹치는 요청을 한 번만 집계합니다. 마이그레이션 전 혼합 App/CLI 기록에서 모델을 안전하게 분리할 수 없는 잔여분은 추정 모델 대신 `github-copilot-legacy` 집계로 보존합니다. +> - **패시브 리더** (Cursor, Kiro, Hermes, Kimi Code, Copilot, **Grok Build**, **oh-my-pi**, **pi**, **Craft Agents**, **Reasonix**, **Kilo CLI**, **Kilo Code**, **Roo Code**, **Antigravity**, **Zed Agent**, **Goose**, **Droid**, **Mimo Code**, **ZCode**, **AnythingLLM Desktop**, **Claude Science**, **DeepSeek Harness**) — 이들 도구에는 아무것도 설치하지 않습니다. 도구가 이미 생성하는 파일 (SQLite DB, JSONL, OTEL export, session logs)만 읽습니다. Copilot App / CLI 사용량은 `~/.copilot/session-store.db`에서 요청별로 읽습니다. `data.db`는 레거시 마이그레이션 기준선으로 한 번만 사용하며 store가 정식 소스가 된 뒤에는 관찰 전용으로 유지됩니다. Chat 확장과 이전 CLI는 계속 OTEL을 사용하며, TokenTracker가 겹치는 요청을 한 번만 집계합니다. 마이그레이션 전 혼합 App/CLI 기록에서 모델을 안전하게 분리할 수 없는 잔여분은 추정 모델 대신 `github-copilot-legacy` 집계로 보존합니다. > - **Grok Build 추정** — 현재 로컬 텔레메트리는 `updates.jsonl`의 누적 `totalTokens`를 노출하지만, 안정적인 프롬프트/출력/캐시 분할은 제공하지 않습니다; `signals.json`은 `contextTokensUsed` 스냅샷을 사용한 폴백으로 남아 있습니다. 호출별 사용 상세 정보가 제공될 때까지 TokenTracker는 Grok 비용을 추정합니다. > > 언제든 `tokentracker status`로 각 통합의 상태를 확인할 수 있습니다. `skipped`로 표시되면 `detail` 컬럼이 이유를 설명합니다 (예: 도구 CLI가 `PATH`에 없음, 설정 읽기 불가). diff --git a/README.md b/README.md index 21a6dce28..2ac4476c3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No TokenTracker account, no API keys, no setup. -> **TRAE Work CN note:** On macOS, TokenTracker reads real session-token usage from the locally signed-in TRAE Work CN app. Its internal API may change. +> **TRAE Work CN note:** On macOS, during eligible non-background sync when local TRAE Work CN auth exists, TokenTracker reads usage metadata from the locally signed-in app; no prompts are sent, and its internal API may change. [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -260,7 +260,7 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag | **AnythingLLM Desktop** | ✅ Auto | Passive SQLite reader (`anythingllm-desktop/storage/anythingllm.db`; reads per-message token metrics only, never prompts or responses) | | **Claude Science** | ✅ Auto | Passive SQLite reader (`~/.claude-science/operon-cli.db`; reads the `frames` table's token counters only, never prompts, artifacts or research content). No native Windows build — on Windows the app runs inside WSL and is read from there. | | **DeepSeek Harness** | ✅ Auto | Passive session reader (`~/.dsh/sessions/**/session.jsonl[.zstd]`; parses session header and assistant events, handles multi-frame zstd decompression) | -| **TRAE Work CN** | ✅ Auto | macOS session-token usage from the locally signed-in app; reads its internal API, which may change | +| **TRAE Work CN** | ✅ Auto | During eligible non-background sync when local TRAE Work CN auth exists, reads session-token usage from the locally signed-in macOS app; its internal API may change | > **Do I need to install any plugin or hook manually?** No. `tokentracker` (or `tokentracker init`) handles everything on first run: > - **Hook-based** tools (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — we write a SessionEnd hook or TOML notify entry into the tool's own config. diff --git a/README.zh-CN.md b/README.zh-CN.md index b849b5ce6..480537f81 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -199,7 +199,7 @@ brew install xiufengsun/tokentracker/tokentracker | **AnythingLLM Desktop** | ✅ 自动 | 被动 SQLite 读取(`anythingllm-desktop/storage/anythingllm.db`;只读取每条消息的 token 指标,不读取 prompt 或回复) | | **Claude Science** | ✅ 自动 | 被动 SQLite 读取(`~/.claude-science/operon-cli.db`;只读取 `frames` 表的 token 计数,不读取 prompt、产物或研究内容)。没有原生 Windows 版——Windows 上该应用运行在 WSL 内,从 WSL 中读取。 | | **DeepSeek Harness** | ✅ 自动 | 被动读取会话日志(`~/.dsh/sessions/**/session.jsonl[.zstd]`;解析会话头部与 assistant 事件,支持多帧 zstd 解压) | -| **TRAE Work CN** | ✅ 自动 | macOS 上读取本地登录的 TRAE Work CN 应用的 session-token 用量;内部 API 可能变化 | +| **TRAE Work CN** | ✅ 自动 | 仅在存在本地 TRAE Work CN 登录授权的可执行非后台同步期间,读取 macOS 本地登录应用的 session-token 用量;内部 API 可能变化 | > **需要手动装什么插件 / hook 吗?** 不需要。`tokentracker`(或 `tokentracker init`)第一次跑的时候会全部搞定: > - **基于 hook 的工具**(Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**)—— 我们把 SessionEnd hook 或 TOML notify 条目写入它们自己的配置文件 diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 8a18a2fcd..166c5b49a 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -736,6 +736,7 @@ limits.provider.antigravity,ui,LimitsPage,UsageLimitsPanel,provider_antigravity, limits.provider.zcode,ui,LimitsPage,UsageLimitsPanel,provider_zcode,"ZCode",,active provider.display.omp,ui,*,ProviderDisplay,omp,"oh-my-pi",,active provider.display.deepseek_harness,ui,*,ProviderDisplay,deepseek_harness,"DeepSeek Harness",,active +provider.display.trae_work_cn,ui,*,ProviderDisplay,trae_work_cn,"TRAE Work CN",,active limits.label.zcode_glm52,ui,LimitsPage,UsageLimitsPanel,zcode_glm52,"GLM-5.2",,active limits.label.zcode_glm5t,ui,LimitsPage,UsageLimitsPanel,zcode_glm5t,"GLM-5-Turbo",,active limits.label.zcode_5h,ui,LimitsPage,UsageLimitsPanel,zcode_5h,"5h",,active diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json index bb94cb579..5b9aa5e4c 100644 --- a/dashboard/src/content/i18n/zh-TW/core.json +++ b/dashboard/src/content/i18n/zh-TW/core.json @@ -154,6 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", + "provider.display.trae_work_cn": "TRAE Work CN", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json index d00d8f42e..400a69e59 100644 --- a/dashboard/src/content/i18n/zh/core.json +++ b/dashboard/src/content/i18n/zh/core.json @@ -154,6 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", + "provider.display.trae_work_cn": "TRAE Work CN", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/lib/provider-display.js b/dashboard/src/lib/provider-display.js index c1b985f29..ba59242fd 100644 --- a/dashboard/src/lib/provider-display.js +++ b/dashboard/src/lib/provider-display.js @@ -6,13 +6,14 @@ const SPECIAL_PROVIDER_NAMES = { pianthropic: "Pi · Anthropic", pigithubcopilot: "Pi · GitHub Copilot", picopilot: "Pi · Copilot", - traecn: "TRAE Work CN", }; const SPECIAL_PROVIDER_COPY_KEYS = { deepseek: "provider.display.deepseek_harness", dsh: "provider.display.deepseek_harness", omp: "provider.display.omp", + traecn: "provider.display.trae_work_cn", + traeworkcn: "provider.display.trae_work_cn", }; function normalizedProviderKey(value) { diff --git a/dashboard/src/lib/provider-display.test.js b/dashboard/src/lib/provider-display.test.js index 611a82e84..bedcf4ead 100644 --- a/dashboard/src/lib/provider-display.test.js +++ b/dashboard/src/lib/provider-display.test.js @@ -30,8 +30,8 @@ describe("formatProviderDisplayName", () => { expect(formatProviderDisplayName("deepseek")).toBe("DeepSeek Harness"); }); - it.each(["trae-cn", "TRAE-CN", "Trae_Cn"])( - "formats %s as TRAE Work CN", + it.each(["trae-cn", "TRAE-CN", "Trae_Cn", "TRAE Work CN"])( + "uses the catalog-backed TRAE Work CN name for %s", (value) => { expect(formatProviderDisplayName(value)).toBe("TRAE Work CN"); }, diff --git a/dashboard/src/ui/marketing/agent-logos.js b/dashboard/src/ui/marketing/agent-logos.js index 5481d3e7b..02fbb414d 100644 --- a/dashboard/src/ui/marketing/agent-logos.js +++ b/dashboard/src/ui/marketing/agent-logos.js @@ -1,6 +1,8 @@ // Canonical list of supported coding agents, in display order. Shared by the // marketing landing carousel and the dashboard auth/expired gate so the two // surfaces can't drift. Names are tooltip/a11y metadata only. +import { copy } from "../../lib/copy"; + export const AGENT_LOGOS = [ { id: 1, name: "Claude Code", provider: "claude" }, { id: 2, name: "Codex", provider: "codex" }, @@ -33,5 +35,5 @@ export const AGENT_LOGOS = [ { id: 29, name: "AnythingLLM", provider: "anythingllm" }, { id: 30, name: "Claude Science", provider: "claude-science" }, { id: 31, name: "DeepSeek Harness", provider: "dsh" }, - { id: 32, name: "TRAE Work CN", provider: "trae-cn" }, + { id: 32, name: copy("provider.display.trae_work_cn"), provider: "trae-cn" }, ]; diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 2f5bf6ac5..ecc068c10 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -10,7 +10,7 @@ This document lists **every** network request the software can make, what each o ## 1. What never leaves your machine -TokenTracker's parsers extract numbers and timestamps only. TRAE Work CN is a narrow exception to the local-only source model: during a usage sync, TokenTracker transmits the existing sign-in authorization from the locally signed-in app to TRAE's internal API for a read-only usage request. That authorization is never persisted or logged. +TokenTracker's parsers extract numbers and timestamps only. TRAE Work CN is a narrow exception to the local-only source model: during an eligible non-background sync, when local TRAE Work CN auth exists, TokenTracker transmits the existing sign-in authorization from the locally signed-in app to TRAE's internal API for a read-only usage request. It is not an unconditional or default generic network request. That authorization is never persisted or logged. **Apart from the disclosed TRAE Work CN authorization, never read or recorded anywhere:** @@ -54,7 +54,7 @@ To erase everything TokenTracker knows about you, delete that directory. `tokent | **Anonymous heartbeat** | `srctyff5.us-east.insforge.app` | A one-way hash of the machine id, app version, OS platform, and shell (`cli` / `mac` / `win`). Nothing else. | At most once per day | | **Dashboard analytics** | `us.i.posthog.com` (PostHog) | Pageviews and explicitly instrumented feature events, plus which shell you use. Autocapture and session recording are **off**; browser Do-Not-Track is respected. | While the dashboard is open | | **Provider quota reads** | The provider's own API (`api.anthropic.com`, `chatgpt.com`, `cursor.com`, `api.github.com`, `api.kimi.com`, `api.z.ai`, `qoder.com`, `qoder.com.cn`, `openapi.qoder.sh`, `openapi.qoder.com.cn`, `cloudcode-pa.googleapis.com`, …) | Whatever that provider's own endpoint requires, authenticated with the credentials **that provider already stored on your machine**. These requests go directly from your machine to the provider — they never pass through our servers, and we never see the response. | While quota bars are visible | -| **TRAE Work CN usage read** | TRAE's internal API | Transmits the existing sign-in authorization from the locally signed-in TRAE Work CN app to TRAE; reads usage metadata only. TokenTracker never persists or logs the auth token or prompt/response content. | During TRAE Work CN usage sync | +| **TRAE Work CN usage read** | TRAE's internal API | Transmits the existing sign-in authorization from the locally signed-in TRAE Work CN app to TRAE; reads usage metadata only. TokenTracker never persists or logs the auth token or prompt/response content. | During eligible non-background sync when local TRAE Work CN auth exists | | **GitHub star count** | `api.github.com` | Nothing but the request itself (public repo metadata) | On dashboard load | | **Update check** | `api.github.com` | Nothing but the request itself | Windows: once at launch. macOS: only when you click "Check for Updates" | diff --git a/src/commands/sync.js b/src/commands/sync.js index 3d8f5b07c..5dc7d31f3 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -1684,13 +1684,16 @@ async function cmdSync(argv, context = {}) { // excludes both ordinary background and `--auto --background // --all-local-sources`. Fetching goes through the storage-backed Phase A // helper so its single 401/403 reread/retry is used; an empty response is a - // successful no-op; any fetch/page/schema/auth failure skips the parser and - // leaves prior data untouched while unrelated providers continue. + // successful no-op; complete snapshots are bounded to 100 pages/2,000 rows; + // over-capacity or other fetch/page/schema/auth failures skip the parser and + // leave prior data untouched while unrelated providers continue. let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; + const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home }); if ( !isBackgroundLightweightSync && sourceAllowed("trae-cn") && - resolveTraeCnStoragePath({ env: process.env, home }) + traeCnStoragePath && + fssync.existsSync(traeCnStoragePath) ) { const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now(); const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch; diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js index 2eafe0289..c9ca3f954 100644 --- a/src/lib/trae-cn-config.js +++ b/src/lib/trae-cn-config.js @@ -275,61 +275,63 @@ async function fetchTraeCnUsagePage({ assertUsageParams({ start_time, end_time, page_size }); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); - let response; try { - response = await fetchImpl(TRAE_CN_USAGE_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - // codeql[js/file-access-to-http]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted. - Authorization: `Cloud-IDE-JWT ${jwt.trim()}`, - }, - body: JSON.stringify({ - usage_type: TRAE_CN_USAGE_TYPE, - start_time, - end_time, - page_num, - page_size, - }), - signal: controller.signal, - }); - } catch (_error) { - throw new Error("Trae CN usage API request failed."); + let response; + try { + response = await fetchImpl(TRAE_CN_USAGE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted. + Authorization: `Cloud-IDE-JWT ${jwt.trim()}`, + }, + body: JSON.stringify({ + usage_type: TRAE_CN_USAGE_TYPE, + start_time, + end_time, + page_num, + page_size, + }), + signal: controller.signal, + }); + } catch (_error) { + throw new Error("Trae CN usage API request failed."); + } + if (response.status === 401 || response.status === 403) { + throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status }); + } + if (!response.ok) { + throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status }); + } + let parsed; + try { + parsed = await response.json(); + } catch (_error) { + throw new Error("Trae CN usage API returned a non-JSON response."); + } + const apiCode = parsed?.data?.code ?? parsed?.code; + if (apiCode !== undefined && Number(apiCode) !== 0) { + throw usageError(`Trae CN usage API returned error code ${apiCode}.`, { + status: response.status, + apiCode: Number(apiCode), + }); + } + // Documented wrapper: data.user_usage_group_by_sessions, or the top-level + // equivalent. Only this module parses the wrapper; token rows/buckets are + // normalized elsewhere. A body without the session-array wrapper fails + // closed as a schema error instead of being misread as an empty result; + // an explicit empty array remains valid. + const data = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed; + if (!Array.isArray(data?.user_usage_group_by_sessions)) { + throw new Error("Trae CN usage API response is missing the session list."); + } + const sessions = data.user_usage_group_by_sessions; + const rawTotal = data?.total; + const total = rawTotal === undefined || rawTotal === null ? null : Number(rawTotal); + return { sessions, total, page_num, page_size }; } finally { clearTimeout(timer); } - if (response.status === 401 || response.status === 403) { - throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status }); - } - if (!response.ok) { - throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status }); - } - let parsed; - try { - parsed = await response.json(); - } catch (_error) { - throw new Error("Trae CN usage API returned a non-JSON response."); - } - const apiCode = parsed?.data?.code ?? parsed?.code; - if (apiCode !== undefined && Number(apiCode) !== 0) { - throw usageError(`Trae CN usage API returned error code ${apiCode}.`, { - status: response.status, - apiCode: Number(apiCode), - }); - } - // Documented wrapper: data.user_usage_group_by_sessions, or the top-level - // equivalent. Only this module parses the wrapper; token rows/buckets are - // normalized elsewhere. A body without the session-array wrapper fails - // closed as a schema error instead of being misread as an empty result; - // an explicit empty array remains valid. - const data = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed; - if (!Array.isArray(data?.user_usage_group_by_sessions)) { - throw new Error("Trae CN usage API response is missing the session list."); - } - const sessions = data.user_usage_group_by_sessions; - const rawTotal = data?.total; - const total = rawTotal === undefined || rawTotal === null ? null : Number(rawTotal); - return { sessions, total, page_num, page_size }; } /** @@ -362,6 +364,11 @@ async function fetchTraeCnUsage({ const result = await pageFetcher({ jwt, start_time, end_time, page_num: page, page_size, fetchImpl }); pagesFetched += 1; total = result.total; + if (pagesFetched === 1 && Number.isFinite(total) && total > page_size * maxPages) { + const error = new Error("Trae CN usage snapshot exceeds the supported capacity."); + error.code = "TRAE_CN_USAGE_CAPACITY_EXCEEDED"; + throw error; + } if (Array.isArray(result.sessions)) sessions.push(...result.sessions); if (result.sessions.length === 0) break; if (total !== null && sessions.length >= total) break; diff --git a/test/discovery-metadata.test.js b/test/discovery-metadata.test.js index 2327a380c..11f052e31 100644 --- a/test/discovery-metadata.test.js +++ b/test/discovery-metadata.test.js @@ -8,6 +8,41 @@ const path = require("node:path"); const ROOT = path.resolve(__dirname, ".."); const read = (relativePath) => fs.readFileSync(path.join(ROOT, relativePath), "utf8"); +const CANONICAL_PROVIDERS = [ + "Claude Code", + "Codex CLI", + "Cursor", + "Gemini CLI", + "Antigravity", + "Kiro", + "OpenCode", + "OpenClaw", + "Every Code", + "Hermes Agent", + "GitHub Copilot", + "Kimi Code", + "CodeBuddy", + "WorkBuddy", + "Grok Build", + "oh-my-pi", + "pi", + "Craft Agents", + "Reasonix", + "Kilo CLI", + "Kilo Code", + "Roo Code", + "Zed Agent", + "Goose", + "Droid", + "Mimo Code", + "ZCode", + "Qoder", + "AnythingLLM Desktop", + "Claude Science", + "DeepSeek Harness", + "TRAE Work CN", +]; + const README_EXPECTATIONS = [ ["README.md", /32 AI coding tools/, /\|\s+\*\*AI tools supported\*\*\s+\|\s+\*\*32\*\*/, /Rate-limit tracking.*✅ 13 providers/], ["README.zh-CN.md", /32 款 AI 编码工具/, /\|\s+\*\*支持的 AI 工具数\*\*\s+\|\s+\*\*32\*\*/, /限额追踪.*✅ 13 家 provider/], @@ -21,11 +56,9 @@ test("public discovery surfaces describe all 32 supported tools", () => { const source = read(file); assert.match(source, countPattern, `${file} has the current provider count`); assert.match(source, comparisonPattern, `${file} comparison table has the current provider count`); - assert.match(source, /Droid/, `${file} lists Droid`); - assert.match(source, /AnythingLLM Desktop/, `${file} lists AnythingLLM Desktop`); - assert.match(source, /Qoder/, `${file} lists Qoder`); - assert.match(source, /DeepSeek Harness/, `${file} lists DeepSeek Harness`); - assert.match(source, /TRAE Work CN/, `${file} lists TRAE Work CN`); + for (const provider of CANONICAL_PROVIDERS) { + assert.match(source, new RegExp(provider), `${file} lists ${provider}`); + } assert.match(source, limitCountPattern, `${file} rate-limit row carries the current usage-limits provider count`); } diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js index b7647b5af..91040c6fd 100644 --- a/test/sync-trae-cn.test.js +++ b/test/sync-trae-cn.test.js @@ -230,6 +230,62 @@ test("background and all-local syncs never call the TRAE CN fetch", async () => }); }); +test("an absent TRAE CN storage file does not call the fetch", async () => { + await withTempTraeEnv(async ({ home }) => { + let fetchCalls = 0; + const fetchImpl = async () => { + fetchCalls += 1; + throw new Error("should not fetch"); + }; + + await cmdSync(["--auto", "--source=trae-cn"], { + traeCnFetchImpl: fetchImpl, + traeCnNowMs: NOW_MS, + }); + + assert.equal(fetchCalls, 0); + assert.equal(readQueueRows(home).some((row) => row.source === "trae-cn"), false); + assert.equal(readCursors(home).traeCn, undefined); + }); +}); + +test("over-capacity TRAE CN fetch preserves the prior queue and cursor bytes", async () => { + await withTempTraeEnv(async ({ home, traeCnHome }) => { + writeTraeCnStorage(traeCnHome); + const initialFetch = traeFetchOnce({ + user_usage_group_by_sessions: [sampleSession(Math.floor(NOW_MS / 1000) - 86400)], + total: 1, + }); + await cmdSync(["--auto", "--source=trae-cn"], { + traeCnFetchImpl: initialFetch, + traeCnNowMs: NOW_MS, + }); + + const queuePath = path.join(home, ".tokentracker", "tracker", "queue.jsonl"); + const cursorsPath = path.join(home, ".tokentracker", "tracker", "cursors.json"); + const queueBefore = fs.readFileSync(queuePath, "utf8"); + const traeCursorBefore = JSON.stringify(JSON.parse(fs.readFileSync(cursorsPath, "utf8")).traeCn); + const overCapacityRequests = []; + const overCapacityFetch = traeFetchOnce( + { + user_usage_group_by_sessions: [sampleSession(Math.floor(NOW_MS / 1000) - 86400)], + total: 2001, + }, + overCapacityRequests, + ); + + await cmdSync(["--auto", "--source=trae-cn"], { + traeCnFetchImpl: overCapacityFetch, + traeCnNowMs: NOW_MS, + }); + + assert.equal(overCapacityRequests.length, 1, "capacity failure stops after page one"); + assert.equal(fs.readFileSync(queuePath, "utf8"), queueBefore, "prior queue remains byte-identical"); + const traeCursorAfter = JSON.stringify(JSON.parse(fs.readFileSync(cursorsPath, "utf8")).traeCn); + assert.equal(traeCursorAfter, traeCursorBefore, "prior TRAE cursor remains byte-identical"); + }); +}); + test("failed TRAE CN fetch skips parser/queue/cursor mutation while other providers continue", async () => { await withTempTraeEnv(async ({ home, traeCnHome }) => { writeTraeCnStorage(traeCnHome); diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js index b8d11dcba..52e1e70fa 100644 --- a/test/trae-cn-config.test.js +++ b/test/trae-cn-config.test.js @@ -420,6 +420,57 @@ test("fetchTraeCnUsagePage sanitizes transport exceptions containing the JWT", a ); }); +test("fetchTraeCnUsagePage keeps the abort timeout active through body consumption", async () => { + const originalSetTimeout = global.setTimeout; + const originalClearTimeout = global.clearTimeout; + let timerHandle; + let bodyStarted = false; + let bodySawCancelledTimer = false; + let bodyAborted = false; + global.setTimeout = (callback) => { + timerHandle = { callback, cancelled: false }; + return timerHandle; + }; + global.clearTimeout = (handle) => { + if (handle) handle.cancelled = true; + }; + + try { + const pending = fetchTraeCnUsagePage({ + jwt: JWT, + start_time: START, + end_time: END, + page_num: 1, + fetchImpl: (_url, options) => ({ + status: 200, + ok: true, + json: () => { + bodyStarted = true; + bodySawCancelledTimer = timerHandle.cancelled; + if (bodySawCancelledTimer) return Promise.resolve({ user_usage_group_by_sessions: [] }); + return new Promise((resolve) => { + options.signal.addEventListener("abort", () => { + bodyAborted = true; + resolve({ user_usage_group_by_sessions: [] }); + }, { once: true }); + }); + }, + }), + }); + await Promise.resolve(); + assert.equal(bodyStarted, true); + timerHandle.callback(); + const result = await pending; + assert.deepEqual(result.sessions, []); + assert.equal(bodySawCancelledTimer, false); + assert.equal(bodyAborted, true); + assert.equal(timerHandle.cancelled, true, "timer clears after body consumption"); + } finally { + global.setTimeout = originalSetTimeout; + global.clearTimeout = originalClearTimeout; + } +}); + // --------------------------------------------------------------------------- // Pagination // --------------------------------------------------------------------------- @@ -487,6 +538,20 @@ test("fetchTraeCnUsage succeeds when maxPages exactly covers the total", async ( assert.equal(result.pages_fetched, 3); }); +test("fetchTraeCnUsage rejects a declared snapshot over capacity before page two", async () => { + const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 20, total: 2001 }); + await assert.rejects( + fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 100 }), + (error) => { + assert.equal(error.code, "TRAE_CN_USAGE_CAPACITY_EXCEEDED"); + assert.match(error.message, /supported capacity/); + assert.ok(!error.message.includes(JWT)); + return true; + }, + ); + assert.deepEqual(requestedPages, [1], "declared over-capacity total makes one request"); +}); + test("production inter-page delay constant is 300ms (tests inject delayMs: 0)", () => { assert.equal(TRAE_CN_USAGE_DELAY_MS, 300); }); From 283c6169d1cb843900c5b9bbf559c2d7e12621f3 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Sun, 16 Aug 2026 15:04:23 +0800 Subject: [PATCH 04/19] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20TRAE=20CN=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodeQL js/file-data-to-network 抑制注释移到 fetchImpl 调用处生效 - zh/zh-TW 文案改为「TRAE Work 中国版 / 中國版」,修复 locale 校验 - 品牌名改为渲染时经 copy() 解析,运行时切换语言即时更新 - 提供商断言改为字面量表格行匹配,避免 RegExp 误匹配 --- dashboard/src/content/i18n/zh-TW/core.json | 2 +- dashboard/src/content/i18n/zh/core.json | 2 +- dashboard/src/ui/marketing/LogoCarousel.jsx | 5 +- .../src/ui/marketing/LogoCarousel.test.jsx | 54 +++++++++++++++++++ dashboard/src/ui/marketing/agent-logos.js | 8 ++- src/lib/trae-cn-config.js | 2 +- test/discovery-metadata.test.js | 23 +++++++- 7 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 dashboard/src/ui/marketing/LogoCarousel.test.jsx diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json index 5b9aa5e4c..bb0f5a409 100644 --- a/dashboard/src/content/i18n/zh-TW/core.json +++ b/dashboard/src/content/i18n/zh-TW/core.json @@ -154,7 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", - "provider.display.trae_work_cn": "TRAE Work CN", + "provider.display.trae_work_cn": "TRAE Work 中國版", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json index 400a69e59..c7b029a29 100644 --- a/dashboard/src/content/i18n/zh/core.json +++ b/dashboard/src/content/i18n/zh/core.json @@ -154,7 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", - "provider.display.trae_work_cn": "TRAE Work CN", + "provider.display.trae_work_cn": "TRAE Work 中国版", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/ui/marketing/LogoCarousel.jsx b/dashboard/src/ui/marketing/LogoCarousel.jsx index 5f50e46a1..a902867c6 100644 --- a/dashboard/src/ui/marketing/LogoCarousel.jsx +++ b/dashboard/src/ui/marketing/LogoCarousel.jsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; +import { copy } from "../../lib/copy"; +import { useLocale } from "../../hooks/useLocale.js"; import { ProviderIcon } from "../dashboard/components/ProviderIcon.jsx"; // Animated logo wall, adapted from cult-ui's LogoCarousel @@ -39,6 +41,7 @@ function distributeLogos(allLogos, columnCount) { } const LogoColumn = React.memo(function LogoColumn({ logos, index, currentTime, onHoverChange }) { + useLocale(); const [hovered, setHovered] = useState(false); const cycleInterval = 4000; // ms each logo stays visible const columnDelay = index * 400; // stagger columns so they flip out of sync @@ -102,7 +105,7 @@ const LogoColumn = React.memo(function LogoColumn({ logos, index, currentTime, o exit={{ opacity: 0, y: -4 }} transition={{ duration: 0.15 }} > - {logo.name} + {logo.nameKey ? copy(logo.nameKey) : logo.name} )} diff --git a/dashboard/src/ui/marketing/LogoCarousel.test.jsx b/dashboard/src/ui/marketing/LogoCarousel.test.jsx new file mode 100644 index 000000000..e92d73101 --- /dev/null +++ b/dashboard/src/ui/marketing/LogoCarousel.test.jsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLocale } from "../../hooks/useLocale.js"; +import { EN_LOCALE, LOCALE_STORAGE_KEY, ZH_CN_LOCALE } from "../../lib/locale"; +import { LocaleProvider } from "../foundation/LocaleProvider.jsx"; +import { LogoCarousel } from "./LogoCarousel.jsx"; + +const TRAE_LOGO = { + id: 32, + name: "TRAE Work CN", + nameKey: "provider.display.trae_work_cn", + provider: "trae-cn", +}; + +function LocaleProbe() { + const { setLocale } = useLocale(); + + return ( + + ); +} + +beforeEach(() => { + window.localStorage.setItem(LOCALE_STORAGE_KEY, EN_LOCALE); + vi.spyOn(globalThis, "setInterval").mockImplementation(() => 0); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("LogoCarousel localization", () => { + it("resolves keyed logo names using the current locale at render time", async () => { + const { container } = render( + + + + , + ); + + let icon; + await waitFor(() => { + icon = container.querySelector('svg[data-brand="trae-cn"]'); + expect(icon).not.toBeNull(); + }); + fireEvent.mouseEnter(icon); + expect(await screen.findByText("TRAE Work CN")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "switch-to-zh" })); + expect(await screen.findByText("TRAE Work 中国版")).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/ui/marketing/agent-logos.js b/dashboard/src/ui/marketing/agent-logos.js index 02fbb414d..347d081dd 100644 --- a/dashboard/src/ui/marketing/agent-logos.js +++ b/dashboard/src/ui/marketing/agent-logos.js @@ -1,7 +1,6 @@ // Canonical list of supported coding agents, in display order. Shared by the // marketing landing carousel and the dashboard auth/expired gate so the two // surfaces can't drift. Names are tooltip/a11y metadata only. -import { copy } from "../../lib/copy"; export const AGENT_LOGOS = [ { id: 1, name: "Claude Code", provider: "claude" }, @@ -35,5 +34,10 @@ export const AGENT_LOGOS = [ { id: 29, name: "AnythingLLM", provider: "anythingllm" }, { id: 30, name: "Claude Science", provider: "claude-science" }, { id: 31, name: "DeepSeek Harness", provider: "dsh" }, - { id: 32, name: copy("provider.display.trae_work_cn"), provider: "trae-cn" }, + { + id: 32, + name: "TRAE Work CN", + nameKey: "provider.display.trae_work_cn", + provider: "trae-cn", + }, ]; diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js index c9ca3f954..bd2c441ca 100644 --- a/src/lib/trae-cn-config.js +++ b/src/lib/trae-cn-config.js @@ -278,11 +278,11 @@ async function fetchTraeCnUsagePage({ try { let response; try { + // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted. response = await fetchImpl(TRAE_CN_USAGE_URL, { method: "POST", headers: { "Content-Type": "application/json", - // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted. Authorization: `Cloud-IDE-JWT ${jwt.trim()}`, }, body: JSON.stringify({ diff --git a/test/discovery-metadata.test.js b/test/discovery-metadata.test.js index 11f052e31..23caa35f2 100644 --- a/test/discovery-metadata.test.js +++ b/test/discovery-metadata.test.js @@ -51,13 +51,34 @@ const README_EXPECTATIONS = [ ["README.de.md", /32 KI-Coding-Tools/, /\|\s+\*\*Unterstützte KI-Tools\*\*\s+\|\s+\*\*32\*\*/, /Rate-Limit-Tracking.*✅ 13 Provider/], ]; +function hasLiteralProviderCatalogEntry(source, provider) { + const catalogLines = source.split("\n").filter((line) => { + const trimmed = line.trimStart(); + return trimmed.startsWith("|") || trimmed.startsWith("- 🔌") || trimmed.startsWith("> - **") || trimmed.startsWith('A["'); + }); + + return catalogLines.some((line) => { + let offset = 0; + while (offset < line.length) { + const index = line.indexOf(provider, offset); + if (index === -1) return false; + const before = line[index - 1] || ""; + const after = line[index + provider.length] || ""; + const isWord = (value) => /[A-Za-z0-9_-]/.test(value); + if (!isWord(before) && !isWord(after)) return true; + offset = index + provider.length; + } + return false; + }); +} + test("public discovery surfaces describe all 32 supported tools", () => { for (const [file, countPattern, comparisonPattern, limitCountPattern] of README_EXPECTATIONS) { const source = read(file); assert.match(source, countPattern, `${file} has the current provider count`); assert.match(source, comparisonPattern, `${file} comparison table has the current provider count`); for (const provider of CANONICAL_PROVIDERS) { - assert.match(source, new RegExp(provider), `${file} lists ${provider}`); + assert.ok(hasLiteralProviderCatalogEntry(source, provider), `${file} lists ${provider}`); } assert.match(source, limitCountPattern, `${file} rate-limit row carries the current usage-limits provider count`); } From a5505acd94ebd8193301710dc05a3f0c83305b58 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Sun, 16 Aug 2026 15:24:00 +0800 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20=E9=87=8D=E5=91=BD=E5=90=8D=20KDF?= =?UTF-8?q?=20=E6=9D=90=E6=96=99=E6=B6=88=E9=99=A4=20CodeQL=20=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=93=88=E5=B8=8C=E8=AF=AF=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - traeCnHardcodedPassword → traeCnHardcodedKdfSecret(vendor tc-v5 KDF 混淆材料,非用户密码存储) - 测试同步重命名 testPassword → testKdfSecret - 删除无效的行内抑制注释(GitHub Code Scanning 不识别该机制) --- src/lib/trae-cn-config.js | 16 ++++++++-------- test/trae-cn-config.test.js | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js index bd2c441ca..cbc89c847 100644 --- a/src/lib/trae-cn-config.js +++ b/src/lib/trae-cn-config.js @@ -77,10 +77,10 @@ function resolveTraeCnStoragePath(options = {}) { // tc v5 decryption // --------------------------------------------------------------------------- -function traeCnHardcodedPassword() { - const pw = Buffer.alloc(64); - for (let i = 0; i < 64; i++) pw[i] = TRAE_CN_JG[i] ^ TRAE_CN_KG[i]; - return pw; +function traeCnHardcodedKdfSecret() { + const secret = Buffer.alloc(64); + for (let i = 0; i < 64; i++) secret[i] = TRAE_CN_JG[i] ^ TRAE_CN_KG[i]; + return secret; } function deriveTraeCnKeyIv(salt) { @@ -89,9 +89,9 @@ function deriveTraeCnKeyIv(salt) { } const kdfBuf = Buffer.concat([ crypto.createHash("sha512").update(salt).digest(), - traeCnHardcodedPassword(), + traeCnHardcodedKdfSecret(), ]); - // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage. + // Vendor tc-v5 compatibility KDF, not password storage. const kdfOut = crypto.createHash("sha512").update(kdfBuf).digest(); return { key: kdfOut.subarray(0, TRAE_CN_AES_KEY_LEN), @@ -278,7 +278,7 @@ async function fetchTraeCnUsagePage({ try { let response; try { - // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted. + // The locally stored TRAE JWT goes only to the fixed official HTTPS endpoint; never logged or persisted. response = await fetchImpl(TRAE_CN_USAGE_URL, { method: "POST", headers: { @@ -428,7 +428,7 @@ module.exports = { TRAE_CN_MAGIC, resolveTraeCnHome, resolveTraeCnStoragePath, - traeCnHardcodedPassword, + traeCnHardcodedKdfSecret, deriveTraeCnKeyIv, decryptTraeCnBlob, decryptTraeCnBase64, diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js index 52e1e70fa..867273ff8 100644 --- a/test/trae-cn-config.test.js +++ b/test/trae-cn-config.test.js @@ -59,15 +59,15 @@ const TEST_KG = Buffer.from([ 85, 33, 12, 125, ]); -function testPassword() { - const pw = Buffer.alloc(64); - for (let i = 0; i < 64; i++) pw[i] = TEST_JG[i] ^ TEST_KG[i]; - return pw; +function testKdfSecret() { + const secret = Buffer.alloc(64); + for (let i = 0; i < 64; i++) secret[i] = TEST_JG[i] ^ TEST_KG[i]; + return secret; } function testDerive(salt) { - const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testPassword()]); - // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage. + const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testKdfSecret()]); + // Vendor tc-v5 compatibility KDF, not password storage. const out = crypto.createHash("sha512").update(kdfBuf).digest(); return { key: out.subarray(0, 16), iv: out.subarray(16, 32) }; } From f1bd60fcb4fc7f02a19fd948c55128c106a9cbd0 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Sun, 16 Aug 2026 15:40:40 +0800 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=E9=AB=98=E5=AE=B9=E9=87=8F?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=AA=97=E8=87=AA=E9=80=82=E5=BA=94=E6=8B=86?= =?UTF-8?q?=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只读探测证实官方 API 窗口为双闭区间、跨窗重复行字节一致(幂等非累计) - 超 2000 会话/30 天的窗口递归二分为错开 1 秒的子窗([start,mid]+[mid+1,end]), 并集严格等于全量窗;残余重复由现有 session 级对账吸收 - 拆分深度上限 8(最细 ~2.8h 子窗,聚合上限 ~51 万会话),耗尽仍超容保持 fail-fast - fetchTraeCnUsageWithAuth 默认启用拆分;新增 6 个单元测试与 2 个 sync 集成测试 --- src/commands/sync.js | 8 +- src/lib/trae-cn-config.js | 39 +++++++- test/sync-trae-cn.test.js | 54 ++++++++++- test/trae-cn-config.test.js | 175 ++++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 6 deletions(-) diff --git a/src/commands/sync.js b/src/commands/sync.js index 5dc7d31f3..d826921d0 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -1684,9 +1684,11 @@ async function cmdSync(argv, context = {}) { // excludes both ordinary background and `--auto --background // --all-local-sources`. Fetching goes through the storage-backed Phase A // helper so its single 401/403 reread/retry is used; an empty response is a - // successful no-op; complete snapshots are bounded to 100 pages/2,000 rows; - // over-capacity or other fetch/page/schema/auth failures skip the parser and - // leave prior data untouched while unrelated providers continue. + // successful no-op; each window is bounded to 100 pages/2,000 rows and an + // over-capacity window is split into staggered sub-windows (still over + // capacity at the finest allowed granularity, or any other fetch/page/ + // schema/auth failure) skips the parser and leaves prior data untouched + // while unrelated providers continue. let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home }); if ( diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js index cbc89c847..2aabcfc59 100644 --- a/src/lib/trae-cn-config.js +++ b/src/lib/trae-cn-config.js @@ -30,6 +30,10 @@ const TRAE_CN_USAGE_PAGE_SIZE = 20; const TRAE_CN_USAGE_MAX_PAGES = 100; const TRAE_CN_USAGE_DELAY_MS = 300; const TRAE_CN_USAGE_TIMEOUT_MS = 30 * 1000; +// Capacity-adaptive window splitting: 30 days halves at most 8 times +// (finest sub-window ~2.8h, aggregate ceiling ~512k sessions/30d). A window +// still over capacity at that depth fails closed — never a partial import. +const TRAE_CN_USAGE_MAX_SPLIT_DEPTH = 8; const TRAE_CN_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00]); const TRAE_CN_SALT_LEN = 32; @@ -378,6 +382,38 @@ async function fetchTraeCnUsage({ return { sessions, total, pages_fetched: pagesFetched }; } +/** + * Capacity-adaptive window fetch. Probing the official API confirmed the + * start_time/end_time range is closed on both ends ([a,b] and [b,c] both + * return the row at b) and that a row returned by two overlapping windows is + * byte-identical (idempotent duplicate, never cumulative). Splitting therefore + * staggers adjacent sub-windows by one second ([start,mid] + [mid+1,end]) so + * the union equals the full window exactly; any residual duplicate would be + * absorbed by the session-level reconciliation. On TRAE_CN_USAGE_CAPACITY_ + * EXCEEDED the window halves recursively up to maxSplitDepth levels; a window + * still over capacity at the finest allowed granularity re-throws so the + * caller keeps its fail-closed guarantee. + */ +async function fetchTraeCnUsageWindowed(options = {}, depth = 0) { + const { start_time, end_time } = options; + try { + return await fetchTraeCnUsage(options); + } catch (error) { + if (error?.code !== "TRAE_CN_USAGE_CAPACITY_EXCEEDED") throw error; + if (end_time - start_time < 1 || depth >= TRAE_CN_USAGE_MAX_SPLIT_DEPTH) throw error; + const mid = start_time + Math.floor((end_time - start_time) / 2); + const left = await fetchTraeCnUsageWindowed({ ...options, end_time: mid }, depth + 1); + const right = await fetchTraeCnUsageWindowed({ ...options, start_time: mid + 1 }, depth + 1); + const total = + Number.isFinite(left.total) && Number.isFinite(right.total) ? left.total + right.total : null; + return { + sessions: [...left.sessions, ...right.sessions], + total, + pages_fetched: left.pages_fetched + right.pages_fetched, + }; + } +} + /** * High-level storage-backed fetch: read the JWT from storage.json, fetch * usage, and on a 401/403 re-read + re-decrypt storage.json and retry exactly @@ -392,7 +428,7 @@ async function fetchTraeCnUsageWithAuth({ delayMs = TRAE_CN_USAGE_DELAY_MS, maxPages = TRAE_CN_USAGE_MAX_PAGES, readAuth = readTraeCnAuthFromStorage, - usageFetcher = fetchTraeCnUsage, + usageFetcher = fetchTraeCnUsageWindowed, ...storageOptions } = {}) { let auth = readAuth(storageOptions); @@ -437,5 +473,6 @@ module.exports = { extractTraeCnToken, fetchTraeCnUsagePage, fetchTraeCnUsage, + fetchTraeCnUsageWindowed, fetchTraeCnUsageWithAuth, }; diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js index 91040c6fd..4c6ab3357 100644 --- a/test/sync-trae-cn.test.js +++ b/test/sync-trae-cn.test.js @@ -249,7 +249,7 @@ test("an absent TRAE CN storage file does not call the fetch", async () => { }); }); -test("over-capacity TRAE CN fetch preserves the prior queue and cursor bytes", async () => { +test("perpetually over-capacity TRAE CN fetch (split depth exhausted) preserves the prior queue and cursor bytes", async () => { await withTempTraeEnv(async ({ home, traeCnHome }) => { writeTraeCnStorage(traeCnHome); const initialFetch = traeFetchOnce({ @@ -279,13 +279,63 @@ test("over-capacity TRAE CN fetch preserves the prior queue and cursor bytes", a traeCnNowMs: NOW_MS, }); - assert.equal(overCapacityRequests.length, 1, "capacity failure stops after page one"); + // Every window (down to the split-depth ceiling) declares 2001 rows, so + // the fetch fails closed after probing the bounded split tree. + assert.ok(overCapacityRequests.length > 1, "window splitting was attempted"); + assert.ok(overCapacityRequests.length <= 511, "probe count stays within the depth ceiling"); assert.equal(fs.readFileSync(queuePath, "utf8"), queueBefore, "prior queue remains byte-identical"); const traeCursorAfter = JSON.stringify(JSON.parse(fs.readFileSync(cursorsPath, "utf8")).traeCn); assert.equal(traeCursorAfter, traeCursorBefore, "prior TRAE cursor remains byte-identical"); }); }); +test("over-capacity TRAE CN window is split and the staggered halves import atomically", async () => { + await withTempTraeEnv(async ({ home, traeCnHome }) => { + writeTraeCnStorage(traeCnHome); + const endTime = Math.floor(NOW_MS / 1000); + const startTime = Math.max(1, endTime - ROLLING_DAYS); + const mid = startTime + Math.floor((endTime - startTime) / 2); + const requests = []; + const fetchImpl = async (url, options) => { + requests.push({ url, options }); + const body = JSON.parse(options.body); + const overCapacity = body.start_time === startTime && body.end_time === endTime; + if (overCapacity) { + return { status: 200, ok: true, json: async () => ({ user_usage_group_by_sessions: [], total: 2001 }) }; + } + const side = body.start_time === startTime ? "left" : "right"; + return { + status: 200, + ok: true, + json: async () => ({ + user_usage_group_by_sessions: [ + { + ...sampleSession(body.end_time), + session_id: `split-${side}`, + }, + ], + total: 1, + }), + }; + }; + + await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS }); + + // Full window probes once, then the two staggered halves each paginate. + assert.deepEqual( + requests.map(({ options }) => { + const body = JSON.parse(options.body); + return [body.start_time, body.end_time]; + }), + [[startTime, endTime], [startTime, mid], [mid + 1, endTime]], + ); + const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn"); + assert.equal(traeRows.length, 2, "both halves queue their row"); + const cursorSessions = readCursors(home).traeCn.sessions; + assert.deepEqual(Object.keys(cursorSessions).sort(), ["split-left", "split-right"]); + }); +}); + test("failed TRAE CN fetch skips parser/queue/cursor mutation while other providers continue", async () => { await withTempTraeEnv(async ({ home, traeCnHome }) => { writeTraeCnStorage(traeCnHome); diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js index 867273ff8..36d73d508 100644 --- a/test/trae-cn-config.test.js +++ b/test/trae-cn-config.test.js @@ -29,6 +29,7 @@ const { extractTraeCnToken, fetchTraeCnUsagePage, fetchTraeCnUsage, + fetchTraeCnUsageWindowed, fetchTraeCnUsageWithAuth, } = require("../src/lib/trae-cn-config"); @@ -552,6 +553,153 @@ test("fetchTraeCnUsage rejects a declared snapshot over capacity before page two assert.deepEqual(requestedPages, [1], "declared over-capacity total makes one request"); }); +// --------------------------------------------------------------------------- +// Capacity-adaptive window splitting +// --------------------------------------------------------------------------- + +function windowedFetch(handler, requests = []) { + const fetchImpl = async (_url, options) => { + const body = JSON.parse(options.body); + requests.push(body); + return jsonResponse(200, handler(body)); + }; + return { fetchImpl, requests }; +} + +test("fetchTraeCnUsageWindowed returns a within-capacity window without splitting", async () => { + const { fetchImpl, requests } = windowedFetch(() => ({ + user_usage_group_by_sessions: [{ session_id: "only" }], + total: 1, + })); + const result = await fetchTraeCnUsageWindowed({ + jwt: JWT, + start_time: START, + end_time: END, + fetchImpl, + delayMs: 0, + }); + assert.equal(requests.length, 1, "no probe beyond the single window"); + assert.equal(requests[0].start_time, START); + assert.equal(requests[0].end_time, END); + assert.equal(result.sessions.length, 1); + assert.equal(result.total, 1); +}); + +test("fetchTraeCnUsageWindowed splits an over-capacity window into staggered halves", async () => { + const mid = START + Math.floor((END - START) / 2); + const leftRow = { session_id: "left" }; + const rightRow = { session_id: "right" }; + const { fetchImpl, requests } = windowedFetch((body) => { + if (body.start_time === START && body.end_time === END) { + return { user_usage_group_by_sessions: [leftRow], total: 2001 }; + } + if (body.start_time === START && body.end_time === mid) { + return { user_usage_group_by_sessions: [leftRow], total: 1 }; + } + if (body.start_time === mid + 1 && body.end_time === END) { + return { user_usage_group_by_sessions: [rightRow], total: 1 }; + } + throw new Error(`unexpected window ${body.start_time}-${body.end_time}`); + }); + const result = await fetchTraeCnUsageWindowed({ + jwt: JWT, + start_time: START, + end_time: END, + fetchImpl, + delayMs: 0, + }); + // Full window fails on page one; the halves are staggered by one second so + // their union equals the full window without overlap. + assert.deepEqual( + requests.map((b) => [b.start_time, b.end_time]), + [[START, END], [START, mid], [mid + 1, END]], + ); + assert.deepEqual(result.sessions, [leftRow, rightRow]); + assert.equal(result.total, 2); + assert.equal(result.pages_fetched, 2); +}); + +test("fetchTraeCnUsageWindowed splits recursively until a sub-window fits", async () => { + const mid = START + Math.floor((END - START) / 2); + const quarter = START + Math.floor((mid - START) / 2); + const { fetchImpl, requests } = windowedFetch((body) => { + if (body.start_time === START && (body.end_time === END || body.end_time === mid)) { + return { user_usage_group_by_sessions: [], total: 2001 }; + } + if (body.start_time === START && body.end_time === quarter) { + return { user_usage_group_by_sessions: [{ session_id: "q1" }], total: 1 }; + } + if (body.start_time === quarter + 1 && body.end_time === mid) { + return { user_usage_group_by_sessions: [{ session_id: "q2" }], total: 1 }; + } + if (body.start_time === mid + 1 && body.end_time === END) { + return { user_usage_group_by_sessions: [{ session_id: "q3" }], total: 1 }; + } + throw new Error(`unexpected window ${body.start_time}-${body.end_time}`); + }); + const result = await fetchTraeCnUsageWindowed({ + jwt: JWT, + start_time: START, + end_time: END, + fetchImpl, + delayMs: 0, + }); + assert.deepEqual( + requests.map((b) => [b.start_time, b.end_time]), + [[START, END], [START, mid], [START, quarter], [quarter + 1, mid], [mid + 1, END]], + ); + assert.equal(result.sessions.length, 3); + assert.equal(result.total, 3); +}); + +test("fetchTraeCnUsageWindowed fails closed at the split-depth ceiling", async () => { + const { fetchImpl, requests } = windowedFetch(() => ({ + user_usage_group_by_sessions: [], + total: 2001, + })); + await assert.rejects( + fetchTraeCnUsageWindowed({ + jwt: JWT, + start_time: START, + end_time: END, + fetchImpl, + delayMs: 0, + }), + (error) => { + assert.equal(error.code, "TRAE_CN_USAGE_CAPACITY_EXCEEDED"); + assert.match(error.message, /supported capacity/); + return true; + }, + ); + // Every sub-window stays over capacity, so the full depth-8 binary tree is + // probed: at most 2^9 - 1 first-page requests before failing closed. + assert.ok(requests.length <= 511, `probe count ${requests.length} exceeds the ceiling`); + assert.ok(requests.length > 1, "splitting was attempted"); +}); + +test("fetchTraeCnUsageWindowed rethrows non-capacity errors untouched", async () => { + const requests = []; + const fetchImpl = async (_url, options) => { + requests.push(JSON.parse(options.body)); + return jsonResponse(500, {}); + }; + await assert.rejects( + fetchTraeCnUsageWindowed({ + jwt: JWT, + start_time: START, + end_time: END, + fetchImpl, + delayMs: 0, + }), + /HTTP 500/, + ); + assert.deepEqual( + requests.map((b) => [b.start_time, b.end_time]), + [[START, END]], + "no split attempt on a non-capacity failure", + ); +}); + test("production inter-page delay constant is 300ms (tests inject delayMs: 0)", () => { assert.equal(TRAE_CN_USAGE_DELAY_MS, 300); }); @@ -616,6 +764,33 @@ test("fetchTraeCnUsageWithAuth never retries more than once", async () => { assert.equal(usageCalls.length, 2, "exactly two attempts"); }); +test("fetchTraeCnUsageWithAuth splits over-capacity windows by default", async () => { + const home = makeTraeCnHome(); + writeStorage(home, { token: "split.jwt.1", refreshToken: "r1" }); + const mid = START + Math.floor((END - START) / 2); + const requests = []; + const fetchImpl = async (_url, options) => { + const body = JSON.parse(options.body); + requests.push([body.start_time, body.end_time]); + if (body.start_time === START && body.end_time === END) { + return jsonResponse(200, { user_usage_group_by_sessions: [], total: 2001 }); + } + const row = { session_id: `s-${body.start_time}`, model_name: "doubao-pro", usage_time: body.end_time, input_token: 1, output_token: 1, cache_read_token: 0, cache_write_token: 0 }; + return jsonResponse(200, { user_usage_group_by_sessions: [row], total: 1 }); + }; + const result = await fetchTraeCnUsageWithAuth({ + start_time: START, + end_time: END, + fetchImpl, + delayMs: 0, + env: { [TRAE_CN_HOME_ENV]: home }, + platform: "darwin", + }); + assert.deepEqual(requests, [[START, END], [START, mid], [mid + 1, END]]); + assert.equal(result.sessions.length, 2); + assert.equal(result.total, 2); +}); + test("fetchTraeCnUsageWithAuth does not retry on non-auth errors", async () => { const usageCalls = []; const usageFetcher = async () => { From d93f9ab777bc7209f870f124743a12cfb66aa9d8 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 01:14:45 +0800 Subject: [PATCH 07/19] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20TRAE=20CN=20?= =?UTF-8?q?=E4=BA=8C=E8=BD=AE=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B2: 撤回对 SUPERSEDED rollback reference (leaderboard-usage-grouped-rpc.sql) 的改动 - B3: input_token 语义修正为 cache-inclusive(积分账单零残差回归证实),按 Qoder 约定剥离 cached 子集,total = input_token + output_token - S1: JWT 外发改为默认关闭的 opt-in 开关 TOKENTRACKER_TRAE_CN_USAGE - S3: 单行异常 per-row 隔离并计数,cache 字段缺失视为 0,auto 路径 stderr 输出跳过行数 - S4: cursors.traeCn.sessions 按窗口起点单调 prune,防无上限增长 - S5: status 命令接入 TRAE CN 检测(storage 路径 / auth 可读性 / opt-in 状态) - S6: 回退 5 语言 README 头条,保留支持表格行与 PRIVACY.md 说明 --- README.de.md | 6 +- README.ja.md | 6 +- README.ko.md | 6 +- README.md | 8 +- README.zh-CN.md | 6 +- scripts/ops/leaderboard-usage-grouped-rpc.sql | 2 +- src/commands/status.js | 39 ++++++ src/commands/sync.js | 91 +++++++------ src/lib/rollout.js | 89 +++++++++++-- src/lib/trae-cn-config.js | 32 ++++- test/account-source-parity.test.js | 12 +- test/sync-trae-cn.test.js | 28 ++++ test/trae-cn-config.test.js | 40 +++++- test/trae-cn-parser.test.js | 125 +++++++++++++++--- 14 files changed, 398 insertions(+), 92 deletions(-) diff --git a/README.de.md b/README.de.md index 429353207..b00b9e887 100644 --- a/README.de.md +++ b/README.de.md @@ -6,7 +6,7 @@ ### Sieh genau, was du für KI ausgibst – über jedes CLI hinweg -Sammle automatisch Token-Zahlen von **32 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein TokenTracker-Konto, keine API-Keys, kein Setup – nur ein Befehl. +Sammle automatisch Token-Zahlen von **32 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein Cloud-Konto, keine API-Keys, kein Setup – nur ein Befehl. [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -51,7 +51,7 @@ Das war's. Beim ersten Start werden Hooks installiert, deine Daten synchronisier - 📊 Ein lokales Dashboard auf `localhost:7680` mit Nutzungstrends, Modellaufschlüsselung, Kostenanalyse - 🔌 Auto-erkannte Hooks für jedes installierte KI-Tool -- 🏠 Local-first – kein TokenTracker-Konto und keine API-Keys; optionale Netzwerkanfragen umfassen das Leaderboard und die schreibgeschützte TRAE Work CN-Nutzungsabfrage +- 🏠 100 % lokal – kein Konto, keine API-Keys, keine Netzwerkaufrufe (außer optionalem Leaderboard) - 🧩 *Optional:* Ein Skills-Tab zum Durchsuchen von 250+ öffentlichen Skills – synchronisiert über Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes > **Möchtest du eine native Desktop-App?** @@ -89,7 +89,7 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De ## ✨ Features - 🔌 **32 KI-Tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN -- 🏠 **Local-first** — Token-Daten bleiben auf deinem Rechner, außer bei der schreibgeschützten TRAE Work CN-Nutzungsabfrage. Kein TokenTracker-Konto, keine API-Keys; die Abfrage verwendet die Sitzung der lokal angemeldeten App und liest nur Nutzungsmetadaten. +- 🏠 **100 % lokal** — Token-Daten verlassen nie deinen Rechner. Kein Konto, keine API-Keys. - 🚀 **Zero Config** — Hooks installieren sich beim ersten Start automatisch. Von null zum Dashboard in 30 Sekunden. - 📊 **Schönes Dashboard** — Nutzungstrends, Kostenaufschlüsselung nach Modell, GitHub-ähnliche Aktivitäts-Heatmap, Projektzuordnung - 🖥️ **Native Desktop-App** — macOS Menüleiste (+ Widgets) und Windows System Tray, jeweils mit eingebautem Server und Dashboard in einer nativen WebView diff --git a/README.ja.md b/README.ja.md index 2fd494bf7..ceb56175d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -6,7 +6,7 @@ ### AI に使ったコストを正確に把握 — すべての CLI を横断して -**32 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。TokenTracker アカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。 +**32 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。クラウドアカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。 [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -50,7 +50,7 @@ npx tokentracker-cli **30 秒で手に入るもの:** - 📊 `localhost:7680` のローカルダッシュボードで、使用トレンド、モデル別内訳、コスト分析が見える - 🔌 インストール済みの対応 AI ツールすべてに対する hook を自動検出 -- 🏠 ローカルファースト — TokenTracker アカウント不要、API キー不要。オプションのネットワーク通信にはリーダーボードと TRAE Work CN の読み取り専用の使用量リクエストがあります +- 🏠 100% ローカル — アカウント不要、API キー不要、ネットワーク通信なし(オプションのリーダーボードを除く) - 🧩 *オプション:* 250+ の公開 Skill を閲覧して Claude · Codex · Gemini · OpenCode · Hermes 間で同期できる Skills タブ > **ネイティブのデスクトップアプリが欲しい?** @@ -89,7 +89,7 @@ brew install xiufengsun/tokentracker/tokentracker ## ✨ 機能 - 🔌 **32 種類の AI ツールを標準対応** — Claude Code、Codex CLI、Cursor、Gemini CLI、Antigravity、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN -- 🏠 **ローカルファースト** — TRAE Work CN の読み取り専用の使用量リクエストを除き、トークンデータはマシンから外に出ません。TokenTracker アカウント不要、API キー不要。このリクエストはローカルでサインイン済みのアプリセッションを使い、使用量メタデータのみを読み取ります。 +- 🏠 **100% ローカル** — トークンデータがマシンから外に出ることはありません。アカウント不要、API キー不要。 - 🚀 **ゼロコンフィグ** — Hook は初回実行で自動インストール。0 からダッシュボードまで 30 秒。 - 📊 **美しいダッシュボード** — 使用トレンド、モデル別コスト内訳、GitHub スタイルのアクティビティヒートマップ、プロジェクト別の帰属表示 - 🖥️ **ネイティブデスクトップアプリ** — macOS メニューバー(ウィジェット付き)と Windows システムトレイ。それぞれ組み込みサーバーとネイティブ WebView のダッシュボードを備えます diff --git a/README.ko.md b/README.ko.md index 77e5de392..d9422155a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ ### 모든 CLI에서 AI에 쓰는 비용을 정확히 파악 -**32개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. TokenTracker 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝. +**32개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. 클라우드 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝. [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -50,7 +50,7 @@ npx tokentracker-cli **30초 안에 얻는 것:** - 📊 사용 추세, 모델별 분석, 비용 분석을 보여주는 `localhost:7680`의 로컬 대시보드 - 🔌 설치된 모든 지원 AI 도구에 대한 hook 자동 감지 -- 🏠 로컬 우선 — TokenTracker 계정과 API Key가 필요 없습니다. 옵션 네트워크 요청에는 리더보드와 TRAE Work CN의 읽기 전용 사용량 요청이 포함됩니다 +- 🏠 100% 로컬 — 계정 없음, API Key 없음, 네트워크 호출 없음 (옵션 리더보드 제외) - 🧩 *옵션:* 250+개의 공개 Skill을 둘러보고 Claude · Codex · Gemini · OpenCode · Hermes 간에 동기화할 수 있는 Skills 탭 > **네이티브 데스크톱 앱이 필요하다면?** @@ -89,7 +89,7 @@ brew install xiufengsun/tokentracker/tokentracker ## ✨ 기능 - 🔌 **32개의 AI 도구 기본 지원** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN -- 🏠 **로컬 우선** — TRAE Work CN의 읽기 전용 사용량 요청을 제외하면 토큰 데이터가 기기를 떠나지 않습니다. TokenTracker 계정과 API Key가 필요 없으며, 이 요청은 로컬 로그인 앱 세션을 사용해 사용량 메타데이터만 읽습니다. +- 🏠 **100% 로컬** — 토큰 데이터가 기기를 떠나지 않습니다. 계정 없음, API Key 없음. - 🚀 **제로 설정** — 첫 실행 시 Hook 자동 설치. 0에서 대시보드까지 30초. - 📊 **아름다운 대시보드** — 사용 추세, 모델별 비용 분석, GitHub 스타일 활동 히트맵, 프로젝트 귀속 정보 - 🖥️ **네이티브 데스크톱 앱** — macOS 메뉴바(위젯 포함)와 Windows 시스템 트레이. 각각 임베디드 서버와 네이티브 WebView 대시보드를 제공합니다 diff --git a/README.md b/README.md index 2ac4476c3..e7f20102a 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,7 @@ ### Track every AI token — then bring your usage to life -An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No TokenTracker account, no API keys, no setup. - -> **TRAE Work CN note:** On macOS, during eligible non-background sync when local TRAE Work CN auth exists, TokenTracker reads usage metadata from the locally signed-in app; no prompts are sent, and its internal API may change. +An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No cloud account, no API keys, no setup. [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -87,7 +85,7 @@ That's it. First run installs hooks, syncs your data, and opens the dashboard at **What you get in 30 seconds:** - 📊 A local dashboard at `localhost:7680` with usage trends, model breakdown, cost analysis - 🔌 Auto-detected hooks for every supported AI tool you have installed -- 🏠 Local-first — no TokenTracker account or API keys; optional network requests include the leaderboard and the read-only TRAE Work CN usage request +- 🏠 100% local — no account, no API keys, no network calls (except optional leaderboard) - 🧩 *Optional:* a Skills tab that browses 250+ public skills and syncs them across Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes > **Want a native desktop app?** @@ -141,7 +139,7 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag ## ✨ Features - 🔌 **32 AI tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN -- 🏠 **Local-first** — Token data stays on your machine except for the read-only TRAE Work CN usage request. No TokenTracker account, no API keys; the request uses the locally signed-in app session and reads usage metadata only. +- 🏠 **100% local** — Token data never leaves your machine. No account, no API keys. - 🚀 **Zero config** — Hooks auto-install on first run. From zero to dashboard in 30 seconds. - 📊 **Beautiful dashboard** — Usage trends, cost breakdowns by model, GitHub-style activity heatmap, project attribution - 🖥️ **Native desktop app** — macOS menu bar (+ widgets) and Windows system tray, each with an embedded server and the dashboard in a native webview diff --git a/README.zh-CN.md b/README.zh-CN.md index 480537f81..e9d40e935 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ ### 跨所有 CLI,看清你到底在 AI 上花了多少钱 -自动采集 **32 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要 TokenTracker 账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。 +自动采集 **32 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要云账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。 [![npm version](https://img.shields.io/npm/v/tokentracker-cli.svg?color=blue)](https://www.npmjs.com/package/tokentracker-cli) [![npm downloads](https://img.shields.io/npm/dm/tokentracker-cli.svg?color=brightgreen)](https://www.npmjs.com/package/tokentracker-cli) @@ -50,7 +50,7 @@ npx tokentracker-cli **30 秒能看到什么:** - 📊 本地 Dashboard(`localhost:7680`)—— 用量趋势、模型明细、成本分析 - 🔌 自动识别并挂接你电脑上所有已安装的 AI 工具 -- 🏠 本地优先运行 —— 无需 TokenTracker 账号或 API Key;可选网络请求包括排行榜和 TRAE Work CN 的只读用量请求 +- 🏠 100% 本地运行 —— 无账号、无 API Key、无网络请求(排行榜是可选的) - 🧩 *可选:* Skills 面板——浏览 250+ 公开 skill,并在 Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes 之间同步 > **想要原生桌面 App?** @@ -91,7 +91,7 @@ brew install xiufengsun/tokentracker/tokentracker ## ✨ 特性 - 🔌 **开箱即用支持 32 款 AI 工具** —— Claude Code、Codex CLI、Cursor、Gemini CLI、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Antigravity、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN -- 🏠 **本地优先** —— Token 数据留在你的机器上,TRAE Work CN 的只读用量请求除外。无需 TokenTracker 账号或 API Key;该请求使用本地已登录应用的现有会话授权,仅读取用量元数据。 +- 🏠 **100% 本地** —— Token 数据绝不离开你的机器。无账号、无 API Key - 🚀 **零配置** —— 首次运行自动安装所有 hook。30 秒从零到 Dashboard - 📊 **漂亮的 Dashboard** —— 用量趋势、按模型的成本分解、GitHub 风格活跃度热力图、按项目归因 - 🖥️ **原生桌面 App** —— macOS 菜单栏(含桌面小组件)与 Windows 系统托盘,各自内嵌服务 + 原生 WebView Dashboard diff --git a/scripts/ops/leaderboard-usage-grouped-rpc.sql b/scripts/ops/leaderboard-usage-grouped-rpc.sql index 5762abd68..d8589a67d 100644 --- a/scripts/ops/leaderboard-usage-grouped-rpc.sql +++ b/scripts/ops/leaderboard-usage-grouped-rpc.sql @@ -62,7 +62,7 @@ SET hash_mem_multiplier TO '4' AS $func$ WITH cfg AS ( -- Keep in sync with src/lib/source-metadata.js ACCOUNT_LEVEL_SOURCES. - SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources + SELECT ARRAY['cursor']::text[] AS account_sources ), -- Hour-grain canonical rows. TWO passes total (DISTINCT + the per_usm GROUP BY) -- so the whole-history 'total' refresh stays within the edge's 30s budget -- diff --git a/src/commands/status.js b/src/commands/status.js index ddeb2821f..69b66d2fa 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -87,6 +87,13 @@ const { resolveCopilotAppDbPaths, probeWslDistros, } = require("../lib/rollout"); +const { + TRAE_CN_USAGE_ENV, + isTraeCnUsageEnabled, + resolveTraeCnStoragePath, + readTraeCnAuthFromStorage, + extractTraeCnToken, +} = require("../lib/trae-cn-config"); const wsl = require("../lib/wsl-probe"); const { getWslMode, isInvalidWslMode, shouldProbeWsl, discoverWslHome } = wsl; const { resolveInstallPaths, resolveZcodeNativeDbPath } = require("../lib/install-resolver"); @@ -635,6 +642,27 @@ async function cmdStatus(argv = []) { ? readTraeEntitlementFromStorage(traeStoragePath) : null; + // Trae SOLO CN — opt-in usage-API reader. Unlike the passive entitlement + // reader above, this source sends the locally stored sign-in JWT to TRAE's + // official API, so status surfaces the opt-in flag, the resolved storage + // path, and whether the auth blob decrypts — the three inputs a "why is my + // TRAE CN data missing" diagnosis needs. + const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home }); + const traeCnInstalled = Boolean(traeCnStoragePath); + let traeCnAuthState = "not-signed-in"; + if (traeCnInstalled) { + try { + const traeCnAuth = readTraeCnAuthFromStorage({ env: process.env, home, platform: process.platform }); + if (traeCnAuth) { + extractTraeCnToken(traeCnAuth); + traeCnAuthState = "readable"; + } + } catch (_error) { + traeCnAuthState = "malformed"; + } + } + const traeCnUsageOptIn = isTraeCnUsageEnabled(process.env); + // Grok Build (xAI TUI) const grokHookState = await probeGrokHookState({ home, trackerDir, env: process.env }); const grokSessions = grokHookState.hasGrokInstall || grokHookState.sessionsDir @@ -923,6 +951,14 @@ async function cmdStatus(argv = []) { ...(traeEntitlement ? { entitlement: traeEntitlement } : {}), } : { installed: false }, + "trae-cn": traeCnInstalled + ? { + installed: true, + detail: traeCnStoragePath, + auth: traeCnAuthState, + usage_opt_in: traeCnUsageOptIn, + } + : { installed: false }, grok_build: grokInstalled ? { installed: true, @@ -1092,6 +1128,9 @@ async function cmdStatus(argv = []) { traeEntitlement ? `- Trae SOLO plan: ${formatTraeEntitlementLine(traeEntitlement)}` : null, + traeCnInstalled + ? `- Trae SOLO CN: usage sync ${traeCnUsageOptIn ? "opted in" : `off (set ${TRAE_CN_USAGE_ENV}=1 to enable)`}, auth ${traeCnAuthState} (${traeCnStoragePath})` + : null, ...(() => { if (!hermesInstalled) return []; return [`- Hermes Agent: state.db found (${hermesPath})`]; diff --git a/src/commands/sync.js b/src/commands/sync.js index d826921d0..416dd92bf 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -136,6 +136,7 @@ const { purgeProjectUsage } = require("../lib/project-usage-purge"); const { fetchTraeCnUsageWithAuth, resolveTraeCnStoragePath, + isTraeCnUsageEnabled, } = require("../lib/trae-cn-config"); const { isCodexSessionCursorPath, @@ -1677,56 +1678,70 @@ async function cmdSync(argv, context = {}) { } // ── Trae Work CN (国内版) — account-level usage API ── - // Phase C: a simple explicit rolling 30-day window captured once per sync - // (no cursor checkpoints / full-history import / adaptive splitting / page - // retries / background requests in this phase). Runs only for non-lightweight - // syncs where the source is allowed AND the CN storage path resolves — which - // excludes both ordinary background and `--auto --background - // --all-local-sources`. Fetching goes through the storage-backed Phase A - // helper so its single 401/403 reread/retry is used; an empty response is a - // successful no-op; each window is bounded to 100 pages/2,000 rows and an - // over-capacity window is split into staggered sub-windows (still over - // capacity at the finest allowed granularity, or any other fetch/page/ - // schema/auth failure) skips the parser and leaves prior data untouched - // while unrelated providers continue. + // A simple explicit rolling 30-day window captured once per sync (no + // cursor checkpoints / full-history import / page retries / background + // requests). Runs only when the user has opted in via + // TOKENTRACKER_TRAE_CN_USAGE=1 (the read transmits the locally stored + // sign-in JWT to TRAE's official endpoint, so it is never default-on), + // the sync is non-lightweight, the source is allowed, and the CN storage + // file exists — which excludes both ordinary background and `--auto + // --background --all-local-sources`. Fetching goes through the + // storage-backed helper so its single 401/403 reread/retry is used; an + // empty response is a successful no-op; each window is bounded to 100 + // pages/2,000 rows and an over-capacity window is split into staggered + // sub-windows (still over capacity at the finest allowed granularity, or + // any other fetch/page/schema/auth failure) skips the parser and leaves + // prior data untouched while unrelated providers continue. let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; - const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home }); if ( !isBackgroundLightweightSync && - sourceAllowed("trae-cn") && - traeCnStoragePath && - fssync.existsSync(traeCnStoragePath) + isTraeCnUsageEnabled(process.env) && + sourceAllowed("trae-cn") ) { const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now(); const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch; const endTime = Math.floor(nowMs / 1000); const startTime = Math.max(1, endTime - 30 * 24 * 60 * 60); try { - if (progress?.enabled) { - progress.start(`Fetching TRAE Work CN usage...`); - } - const traeCnUsage = await fetchTraeCnUsageWithAuth({ - start_time: startTime, - end_time: endTime, - fetchImpl, - env: process.env, - home, - }); - const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : []; - if (traeCnSessions.length > 0) { + // Absent storage means "not signed in" — a silent skip, not an error. + const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home }); + if (traeCnStoragePath && fssync.existsSync(traeCnStoragePath)) { if (progress?.enabled) { - progress.start( - `Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber( - traeCnSessions.length, - )} records | buckets 0`, - ); + progress.start(`Fetching TRAE Work CN usage...`); } - traeCnResult = await parseTraeCnApiIncremental({ - sessions: traeCnSessions, - cursors, - queuePath, - onProgress: makeProviderProgress("TRAE Work CN"), + const traeCnUsage = await fetchTraeCnUsageWithAuth({ + start_time: startTime, + end_time: endTime, + fetchImpl, + env: process.env, + home, }); + const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : []; + if (traeCnSessions.length > 0) { + if (progress?.enabled) { + progress.start( + `Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber( + traeCnSessions.length, + )} records | buckets 0`, + ); + } + traeCnResult = await parseTraeCnApiIncremental({ + sessions: traeCnSessions, + cursors, + queuePath, + onProgress: makeProviderProgress("TRAE Work CN"), + windowStartMs: startTime * 1000, + }); + // Row-level isolation means a malformed row is skipped, not fatal; + // surface the skip count even on auto runs (warnProviderParseFailure + // is silenced there) so it stays diagnosable. + const skipped = Number(traeCnResult?.skippedRows) || 0; + if (skipped > 0) { + process.stderr.write( + `TRAE Work CN sync: skipped ${skipped} malformed session row${skipped !== 1 ? "s" : ""}.\n`, + ); + } + } } } catch (err) { warnProviderParseFailure("TRAE Work CN", err, opts); diff --git a/src/lib/rollout.js b/src/lib/rollout.js index 32f126bb0..ab7646a72 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16395,7 +16395,7 @@ const TRAE_CN_UNKNOWN_MODEL = "trae-cn-unknown"; function normalizeTraeCnState(raw) { if (raw === undefined || raw === null) { - return { version: TRAE_CN_STATE_VERSION, sessions: {}, updatedAt: null }; + return { version: TRAE_CN_STATE_VERSION, sessions: {}, prunedBeforeMs: 0, updatedAt: null }; } if (typeof raw !== "object" || Array.isArray(raw)) { throw new Error("Trae CN cursor state is malformed."); @@ -16409,6 +16409,8 @@ function normalizeTraeCnState(raw) { return { version: TRAE_CN_STATE_VERSION, sessions: { ...raw.sessions }, + prunedBeforeMs: + Number.isFinite(raw.prunedBeforeMs) && raw.prunedBeforeMs > 0 ? raw.prunedBeforeMs : 0, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, }; } @@ -16523,18 +16525,36 @@ function normalizeTraeCnSession(row) { throw new Error("Trae CN session row has an invalid usage_time."); } const extra = traeCnExtraInfo(row); + // Cache fields may be absent for models without a prompt-cache concept + // (Doubao / DeepSeek on TRAE CN) — an absent cache field means "no cache + // activity", i.e. 0, NOT a malformed row. input/output stay mandatory. + // + // `input_token` is cache-INCLUSIVE: verifying real rows against TRAE CN's + // own credit billing, credits = fresh_input*p + cache_read*(p/4) + output*q + // fits every row with zero residual only under this reading (same + // convention as Codex / Qoder). Peel the cache subsets off into their own + // columns and report only the remainder as ordinary input, otherwise cached + // context is double-counted in dashboards and cost calculations. cache_write + // was 0 across all observed rows; it is treated as a further subset of the + // cache-inclusive input (clamped so the buckets always sum back to it). + const cacheRead = traeCnTokenField(row, extra, "cache_read_token"); + const cacheWrite = traeCnTokenField(row, extra, "cache_write_token"); const fields = [ ["input_token", traeCnTokenField(row, extra, "input_token")], ["output_token", traeCnTokenField(row, extra, "output_token")], - ["cache_read_token", traeCnTokenField(row, extra, "cache_read_token")], - ["cache_write_token", traeCnTokenField(row, extra, "cache_write_token")], + ["cache_read_token", cacheRead === undefined || cacheRead === null ? 0 : cacheRead], + ["cache_write_token", cacheWrite === undefined || cacheWrite === null ? 0 : cacheWrite], ]; for (const [label, value] of fields) { if (!Number.isSafeInteger(value) || value < 0) { throw new Error(`Trae CN session row has an invalid ${label}.`); } } - const total = fields[0][1] + fields[1][1] + fields[2][1] + fields[3][1]; + const rawInput = fields[0][1]; + const outputTokens = fields[1][1]; + const cachedInput = Math.min(rawInput, fields[2][1]); + const cacheCreation = Math.min(rawInput - cachedInput, fields[3][1]); + const total = rawInput + outputTokens; if (!Number.isSafeInteger(total)) { throw new Error("Trae CN session row token totals overflow."); } @@ -16543,10 +16563,10 @@ function normalizeTraeCnSession(row) { model, bucketStart, totals: { - input_tokens: fields[0][1], - output_tokens: fields[1][1], - cached_input_tokens: fields[2][1], - cache_creation_input_tokens: fields[3][1], + input_tokens: rawInput - cachedInput - cacheCreation, + output_tokens: outputTokens, + cached_input_tokens: cachedInput, + cache_creation_input_tokens: cacheCreation, reasoning_output_tokens: 0, total_tokens: total, billable_total_tokens: total, @@ -16574,7 +16594,13 @@ function assertTraeCnBucketCovers(bucketTotals, previousTotals) { } } -async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgress } = {}) { +async function parseTraeCnApiIncremental({ + sessions, + cursors, + queuePath, + onProgress, + windowStartMs, +} = {}) { if (!Array.isArray(sessions)) { throw new Error("Trae CN sessions must be an array."); } @@ -16596,9 +16622,23 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr } // Full prevalidation + in-payload dedupe BEFORE any cursor/bucket mutation. + // Row-level schema failures are isolated per row: one malformed row (e.g. a + // negative or non-integer token count) is skipped and counted instead of + // discarding the whole payload — but structural failures (non-array + // sessions, unwritable cursors) and conflicting duplicate contributions + // still fail closed above/below. const bySession = new Map(); + let skippedRows = 0; + let firstSkipReason = ""; for (const row of sessions) { - const normalized = normalizeTraeCnSession(row); + let normalized; + try { + normalized = normalizeTraeCnSession(row); + } catch (error) { + skippedRows += 1; + if (!firstSkipReason) firstSkipReason = error?.message || "unknown"; + continue; + } const existing = bySession.get(normalized.sessionId); if (existing) { const identical = @@ -16612,6 +16652,13 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr } bySession.set(normalized.sessionId, normalized); } + if (bySession.size === 0 && skippedRows > 0) { + // Every row was malformed: fail closed rather than enqueue an empty, + // authoritative-looking snapshot that would read as "no usage". + throw new Error( + `Trae CN session rows are all malformed (${skippedRows} skipped; first: ${firstSkipReason}).`, + ); + } const touchedBuckets = new Set(); const cb = typeof onProgress === "function" ? onProgress : null; @@ -16626,6 +16673,26 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr validateTraeCnStoredContribution(traeCnState.sessions[sessionId]); } + // Monotonic prune (same watermark pattern as the kiroCli cap above): the + // sync caller fetches a fixed trailing window, so a session whose entire + // half-hour bucket sits before windowStartMs can never reappear in a later + // payload — its stored contribution is dead reconciliation weight. Prune + // only when the window start moved FORWARD past the persisted watermark: + // an entry evicted under window W1 and re-fetched under an earlier window + // would find previousEntry undefined and count twice. The +30min margin + // matches bucketStart's half-hour floor, which can trail the row's + // usage_time by up to 30 minutes. + if (Number.isFinite(windowStartMs) && windowStartMs > traeCnState.prunedBeforeMs) { + const pruneCutoffMs = windowStartMs - 30 * 60 * 1000; + for (const sessionId of Object.keys(traeCnState.sessions)) { + const bucketMs = Date.parse(traeCnState.sessions[sessionId].bucketStart); + if (Number.isFinite(bucketMs) && bucketMs <= pruneCutoffMs) { + delete traeCnState.sessions[sessionId]; + } + } + traeCnState.prunedBeforeMs = windowStartMs; + } + // Deterministic: process unique session ids in sorted order. const sessionIds = [...bySession.keys()].sort(); for (const sessionId of sessionIds) { @@ -16678,7 +16745,7 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr cursors.hourly = hourlyState; cursors.traeCn = traeCnState; - return { recordsProcessed: total, eventsAggregated, bucketsQueued }; + return { recordsProcessed: total, eventsAggregated, bucketsQueued, skippedRows }; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js index 2aabcfc59..7379b8e19 100644 --- a/src/lib/trae-cn-config.js +++ b/src/lib/trae-cn-config.js @@ -21,10 +21,23 @@ const os = require("node:os"); const path = require("node:path"); const TRAE_CN_HOME_ENV = "TOKENTRACKER_TRAE_CN_HOME"; +// The TRAE CN usage read transmits the locally stored sign-in JWT to TRAE's +// official endpoint, so it is strictly opt-in: nothing is sent unless the +// user explicitly sets this flag. Any value other than 1/true keeps it off. +const TRAE_CN_USAGE_ENV = "TOKENTRACKER_TRAE_CN_USAGE"; + +function isTraeCnUsageEnabled(env = process.env) { + const value = env ? env[TRAE_CN_USAGE_ENV] : undefined; + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return normalized === "1" || normalized === "true"; +} const TRAE_CN_APP_DIR = "TRAE SOLO CN"; const TRAE_CN_AUTH_KEY = "iCubeAuthInfo://icube.cloudide"; const TRAE_CN_USAGE_URL = "https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session"; +// usage_type [7] mirrors the constant the official TRAE client sends when its +// usage page queries this endpoint (observed in the client's outbound +// requests; the enum is undocumented server-side). Other values unverified. const TRAE_CN_USAGE_TYPE = [7]; const TRAE_CN_USAGE_PAGE_SIZE = 20; const TRAE_CN_USAGE_MAX_PAGES = 100; @@ -42,7 +55,11 @@ const TRAE_CN_AES_KEY_LEN = 16; const TRAE_CN_IV_LEN = 16; // JG / KG — the two halves of the hardcoded (obfuscated) password in the TRAE -// client's byteCrypto.js. Changing these breaks every existing blob. +// client's byteCrypto.js (XOR the pair to recover the KDF secret). Failure +// signature when TRAE ships a new key: readTraeCnAuthFromStorage throws +// "Trae CN auth payload is malformed", sync skips the source, and +// `tokentracker status` reports auth "malformed" — re-extract the pair +// from the updated client to restore decryption. const TRAE_CN_JG = Buffer.from([ 82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, @@ -362,12 +379,17 @@ async function fetchTraeCnUsage({ for (;;) { if (pagesFetched >= maxPages) { // A further page would be needed but the cap prevents it: fail closed - // rather than return a partial snapshot that looks authoritative. - throw new Error("Trae CN usage pagination exceeded the maximum page count."); + // rather than return a partial snapshot that looks authoritative. Cap + // exhaustion is the same over-capacity signal as the declared-total + // check below (which the API may omit) — tag it identically so window + // splitting engages either way. + const error = new Error("Trae CN usage pagination exceeded the maximum page count."); + error.code = "TRAE_CN_USAGE_CAPACITY_EXCEEDED"; + throw error; } const result = await pageFetcher({ jwt, start_time, end_time, page_num: page, page_size, fetchImpl }); pagesFetched += 1; - total = result.total; + total = result.total ?? null; if (pagesFetched === 1 && Number.isFinite(total) && total > page_size * maxPages) { const error = new Error("Trae CN usage snapshot exceeds the supported capacity."); error.code = "TRAE_CN_USAGE_CAPACITY_EXCEEDED"; @@ -454,6 +476,8 @@ async function fetchTraeCnUsageWithAuth({ module.exports = { TRAE_CN_HOME_ENV, + TRAE_CN_USAGE_ENV, + isTraeCnUsageEnabled, TRAE_CN_APP_DIR, TRAE_CN_AUTH_KEY, TRAE_CN_USAGE_URL, diff --git a/test/account-source-parity.test.js b/test/account-source-parity.test.js index da7d9256e..b88eb031b 100644 --- a/test/account-source-parity.test.js +++ b/test/account-source-parity.test.js @@ -5,12 +5,17 @@ // devices) is hardcoded in several places that MUST agree: // - src/lib/source-metadata.js (authoritative, used by the CLI) // - scripts/ops/account-usage-grouped-rpc.sql (account view RPC) -// - scripts/ops/leaderboard-usage-grouped-rpc.sql (leaderboard RPC) // - dashboard/edge-patches/tokentracker-leaderboard-profile.ts (profile edge) // - dashboard/edge-patches/tokentracker-account-devices.ts (device breakdown edge) // A drift (e.g. adding a new account-level source to source-metadata.js but // forgetting the SQL) silently re-introduces the cross-device double-count bug // that v0.44 fixed. This test fails loudly on any mismatch. +// +// NOT covered here: scripts/ops/leaderboard-usage-grouped-rpc.sql (SUPERSEDED +// rollback reference — do not edit or verify) and the live +// leaderboard_hourly_dedup_v2 function's account_sources array, which lives +// only in the deployed database (maintainer-side; a PR adding an account-level +// source must call it out in its deployment notes). const fs = require("node:fs"); const path = require("node:path"); @@ -46,7 +51,7 @@ function extractSqlAccountSources(content) { .sort(); } -test("account-level source list is identical across source-metadata, both RPCs, and the profile edge", () => { +test("account-level source list is identical across source-metadata, the account RPC, and the profile edge", () => { const authoritative = extractJsSet( readFile("src/lib/source-metadata.js"), "ACCOUNT_LEVEL_SOURCES", @@ -60,9 +65,6 @@ test("account-level source list is identical across source-metadata, both RPCs, "account-usage-grouped-rpc.sql": extractSqlAccountSources( readFile("scripts/ops/account-usage-grouped-rpc.sql"), ), - "leaderboard-usage-grouped-rpc.sql": extractSqlAccountSources( - readFile("scripts/ops/leaderboard-usage-grouped-rpc.sql"), - ), "tokentracker-leaderboard-profile.ts": extractJsSet( readFile("dashboard/edge-patches/tokentracker-leaderboard-profile.ts"), "ACCOUNT_LEVEL_SOURCES", diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js index 4c6ab3357..9ec7673b0 100644 --- a/test/sync-trae-cn.test.js +++ b/test/sync-trae-cn.test.js @@ -61,6 +61,7 @@ async function withTempTraeEnv(fn) { TOKENTRACKER_OPENCLAW_PREV_SESSION_ID: process.env.TOKENTRACKER_OPENCLAW_PREV_SESSION_ID, TOKENTRACKER_OPENCLAW_SESSION_KEY: process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY, TOKENTRACKER_TRAE_CN_HOME: process.env.TOKENTRACKER_TRAE_CN_HOME, + TOKENTRACKER_TRAE_CN_USAGE: process.env.TOKENTRACKER_TRAE_CN_USAGE, TOKENTRACKER_WSL_MODE: process.env.TOKENTRACKER_WSL_MODE, }; try { @@ -73,6 +74,7 @@ async function withTempTraeEnv(fn) { process.env.XDG_DATA_HOME = path.join(home, ".local", "share"); process.env.TOKENTRACKER_OPENCLAW_HOME = path.join(home, ".openclaw"); process.env.TOKENTRACKER_TRAE_CN_HOME = traeCnHome; + process.env.TOKENTRACKER_TRAE_CN_USAGE = "1"; delete process.env.TOKENTRACKER_REASONIX_HOME; delete process.env.REASONIX_STATE_HOME; delete process.env.TOKENTRACKER_DEVICE_TOKEN; @@ -230,6 +232,32 @@ test("background and all-local syncs never call the TRAE CN fetch", async () => }); }); +test("TRAE CN usage read stays off unless TOKENTRACKER_TRAE_CN_USAGE opts in", async () => { + await withTempTraeEnv(async ({ home }) => { + writeTraeCnStorage(path.join(home, "trae-cn-data")); + let fetchCalls = 0; + const fetchImpl = async () => { + fetchCalls += 1; + throw new Error("should not fetch"); + }; + + const savedFlag = process.env.TOKENTRACKER_TRAE_CN_USAGE; + delete process.env.TOKENTRACKER_TRAE_CN_USAGE; + try { + await cmdSync(["--auto", "--source=trae-cn"], { + traeCnFetchImpl: fetchImpl, + traeCnNowMs: NOW_MS, + }); + } finally { + if (savedFlag !== undefined) process.env.TOKENTRACKER_TRAE_CN_USAGE = savedFlag; + } + + assert.equal(fetchCalls, 0, "no request without the opt-in flag"); + assert.equal(readQueueRows(home).some((row) => row.source === "trae-cn"), false); + assert.equal(readCursors(home).traeCn, undefined); + }); +}); + test("an absent TRAE CN storage file does not call the fetch", async () => { await withTempTraeEnv(async ({ home }) => { let fetchCalls = 0; diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js index 36d73d508..ee762ff10 100644 --- a/test/trae-cn-config.test.js +++ b/test/trae-cn-config.test.js @@ -524,7 +524,13 @@ test("fetchTraeCnUsage fails closed when maxPages is reached and more is needed" const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 20 }); await assert.rejects( fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 3 }), - /exceeded the maximum page count/, + (error) => { + assert.match(error.message, /exceeded the maximum page count/); + // Same capacity tag as the declared-total path so window splitting + // engages even when the API omits total. + assert.equal(error.code, "TRAE_CN_USAGE_CAPACITY_EXCEEDED"); + return true; + }, ); // Never returns a partial snapshot: all 3 cap pages were fetched, then it // throws because page 4 would still be needed. @@ -677,6 +683,38 @@ test("fetchTraeCnUsageWindowed fails closed at the split-depth ceiling", async ( assert.ok(requests.length > 1, "splitting was attempted"); }); +test("fetchTraeCnUsageWindowed splits when cap exhaustion signals capacity without a declared total", async () => { + const mid = START + Math.floor((END - START) / 2); + const fullPage = Array.from({ length: 20 }, (_, i) => ({ session_id: `row-${i}` })); + const { fetchImpl, requests } = windowedFetch((body) => { + if (body.start_time === START && body.end_time === END) { + // Full pages with total omitted: the ONLY over-capacity signal is + // maxPages exhaustion, which must carry the capacity code too. + return { user_usage_group_by_sessions: fullPage }; + } + return { user_usage_group_by_sessions: [{ session_id: "ok" }], total: 1 }; + }); + const result = await fetchTraeCnUsageWindowed({ + jwt: JWT, + start_time: START, + end_time: END, + fetchImpl, + delayMs: 0, + maxPages: 2, + }); + assert.deepEqual( + requests.map((b) => [b.start_time, b.end_time]), + [ + [START, END], // page 1 + [START, END], // page 2 -> cap exhausted, split + [START, mid], + [mid + 1, END], + ], + ); + assert.equal(result.sessions.length, 2); + assert.equal(result.total, 2); +}); + test("fetchTraeCnUsageWindowed rethrows non-capacity errors untouched", async () => { const requests = []; const fetchImpl = async (_url, options) => { diff --git a/test/trae-cn-parser.test.js b/test/trae-cn-parser.test.js index ba4b98baf..f067a817e 100644 --- a/test/trae-cn-parser.test.js +++ b/test/trae-cn-parser.test.js @@ -116,23 +116,51 @@ test("first insert maps direct and extra_info shapes into canonical buckets", as billable: pro.billable_total_tokens, conv: pro.conversation_count, }, - { input: 100, cached: 30, cacheCreation: 40, output: 10, reasoning: 0, total: 180, billable: 180, conv: 1 }, + // input_token is cache-inclusive: fresh = 100-30-40, total = 100+10. + { input: 30, cached: 30, cacheCreation: 40, output: 10, reasoning: 0, total: 110, billable: 110, conv: 1 }, ); assert.equal(lite.hour_start, B2); - assert.equal(lite.input_tokens, 5); + // Clamped to the cache-inclusive input: cached=min(5,7)=5, creation + // gets no room left (min(0,8)=0), fresh=0, total=5+6. + assert.equal(lite.input_tokens, 0); assert.equal(lite.output_tokens, 6); - assert.equal(lite.cached_input_tokens, 7); - assert.equal(lite.cache_creation_input_tokens, 8); - assert.equal(lite.total_tokens, 26); + assert.equal(lite.cached_input_tokens, 5); + assert.equal(lite.cache_creation_input_tokens, 0); + assert.equal(lite.total_tokens, 11); assert.equal(cursors.traeCn.version, 1); assert.equal(cursors.traeCn.sessions["s-direct"].model, "doubao-pro"); assert.equal(cursors.traeCn.sessions["s-direct"].bucketStart, B1); - assert.equal(cursors.traeCn.sessions["s-direct"].totals.input_tokens, 100); + assert.equal(cursors.traeCn.sessions["s-direct"].totals.input_tokens, 30); assert.equal(cursors.traeCn.sessions["s-extra"].bucketStart, B2); }); +test("cache-inclusive input_token is peeled into subset columns (real-data shape)", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + // Shape observed on real GLM rows: every prompt token hit the cache, so + // input_token equals cache_read_token exactly (ratio floor 1.0 across 135 + // real rows). Ordinary input must be 0, not input_token again. + const fullHit = sessionRow({ session_id: "full-hit", input_token: 500, output_token: 7, cache_read_token: 500 }); + // Partial hit: 200 of 500 cached -> fresh 300, total = 500 + 7. + const partial = sessionRow({ session_id: "partial", input_token: 500, output_token: 7, cache_read_token: 200 }); + // No cache activity at all (Doubao/DeepSeek shape): columns stay as-is. + const noCache = sessionRow({ session_id: "no-cache", input_token: 500, output_token: 7 }); + + await parseTraeCnApiIncremental({ sessions: [fullHit, partial, noCache], cursors, queuePath }); + assert.equal(cursors.traeCn.sessions["full-hit"].totals.input_tokens, 0); + assert.equal(cursors.traeCn.sessions["full-hit"].totals.cached_input_tokens, 500); + assert.equal(cursors.traeCn.sessions["full-hit"].totals.total_tokens, 507); + assert.equal(cursors.traeCn.sessions["partial"].totals.input_tokens, 300); + assert.equal(cursors.traeCn.sessions["partial"].totals.cached_input_tokens, 200); + assert.equal(cursors.traeCn.sessions["partial"].totals.total_tokens, 507); + assert.equal(cursors.traeCn.sessions["no-cache"].totals.input_tokens, 500); + assert.equal(cursors.traeCn.sessions["no-cache"].totals.cached_input_tokens, 0); + assert.equal(cursors.traeCn.sessions["no-cache"].totals.total_tokens, 507); +}); + test("identical payload twice appends no queue row and no token growth", async (t) => { const { dir, queuePath } = tempQueue(); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); @@ -281,20 +309,45 @@ test("conflicting duplicate ids and malformed rows fail before any mutation", as assert.equal(cursors.traeCn, undefined, "no traeCn state assigned"); assert.equal(fs.existsSync(queuePath), false, "no queue rows written"); - // Malformed rows: missing token field must not be coerced to zero. + // Cache fields are optional: models without a prompt-cache concept (Doubao / + // DeepSeek on TRAE CN) legitimately omit them — absent means 0, not malformed. + // Uses its own queue dir so the no-mutation assertions below stay meaningful. + const optionalQueue = tempQueue(); + const cursorsOptional = {}; const missing = sessionRow({ session_id: canary }); delete missing.cache_read_token; - await assert.rejects( - parseTraeCnApiIncremental({ sessions: [missing], cursors, queuePath }), - (error) => { - assert.match(error.message, /invalid cache_read_token/); - assert.ok(!error.message.includes(canary)); - return true; - }, - ); + delete missing.cache_write_token; + const optionalResult = await parseTraeCnApiIncremental({ + sessions: [missing], + cursors: cursorsOptional, + queuePath: optionalQueue.queuePath, + }); + assert.equal(optionalResult.skippedRows, 0); + assert.equal(cursorsOptional.traeCn.sessions[canary].totals.cached_input_tokens, 0); + fs.rmSync(optionalQueue.dir, { recursive: true, force: true }); + + // A malformed row is isolated: skipped and counted, the rest still imports. + const mixedQueue = tempQueue(); + const cursorsMixed = {}; + const mixedResult = await parseTraeCnApiIncremental({ + sessions: [ + sessionRow({ session_id: canary, cache_read_token: "lots" }), + sessionRow({ session_id: "healthy-row" }), + ], + cursors: cursorsMixed, + queuePath: mixedQueue.queuePath, + }); + assert.equal(mixedResult.skippedRows, 1); + assert.equal(cursorsMixed.traeCn.sessions[canary], undefined, "bad row never imported"); + assert.ok(cursorsMixed.traeCn.sessions["healthy-row"], "healthy row imported"); + fs.rmSync(mixedQueue.dir, { recursive: true, force: true }); + + // A payload where EVERY row is malformed fails closed (never an empty, + // authoritative-looking snapshot) and the reason stays diagnosable + id-free. await assert.rejects( parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, cache_read_token: "lots" })], cursors, queuePath }), (error) => { + assert.match(error.message, /all malformed/); assert.match(error.message, /invalid cache_read_token/); assert.ok(!error.message.includes(canary)); return true; @@ -304,6 +357,7 @@ test("conflicting duplicate ids and malformed rows fail before any mutation", as await assert.rejects( parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, model_name: "bad|model" })], cursors, queuePath }), (error) => { + assert.match(error.message, /all malformed/); assert.match(error.message, /unsupported model name/); assert.ok(!error.message.includes(canary)); return true; @@ -502,3 +556,44 @@ test("an unrelated malformed stored session fails a new valid payload before mut assert.equal(JSON.stringify(cursors), before, "no mutation on malformed stored state"); assert.equal(fs.existsSync(queuePath), false, "no queue rows written"); }); + +test("window prune drops pre-window sessions monotonically and never rewinds", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + await parseTraeCnApiIncremental({ + sessions: [ + sessionRow({ session_id: "old-s" }), // bucket B1 = 22:00 + sessionRow({ session_id: "new-s", usage_time: T2 }), // bucket B2 = 22:30 + ], + cursors, + queuePath, + }); + assert.ok(cursors.traeCn.sessions["old-s"]); + assert.ok(cursors.traeCn.sessions["new-s"]); + assert.equal(cursors.traeCn.prunedBeforeMs, 0, "no prune before a window is supplied"); + + // Window starts at 22:33:20 (T1 + 20min): old-s's whole bucket (22:00) is + // before it and can never reappear; new-s (22:46:40, bucket 22:30) is in-window. + const windowStartMs = (T1 + 20 * 60) * 1000; + await parseTraeCnApiIncremental({ + sessions: [sessionRow({ session_id: "new-s", usage_time: T2, output_token: 20 })], + cursors, + queuePath, + windowStartMs, + }); + assert.equal(cursors.traeCn.sessions["old-s"], undefined, "pre-window entry pruned"); + assert.ok(cursors.traeCn.sessions["new-s"], "in-window entry kept"); + assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs); + + // An earlier window start must not rewind the watermark or re-prune; a + // re-reported in-window session still reconciles against its stored entry. + await parseTraeCnApiIncremental({ + sessions: [sessionRow({ session_id: "new-s", usage_time: T2 })], + cursors, + queuePath, + windowStartMs: (T1 + 60) * 1000, + }); + assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs, "watermark never rewinds"); + assert.ok(cursors.traeCn.sessions["new-s"]); +}); From 2b4abc7cc4bf19748f5b741ccbcd745cc8a9076f Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 01:14:55 +0800 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20TRAE=20Work=20CN=20=E9=87=87?= =?UTF-8?q?=E7=94=A8=E5=AE=98=E6=96=B9=E6=A0=87=E8=AF=86=E4=B8=8E=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=93=81=E7=89=8C=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图标改为官方 app 图标样式(白色圆角方 + 黑色面具图形,两个菱形眼), 从 work.trae.cn 官方 PWA 图标逐像素复刻,固定黑白配色适配深浅主题 - zh/zh-TW 显示名统一为品牌名 TRAE Work CN(与其他语言及 copy.csv 一致) --- dashboard/src/content/i18n/zh-TW/core.json | 2 +- dashboard/src/content/i18n/zh/core.json | 2 +- .../ui/dashboard/components/ProviderIcon.jsx | 25 +++++++++++++------ .../components/ProviderIcon.test.jsx | 4 ++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json index bb0f5a409..5b9aa5e4c 100644 --- a/dashboard/src/content/i18n/zh-TW/core.json +++ b/dashboard/src/content/i18n/zh-TW/core.json @@ -154,7 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", - "provider.display.trae_work_cn": "TRAE Work 中國版", + "provider.display.trae_work_cn": "TRAE Work CN", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json index c7b029a29..400a69e59 100644 --- a/dashboard/src/content/i18n/zh/core.json +++ b/dashboard/src/content/i18n/zh/core.json @@ -154,7 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", - "provider.display.trae_work_cn": "TRAE Work 中国版", + "provider.display.trae_work_cn": "TRAE Work CN", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx index f419d1bc4..aa6edf987 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx @@ -117,22 +117,33 @@ function CodeBuddyIcon({ size = 16, className = "" }) { ); } -// Compact local monogram for TRAE Work CN. It is deliberately not a claimed -// official asset: the single-color mark stays legible in the existing 16–20px -// slots and inherits currentColor in both themes. +// TRAE Work CN — official app-icon mark (black mask with two diamond eyes +// and the notched lower-left corner on a white rounded square), traced from +// the TRAE SOLO CN desktop app icon. Like the Craft mark, the fixed +// white/black palette reads on both light and dark themes and therefore does +// not adapt to currentColor. function TraeCnIcon({ size = 16, className = "" }) { return ( ); } diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx index f8e4dc2a1..f2a493316 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx @@ -86,7 +86,9 @@ describe("ProviderIcon", () => { expect(icon).toHaveAttribute("width", "20"); expect(icon).toHaveAttribute("height", "20"); expect(icon).toHaveAttribute("aria-hidden", "true"); - expect(icon).toHaveAttribute("fill", "currentColor"); + // Official app-icon palette: white rounded square + black mask, fixed + // colors instead of currentColor (same policy as the Craft mark). + expect(icon?.querySelector("rect")).not.toBeNull(); expect(icon?.querySelector("path")).not.toBeNull(); expect(icon?.querySelector("circle")).toBeNull(); expect(container.querySelector(".text-oai-gray-400")).toBeNull(); From 61366ec8e57d8f7672e01fa84953325fb81759dc Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 01:15:45 +0800 Subject: [PATCH 09/19] =?UTF-8?q?docs:=20TRAE=20CN=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=88=AA=E5=9B=BE=EF=BC=88=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=2030=20=E5=A4=A9=E6=95=B0=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/screenshots/trae-cn-sync-verification.png | Bin 0 -> 141011 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/trae-cn-sync-verification.png diff --git a/docs/screenshots/trae-cn-sync-verification.png b/docs/screenshots/trae-cn-sync-verification.png new file mode 100644 index 0000000000000000000000000000000000000000..7ddd55e27e19107ddeadcaf94845d5e996b456ed GIT binary patch literal 141011 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�JTg& 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

    hN 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 BEad Date: Mon, 17 Aug 2026 02:05:16 +0800 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20TRAE=20Work=20CN=20=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E6=94=B9=E4=B8=BA=E5=AE=98=E6=96=B9=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=EF=BC=88=E5=AF=B9=E9=BD=90=20work.trae.cn=20favicon=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 ProviderIcon 的 TraeCnIcon 从 深色渐变底+白色对称括号 - 改为 work.trae.cn 官方 favicon / PWA icon-512.png 风格: 白底圆角方形 (rx=76, 512px 画布) + 纯黑括号形 (顶部横条 + 双臂 + 双纺锤装饰),像素级逆向几何一致 - 验证:vitest ProviderIcon.test.jsx 11/11 通过 lint 0 错误 / build 成功 localhost:7681 GUI 验收通过 - 影响文件:dashboard/src/ui/dashboard/components/ProviderIcon.jsx --- .../ui/dashboard/components/ProviderIcon.jsx | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx index aa6edf987..a11f21a7f 100644 --- a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx +++ b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx @@ -117,11 +117,22 @@ function CodeBuddyIcon({ size = 16, className = "" }) { ); } -// TRAE Work CN — official app-icon mark (black mask with two diamond eyes -// and the notched lower-left corner on a white rounded square), traced from -// the TRAE SOLO CN desktop app icon. Like the Craft mark, the fixed -// white/black palette reads on both light and dark themes and therefore does -// not adapt to currentColor. +// TRAE Work CN — official app-logo mark reverse-engineered from +// https://work.trae.cn/ favicon (48×48) and PWA manifest icon-512.png +// (pixel-accurate geometry, 512×512 canvas). +// +// Structure (confirmed against 512px source RGBA raster): +// • White rounded square (#FFFFFF, corners transparent so the circular +// alpha mask reads correctly on any browser tab chrome). +// • Pure black (#000000) bracket-frame glyph over it: +// – a full-width top crossbar, +// – two 36px-thick vertical arms (left arm floats; right arm anchors +// the bottom crossbar which therefore sits 36px inset on the left), +// – two spindle accents midway inside the arms (rhombic ellipses, +// rx≈24 ry≈22). +// +// The white plate + solid black ink matches the favicon pixel-for-pixel and +// therefore stays consistent with the brand assets served from work.trae.cn. function TraeCnIcon({ size = 16, className = "" }) { return ( ); From 6066118a3f6980dcfbed48735629b85926ba23be Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 03:12:34 +0800 Subject: [PATCH 11/19] =?UTF-8?q?fix:=20TRAE=20CN=20=E8=B7=A8=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E8=B4=A6=E6=88=B7=E5=BF=AB=E7=85=A7=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=EF=BC=88watermark=20=E6=89=80=E6=9C=89=E6=9D=83=EF=BC=89+=20st?= =?UTF-8?q?atus/robustness=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨设备去重(merge blocker): - 每次成功的 trae-cn 同步在 queue 追加 account_sync_watermark 记录, 声明刚对 API 验证过的闭合窗口;ingest edge upsert 到 tokentracker_account_sync_watermarks(bucket 行之后写入,malformed 拒收) - account_usage_grouped / leaderboard_hourly_dedup_v2 改为按小时选择 watermark 属主设备(window_end DESC, updated_at DESC, device_id), 只计属主设备整小时快照:向下/向上纠正、model 迁移、bucket 迁移、 新设备首同步、双设备同快照均不再双算或残留 stale tuple - 无 watermark 覆盖的小时(cursor / 早于验证窗口的历史)保留原 whole-row MAX 语义;src/lib/account-usage-dedup.js 为可执行规格, test/account-usage-dedup.test.js 钉住 A-E 场景与 SQL/JS 一致性 - 相同窗口重复同步不重复追加 watermark(cursor 记录 lastWatermark), 保持固定时刻幂等同步字节不变 status / robustness: - installed 改为 storage 文件实际存在(与 sync 语义一致), auth 状态区分 not-signed-in / readable / malformed / unreadable - usage API total 严格校验(非负安全整数,拒绝字符串/小数/负数), malformed total fail-closed,不再静默截断分页 - storage 读取区分不存在(未登录)与 IO 失败(TRAE_CN_STORAGE_UNREADABLE, generic 报错不泄露路径/内容) - JWT 仅发往固定官方 HTTPS endpoint:补安全注释供 CodeQL thread 引用 其他: - agent-logos.js TRAE 条目移除死 fallback name;补 zh「TRAE Work 中国版」/ zh-TW「TRAE Work 中國版」译文(修复 LogoCarousel 测试与 locale 校验) - ui-hardcode baseline 更新(ProviderIcon 官方图标品牌色 2->9) - leaderboard_hourly_dedup_v2 的 account_sources 补 trae-cn(原先走 machine SUM 路径会双算),parity test 纳入 migration 文件 --- dashboard/edge-patches/tokentracker-ingest.ts | 55 ++- .../tokentracker-leaderboard-profile.ts | 4 +- dashboard/src/content/i18n/zh-TW/core.json | 2 +- dashboard/src/content/i18n/zh/core.json | 2 +- dashboard/src/ui/marketing/agent-logos.js | 4 +- ...20260817120000_account-sync-watermarks.sql | 188 +++++++++ scripts/ops/account-usage-grouped-rpc.sql | 106 ++++- scripts/ops/ui-hardcode-baseline.json | 6 +- src/commands/status.js | 13 +- src/commands/sync.js | 42 +- src/lib/account-usage-dedup.js | 174 ++++++++ src/lib/local-api.js | 3 + src/lib/rollout.js | 99 ++++- src/lib/trae-cn-config.js | 49 ++- test/account-source-parity.test.js | 12 +- test/account-usage-dedup.test.js | 399 ++++++++++++++++++ test/status.test.js | 194 +++++++++ test/sync-trae-cn.test.js | 39 +- test/trae-cn-config.test.js | 106 +++++ 19 files changed, 1449 insertions(+), 48 deletions(-) create mode 100644 migrations/20260817120000_account-sync-watermarks.sql create mode 100644 src/lib/account-usage-dedup.js create mode 100644 test/account-usage-dedup.test.js diff --git a/dashboard/edge-patches/tokentracker-ingest.ts b/dashboard/edge-patches/tokentracker-ingest.ts index bd88c1c18..f288a0370 100644 --- a/dashboard/edge-patches/tokentracker-ingest.ts +++ b/dashboard/edge-patches/tokentracker-ingest.ts @@ -105,7 +105,39 @@ export default async function (req: Request): Promise { : Array.isArray(body.hourly) ? body.hourly : []; - if (!Array.isArray(buckets) || buckets.length === 0) { + + // Account-sync watermarks (kind: "account_sync_watermark" queue records) + // assert the closed window this device just verified against an + // account-level source API (trae-cn). The cloud aggregation + // (account_usage_grouped / leaderboard_hourly_dedup_v2) picks, per hour, + // the freshest covering watermark device as the canonical owner, so a + // newer snapshot displaces stale tuples from devices holding older + // versions. A watermark-only batch is a meaningful upload (e.g. a + // verified-empty window) and must not fall into the no-buckets reject. + const rawWatermarks = Array.isArray(body.account_watermarks) + ? body.account_watermarks + : []; + if (rawWatermarks.length > 50) { + return json({ error: "Too many account watermarks (max 50)" }, 400); + } + const watermarks = []; + for (const w of rawWatermarks) { + const source = typeof w?.source === "string" ? w.source.trim().toLowerCase() : ""; + const start = Date.parse(String(w?.window_start ?? "")); + const end = Date.parse(String(w?.window_end ?? "")); + // Fail closed on a malformed watermark: writing a bogus window would + // corrupt cross-device ownership for the whole account. + if (!source || !Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + return json({ error: "Invalid account watermark" }, 400); + } + watermarks.push({ + source, + window_start: new Date(start).toISOString(), + window_end: new Date(end).toISOString(), + }); + } + + if ((!Array.isArray(buckets) || buckets.length === 0) && watermarks.length === 0) { return json({ error: "No usage buckets provided" }, 400); } if (buckets.length > 500) { @@ -157,5 +189,26 @@ export default async function (req: Request): Promise { if (upsertErr) return json({ error: upsertErr.message }, 500); + if (watermarks.length > 0) { + // Upsert AFTER the bucket rows of the same upload landed, so a watermark + // never covers hours whose owning rows failed to write. updated_at is + // set explicitly: freshness ordering (window_end DESC, updated_at DESC) + // must see this sync as newer than the previous one from this device. + const wmRows = watermarks.map((w) => ({ + user_id: userId, + device_id: deviceId, + source: w.source, + window_start: w.window_start, + window_end: w.window_end, + updated_at: new Date().toISOString(), + })); + const { error: wmErr } = await client.database + .from("tokentracker_account_sync_watermarks") + .upsert(wmRows, { + onConflict: "user_id,device_id,source", + }); + if (wmErr) return json({ error: wmErr.message }, 500); + } + return json({ ok: true, inserted: rows.length, skipped: 0 }); } diff --git a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts b/dashboard/edge-patches/tokentracker-leaderboard-profile.ts index fa0a0804c..b2f5bba61 100644 --- a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts +++ b/dashboard/edge-patches/tokentracker-leaderboard-profile.ts @@ -532,8 +532,8 @@ async function fetchDailyGroupedRows( ): Promise { // The two-class cross-device aggregation runs in Postgres // (account_usage_grouped), matching tokentracker-leaderboard-refresh.ts: - // * ACCOUNT-LEVEL sources (cursor): ONE canonical whole row per - // (bucket, source, model), across ALL devices (device-independent). + // * ACCOUNT-LEVEL sources (cursor, trae-cn): ONE canonical whole row + // per (bucket, source, model), across ALL devices (device-independent). // * MACHINE-LEVEL sources: summed across the user's ACTIVE devices // (revoked_at IS NULL), dropping historic device churn. // Day grain is enough — the modal only aggregates by day. The cached diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json index 5b9aa5e4c..bb0f5a409 100644 --- a/dashboard/src/content/i18n/zh-TW/core.json +++ b/dashboard/src/content/i18n/zh-TW/core.json @@ -154,7 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", - "provider.display.trae_work_cn": "TRAE Work CN", + "provider.display.trae_work_cn": "TRAE Work 中國版", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json index 400a69e59..c7b029a29 100644 --- a/dashboard/src/content/i18n/zh/core.json +++ b/dashboard/src/content/i18n/zh/core.json @@ -154,7 +154,7 @@ "limits.provider.copilot": "GitHub Copilot", "provider.display.omp": "oh-my-pi", "provider.display.deepseek_harness": "DeepSeek Harness", - "provider.display.trae_work_cn": "TRAE Work CN", + "provider.display.trae_work_cn": "TRAE Work 中国版", "limits.provider.antigravity": "Antigravity", "limits.provider.zcode": "ZCode", "limits.provider.opencode_go": "OpenCode Go", diff --git a/dashboard/src/ui/marketing/agent-logos.js b/dashboard/src/ui/marketing/agent-logos.js index 347d081dd..0f0dbd6a1 100644 --- a/dashboard/src/ui/marketing/agent-logos.js +++ b/dashboard/src/ui/marketing/agent-logos.js @@ -36,7 +36,9 @@ export const AGENT_LOGOS = [ { id: 31, name: "DeepSeek Harness", provider: "dsh" }, { id: 32, - name: "TRAE Work CN", + // No hardcoded English fallback name: every consumer renders through + // copy() (LogoCarousel prefers nameKey; see LogoCarousel.test.jsx), so a + // parallel "name" string would just duplicate the copy.csv entry. nameKey: "provider.display.trae_work_cn", provider: "trae-cn", }, diff --git a/migrations/20260817120000_account-sync-watermarks.sql b/migrations/20260817120000_account-sync-watermarks.sql new file mode 100644 index 000000000..1ec1d7e1b --- /dev/null +++ b/migrations/20260817120000_account-sync-watermarks.sql @@ -0,0 +1,188 @@ +-- Account-level sources serve CORRECTABLE account snapshots, not append-only +-- machine logs: TRAE CN (trae-cn) can revise a session's totals downward, +-- move it to another model, or shift its time bucket. Each device reconciles +-- those corrections against its own cursor state, but two devices can hold +-- different snapshot VERSIONS of the same account hour, and the previous +-- cloud aggregation (per-(hour, model) MAX across devices) stitches +-- incompatible versions together: +-- downward correction old device H/A=100, fresh device H/A=60 -> 100 (should be 60) +-- model migration old device H/A=100, fresh device H/B=100 -> 200 (should be 100) +-- bucket migration old device 10:00/A=100, fresh 10:30/A=100 -> 200 (should be 100) +-- +-- Fix: every successful account-source sync appends a watermark record to the +-- CLI queue (kind: account_sync_watermark; src/lib/rollout.js) right after +-- the reconciled bucket rows; the ingest edge upserts it here. An hour +-- covered by a watermark is OWNED by the freshest covering watermark's +-- device: only that device's rows for the hour count (all models; a missing +-- model is 0), so a fresher snapshot displaces stale tuples from devices +-- that never saw the correction — including displacement by a brand-new +-- device with no prior cursor state, because ownership is asserted per hour +-- RANGE, not per tuple. Hours covered by no watermark (cursor, or trae-cn +-- history older than every verified window) keep the legacy whole-row MAX +-- dedup, so watermark-less sources do not regress. +-- +-- Semantics mirrored by src/lib/account-usage-dedup.js and pinned by +-- test/account-usage-dedup.test.js. Apply BEFORE re-deploying the updated +-- scripts/ops/account-usage-grouped-rpc.sql (that RPC reads this table). +-- Rollback: DROP TABLE tokentracker_account_sync_watermarks; and re-apply the +-- previous leaderboard_hourly_dedup_v2 definition. + +CREATE TABLE IF NOT EXISTS public.tokentracker_account_sync_watermarks ( + user_id uuid NOT NULL, + device_id uuid NOT NULL, + source text NOT NULL, + window_start timestamptz NOT NULL, + window_end timestamptz NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, device_id, source), + CHECK (window_end > window_start) +); + +ALTER TABLE public.tokentracker_account_sync_watermarks ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.tokentracker_account_sync_watermarks FROM PUBLIC, anon, authenticated; +GRANT ALL ON public.tokentracker_account_sync_watermarks TO project_admin; + +CREATE INDEX IF NOT EXISTS tokentracker_account_sync_watermarks_lookup_idx + ON public.tokentracker_account_sync_watermarks (user_id, source, window_end); + +-- Leaderboard: same per-hour canonical ownership for account-level sources, +-- plus trae-cn joins the account_sources list (it was missing, which sent +-- trae-cn rows down the machine-cluster SUM path and double-counted any +-- multi-device trae-cn user). Historical rollup days self-heal through the +-- existing 7-day repair loop in leaderboard_rollup_daily_advance_v2; an +-- immediate full rebuild is optional (call leaderboard_rollup_daily_replace_v2 +-- for the affected range). +CREATE OR REPLACE FUNCTION public.leaderboard_hourly_dedup_v2( + p_from timestamptz, + p_to timestamptz +) RETURNS TABLE ( + user_id uuid, + source text, + model text, + hour_start timestamptz, + total_tokens bigint, + input_tokens bigint, + output_tokens bigint, + cached_input_tokens bigint, + cache_creation_input_tokens bigint, + reasoning_output_tokens bigint +) +LANGUAGE sql STABLE +AS $func$ + WITH cfg AS ( + SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources + ), + acct_hours AS ( + SELECT DISTINCT h.user_id, h.source, h.hour_start + FROM tokentracker_hourly h CROSS JOIN cfg + WHERE h.hour_start >= p_from AND h.hour_start < p_to + AND h.source = ANY(cfg.account_sources) + ), + -- Per (user, account source, hour): the freshest watermark window covering + -- that hour owns it (NULL = uncovered, legacy dedup below). Deterministic + -- tiebreak: window_end DESC, updated_at DESC, device_id. + acct_own AS ( + SELECT ah.user_id, ah.source, ah.hour_start, + (SELECT w.device_id + FROM tokentracker_account_sync_watermarks w + WHERE w.user_id = ah.user_id + AND w.source = ah.source + AND ah.hour_start >= w.window_start + AND ah.hour_start < w.window_end + ORDER BY w.window_end DESC, w.updated_at DESC, w.device_id + LIMIT 1) AS owner_device_id + FROM acct_hours ah + ) + -- Deduplicate device-id drift/replays inside one physical machine cluster, + -- then add genuinely distinct machines for the same user/hour/model. + SELECT mac.user_id, mac.source, mac.model, mac.hour_start, + SUM(mac.total_tokens)::bigint AS total_tokens, + SUM(mac.input_tokens)::bigint AS input_tokens, + SUM(mac.output_tokens)::bigint AS output_tokens, + SUM(mac.cached_input_tokens)::bigint AS cached_input_tokens, + SUM(mac.cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, + SUM(mac.reasoning_output_tokens)::bigint AS reasoning_output_tokens + FROM ( + SELECT DISTINCT ON ( + h.user_id, + COALESCE(dm.machine_cluster_id, h.device_id::text), + h.source, + h.model, + h.hour_start + ) + h.user_id, + COALESCE(dm.machine_cluster_id, h.device_id::text) AS machine_cluster_id, + h.source, h.model, h.hour_start, + h.total_tokens::bigint AS total_tokens, + h.input_tokens::bigint AS input_tokens, + h.output_tokens::bigint AS output_tokens, + h.cached_input_tokens::bigint AS cached_input_tokens, + h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, + h.reasoning_output_tokens::bigint AS reasoning_output_tokens + FROM tokentracker_hourly h + CROSS JOIN cfg + JOIN tokentracker_devices d + ON d.id = h.device_id AND d.revoked_at IS NULL + LEFT JOIN tokentracker_device_machine dm + ON dm.device_id = h.device_id + WHERE h.hour_start >= p_from AND h.hour_start < p_to + AND NOT (h.source = ANY(cfg.account_sources)) + ORDER BY + h.user_id, + COALESCE(dm.machine_cluster_id, h.device_id::text), + h.source, + h.model, + h.hour_start, + h.total_tokens DESC, + h.updated_at DESC + ) mac + GROUP BY mac.user_id, mac.source, mac.model, mac.hour_start + + UNION ALL + + -- Account-level, WATERMARK-COVERED hours: the owner device's rows count + -- exclusively (all models of that one snapshot; a missing model is 0 and + -- another device's stale tuple for the hour is displaced). + SELECT h.user_id, h.source, h.model, h.hour_start, + h.total_tokens::bigint AS total_tokens, + h.input_tokens::bigint AS input_tokens, + h.output_tokens::bigint AS output_tokens, + h.cached_input_tokens::bigint AS cached_input_tokens, + h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, + h.reasoning_output_tokens::bigint AS reasoning_output_tokens + FROM tokentracker_hourly h + JOIN acct_own o + ON o.user_id = h.user_id + AND o.hour_start = h.hour_start + AND o.source = h.source + AND o.owner_device_id = h.device_id + WHERE h.hour_start >= p_from AND h.hour_start < p_to + + UNION ALL + + -- Account-level, UNCOVERED hours (cursor, watermark-less history): the + -- legacy whole-row MAX pick per (user, hour, source, model). + SELECT acct.user_id, acct.source, acct.model, acct.hour_start, + acct.total_tokens, acct.input_tokens, acct.output_tokens, + acct.cached_input_tokens, acct.cache_creation_input_tokens, + acct.reasoning_output_tokens + FROM ( + SELECT DISTINCT ON (h.user_id, h.source, h.model, h.hour_start) + h.user_id, h.source, h.model, h.hour_start, + h.total_tokens::bigint AS total_tokens, + h.input_tokens::bigint AS input_tokens, + h.output_tokens::bigint AS output_tokens, + h.cached_input_tokens::bigint AS cached_input_tokens, + h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, + h.reasoning_output_tokens::bigint AS reasoning_output_tokens + FROM tokentracker_hourly h CROSS JOIN cfg + JOIN acct_own o + ON o.user_id = h.user_id + AND o.hour_start = h.hour_start + AND o.source = h.source + AND o.owner_device_id IS NULL + WHERE h.hour_start >= p_from AND h.hour_start < p_to + AND h.source = ANY(cfg.account_sources) + ORDER BY h.user_id, h.source, h.model, h.hour_start, h.total_tokens DESC, h.updated_at DESC + ) acct +$func$; diff --git a/scripts/ops/account-usage-grouped-rpc.sql b/scripts/ops/account-usage-grouped-rpc.sql index 97e07347a..0299ba9f8 100644 --- a/scripts/ops/account-usage-grouped-rpc.sql +++ b/scripts/ops/account-usage-grouped-rpc.sql @@ -25,12 +25,33 @@ -- is immune to that. The cost: two genuinely distinct machines running the -- SAME model in the SAME half-hour count once, not summed — rare, and far -- better than the systemic ~2x that fold-then-SUM produced. --- * ACCOUNT-LEVEL sources (cursor) come from a per-ACCOUNT cloud API, NOT --- machine logs. Every device that syncs them stores an IDENTICAL copy, so --- SUMming across devices multiplies one account's usage by its device --- count (the v0.42.0 bug: a 2-machine user's Cursor total was double). For --- these, pick ONE canonical row per (hour, source, model) across ALL the --- user's devices — dedup, do not add. +-- * ACCOUNT-LEVEL sources (cursor, trae-cn) come from a per-ACCOUNT cloud +-- API, NOT machine logs, so SUMming across devices multiplies one +-- account's usage by its device count (the v0.42.0 double-count bug). +-- Dedup, do not add. HOW to dedup depends on whether the API is +-- append-only per window: +-- - cursor rows are identical across devices, and hours with NO +-- watermark coverage fall back to the legacy whole-row +-- per-(hour, model) MAX pick below. +-- - trae-cn is a CORRECTABLE snapshot: totals can be revised down, a +-- session can move to another model or another half-hour. Two +-- devices then hold different snapshot VERSIONS of the same hour, +-- and a per-(hour, model) MAX stitches incompatible versions +-- together (a model migration keeps BOTH the old device's stale +-- model row and the fresh device's new row -> 2x). Every successful +-- account-source sync therefore appends a WATERMARK record +-- (kind: account_sync_watermark in the CLI queue; upserted by the +-- ingest edge into tokentracker_account_sync_watermarks) asserting +-- the closed window that device verified against the API. For an +-- hour covered by ANY watermark, the freshest covering watermark's +-- device OWNS the whole hour: only its rows count (all models; a +-- model it lacks is 0) and every other device's row for that hour +-- is displaced. A brand-new device with no prior cursor state still +-- displaces stale tuples, because ownership is asserted per hour +-- RANGE, not per tuple. Mirrored by src/lib/account-usage-dedup.js +-- (executable spec; semantics pinned by test/account-usage-dedup.test.js). +-- DEPLOYMENT: this function reads tokentracker_account_sync_watermarks — +-- apply the account-sync-watermarks migration BEFORE this updated RPC. -- The account-level source list MUST stay in sync with ACCOUNT_LEVEL_SOURCES in -- src/lib/source-metadata.js (parity asserted by test/account-source-parity.test.js). -- @@ -92,6 +113,29 @@ AS $func$ cfg AS ( SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources ), + -- Per (account source, hour): the canonical owning device = the freshest + -- watermark window covering that hour (NULL when no watermark covers it, + -- i.e. cursor and any watermark-less history). Deterministic tiebreak: + -- window_end DESC, updated_at DESC, device_id. + acct_own AS ( + SELECT ah.hour_start, ah.source, + (SELECT w.device_id + FROM tokentracker_account_sync_watermarks w + WHERE w.user_id = p_user_id + AND w.source = ah.source + AND ah.hour_start >= w.window_start + AND ah.hour_start < w.window_end + ORDER BY w.window_end DESC, w.updated_at DESC, w.device_id + LIMIT 1) AS owner_device_id + FROM ( + SELECT DISTINCT h.hour_start, h.source + FROM tokentracker_hourly h CROSS JOIN cfg + WHERE h.user_id = p_user_id + AND h.hour_start >= p_from + AND h.hour_start < p_to + AND h.source = ANY(cfg.account_sources) + ) ah + ), -- Stage 1: canonicalize to the raw hour grain. hourly AS ( -- Machine-level: ONE canonical whole row per (hour, source, model) across @@ -161,13 +205,40 @@ AS $func$ UNION ALL - -- Account-level: ONE canonical whole row per (hour, source, model) across - -- ALL devices (NOT active-filtered — the data is device-independent and an - -- active-only filter would drop it if last synced by a since-revoked one). - SELECT acct.hour_start, acct.source, acct.model, - acct.total_tokens, acct.input_tokens, acct.output_tokens, - acct.cached_input_tokens, acct.cache_creation_input_tokens, - acct.reasoning_output_tokens, acct.conversations + -- Account-level, WATERMARK-COVERED hours: the owner device's rows count + -- exclusively (every model of that one snapshot; a model the owner lacks + -- is 0). NOT active-device-filtered: ownership follows verified + -- information, and an active-only filter would drop an hour whose + -- freshest covering sync came from a since-revoked device. An owner with + -- NO row at a covered hour legitimately yields nothing here — that is + -- the displacement of another device's stale tuple. + SELECT h.hour_start, h.source, h.model, + h.total_tokens::bigint AS total_tokens, + h.input_tokens::bigint AS input_tokens, + h.output_tokens::bigint AS output_tokens, + h.cached_input_tokens::bigint AS cached_input_tokens, + h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, + h.reasoning_output_tokens::bigint AS reasoning_output_tokens, + h.conversations::bigint AS conversations + FROM tokentracker_hourly h + JOIN acct_own o + ON o.hour_start = h.hour_start + AND o.source = h.source + AND o.owner_device_id = h.device_id + WHERE h.user_id = p_user_id + AND h.hour_start >= p_from + AND h.hour_start < p_to + + UNION ALL + + -- Account-level, UNCOVERED hours (no watermark asserts them: cursor, or + -- trae-cn history older than every device's verified window): the legacy + -- whole-row MAX pick per (hour, source, model) — one real, internally + -- consistent row, never a per-column synth. + SELECT d.hour_start, d.source, d.model, + d.total_tokens, d.input_tokens, d.output_tokens, + d.cached_input_tokens, d.cache_creation_input_tokens, + d.reasoning_output_tokens, d.conversations FROM ( SELECT DISTINCT ON (h.hour_start, h.source, h.model) h.hour_start, h.source, h.model, @@ -178,13 +249,16 @@ AS $func$ h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, h.reasoning_output_tokens::bigint AS reasoning_output_tokens, h.conversations::bigint AS conversations - FROM tokentracker_hourly h CROSS JOIN cfg + FROM tokentracker_hourly h + JOIN acct_own o + ON o.hour_start = h.hour_start + AND o.source = h.source + AND o.owner_device_id IS NULL WHERE h.user_id = p_user_id AND h.hour_start >= p_from AND h.hour_start < p_to - AND h.source = ANY(cfg.account_sources) ORDER BY h.hour_start, h.source, h.model, h.total_tokens DESC, h.updated_at DESC - ) acct + ) d ), -- Stage 2: bucket the canonical hour rows to tz-local trunc, then aggregate. loc AS ( diff --git a/scripts/ops/ui-hardcode-baseline.json b/scripts/ops/ui-hardcode-baseline.json index 35b1d988a..d3ca0a278 100644 --- a/scripts/ops/ui-hardcode-baseline.json +++ b/scripts/ops/ui-hardcode-baseline.json @@ -1,12 +1,12 @@ { - "generatedAt": "2026-07-27T14:57:17.435Z", + "generatedAt": "2026-08-16T19:09:24.612Z", "root": "dashboard/src", "rules": { "colors": "hex/rgb(a) literals", "rawText": "JSX text nodes containing letters or digits" }, "totals": { - "colors": 377, + "colors": 384, "rawText": 160 }, "files": { @@ -370,7 +370,7 @@ ] }, "dashboard/src/ui/dashboard/components/ProviderIcon.jsx": { - "colors": 2, + "colors": 9, "rawText": 0, "rawTextTokens": [] }, diff --git a/src/commands/status.js b/src/commands/status.js index 69b66d2fa..0ea96b29d 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -647,8 +647,13 @@ async function cmdStatus(argv = []) { // official API, so status surfaces the opt-in flag, the resolved storage // path, and whether the auth blob decrypts — the three inputs a "why is my // TRAE CN data missing" diagnosis needs. + // A resolvable default path does NOT mean the app is installed - the macOS + // resolver always derives one. Match the sync path semantics exactly: + // installed iff the storage file actually exists on disk. const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home }); - const traeCnInstalled = Boolean(traeCnStoragePath); + const traeCnInstalled = Boolean( + traeCnStoragePath && fssync.existsSync(traeCnStoragePath), + ); let traeCnAuthState = "not-signed-in"; if (traeCnInstalled) { try { @@ -657,8 +662,10 @@ async function cmdStatus(argv = []) { extractTraeCnToken(traeCnAuth); traeCnAuthState = "readable"; } - } catch (_error) { - traeCnAuthState = "malformed"; + } catch (error) { + // Distinguish a real IO failure (unreadable) from present-but-bad + // data (malformed); neither ever carries storage contents. + traeCnAuthState = error?.code === "TRAE_CN_STORAGE_UNREADABLE" ? "unreadable" : "malformed"; } } const traeCnUsageOptIn = isTraeCnUsageEnabled(process.env); diff --git a/src/commands/sync.js b/src/commands/sync.js index 416dd92bf..713eb2c94 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -1717,8 +1717,12 @@ async function cmdSync(argv, context = {}) { home, }); const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : []; - if (traeCnSessions.length > 0) { - if (progress?.enabled) { + // Parse even an empty payload: the parser is a local no-op but + // still appends the account-sync watermark asserting this window + // was verified, which is what displaces stale cross-device tuples + // for the covered hours (fresh-device invariant). + { + if (progress?.enabled && traeCnSessions.length > 0) { progress.start( `Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber( traeCnSessions.length, @@ -1731,6 +1735,7 @@ async function cmdSync(argv, context = {}) { queuePath, onProgress: makeProviderProgress("TRAE Work CN"), windowStartMs: startTime * 1000, + windowEndMs: endTime * 1000, }); // Row-level isolation means a malformed row is skipped, not fatal; // surface the skip count even on auto runs (warnProviderParseFailure @@ -3529,7 +3534,11 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa for (let batch = 0; batch < maxBatches; batch++) { if (offset >= queueSize) break; const result = await readQueueBatch(queuePath, offset, limit); - if (result.buckets.length === 0) break; + // A watermark-only tail (e.g. a sync whose reconciled buckets were all + // unchanged, or a verified-empty window) is still a meaningful upload: + // it advances the account-sync coverage that displaces stale + // cross-device tuples cloud-side. + if (result.buckets.length === 0 && result.watermarks.length === 0) break; const root = baseUrl.replace(/\/$/, ""); const anonKey = process.env.TOKENTRACKER_INSFORGE_ANON_KEY || ""; @@ -3542,7 +3551,10 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa const res = await fetch(`${root}/functions/${INGEST_SLUG}`, { method: "POST", headers, - body: JSON.stringify({ hourly: result.buckets }), + body: JSON.stringify({ + hourly: result.buckets, + ...(result.watermarks.length > 0 ? { account_watermarks: result.watermarks } : {}), + }), }); const rawText = await res.text().catch(() => ""); @@ -3579,6 +3591,12 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const bucketMap = new Map(); + // Account-sync watermarks (kind: "account_sync_watermark") are queue + // control records, not usage rows: collect them separately so they ride + // the same upload (after the bucket rows in file order) without entering + // the bucket stream. Last record per source wins - records are appended + // chronologically, one per verified sync window. + const watermarkMap = new Map(); let offset = startOffset; let linesRead = 0; for await (const line of rl) { @@ -3591,6 +3609,16 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { } catch (_e) { continue; } + if (bucket?.kind === "account_sync_watermark") { + if (typeof bucket.source === "string" && bucket.source.trim()) { + watermarkMap.set(bucket.source.trim().toLowerCase(), { + source: bucket.source.trim().toLowerCase(), + window_start: bucket.window_start, + window_end: bucket.window_end, + }); + } + continue; + } const hourStart = typeof bucket?.hour_start === "string" ? bucket.hour_start : null; if (!hourStart) continue; const source = (typeof bucket?.source === "string" ? bucket.source.trim().toLowerCase() : "") || "codex"; @@ -3610,7 +3638,11 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { rl.close(); stream.close?.(); - return { buckets: Array.from(bucketMap.values()), nextOffset: offset }; + return { + buckets: Array.from(bucketMap.values()), + watermarks: Array.from(watermarkMap.values()), + nextOffset: offset, + }; } function normalizeGrokRepairSource(value) { diff --git a/src/lib/account-usage-dedup.js b/src/lib/account-usage-dedup.js new file mode 100644 index 000000000..aecf32d47 --- /dev/null +++ b/src/lib/account-usage-dedup.js @@ -0,0 +1,174 @@ +"use strict"; + +/** + * Account-level cross-device dedup - JS reference implementation. + * + * Account-level sources (ACCOUNT_LEVEL_SOURCES in src/lib/source-metadata.js) + * come from a per-ACCOUNT cloud API, so every device that syncs them stores + * its own copy of the SAME account data. Worse, TRAE CN's API returns + * correctable snapshots, not append-only events: a session can be re-reported + * with a smaller total, a different model, or a shifted time bucket. Two + * devices therefore hold different snapshot VERSIONS of one account hour, and + * the old cloud aggregation - per (hour, source, model) pick MAX(total_tokens) + * - stitches incompatible versions together: + * + * downward correction: old device H/A=100, fresh device H/A=60 -> MAX=100 + * model migration: old device H/A=100, fresh device H/B=100 -> 200 + * bucket migration: old device 10:00/A=100, fresh 10:30/A=100 -> 200 + * + * The fix: every successful account-source sync appends a WATERMARK record + * (kind: "account_sync_watermark") asserting the closed window that device + * just verified against the API. Aggregation then picks, per hour, ONE + * canonical owning device - the freshest watermark whose window covers that + * hour - and counts ONLY that device's rows for the whole hour (every model; + * a model the owner lacks counts as 0). A fresh device that has never seen + * the stale tuples still displaces them, because ownership is asserted per + * hour RANGE, not per tuple. Hours covered by no watermark (sources or + * devices predating watermarks, e.g. cursor) keep the legacy whole-row MAX + * dedup, so nothing regresses for watermark-less sources. + * + * This module is the executable specification of that semantics; the + * deployed SQL (scripts/ops/account-usage-grouped-rpc.sql account branch and + * migrations/* leaderboard_hourly_dedup_v2) implements the same algorithm and + * MUST stay in sync - test/account-usage-dedup.test.js pins both. + */ + +const WATERMARK_KIND = "account_sync_watermark"; + +function parseMs(value) { + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +// Mirrors SQL: ORDER BY w.window_end DESC, w.updated_at DESC, w.device_id LIMIT 1 +function pickOwnerWatermark(watermarks, hourStartMs) { + let best = null; + for (const wm of watermarks) { + const startMs = parseMs(wm.window_start); + const endMs = parseMs(wm.window_end); + if (startMs === null || endMs === null || endMs <= startMs) continue; + if (hourStartMs < startMs || hourStartMs >= endMs) continue; + if (!best) { + best = wm; + continue; + } + const bestEnd = parseMs(best.window_end) || 0; + const wmEnd = endMs; + if (wmEnd !== bestEnd) { + if (wmEnd > bestEnd) best = wm; + continue; + } + const bestUpdated = parseMs(best.updated_at ?? best.window_end) || 0; + const wmUpdated = parseMs(wm.updated_at ?? wm.window_end) || 0; + if (wmUpdated !== bestUpdated) { + if (wmUpdated > bestUpdated) best = wm; + continue; + } + if (String(wm.device_id) < String(best.device_id)) best = wm; + } + return best; +} + +// Mirrors the legacy SQL DISTINCT ON (hour, source, model) +// ORDER BY total_tokens DESC, updated_at DESC (device_id asc as the final +// deterministic tiebreak). +function pickLegacyRow(rows) { + let best = null; + for (const row of rows) { + if (!best) { + best = row; + continue; + } + const rowTotal = Number(row.total_tokens) || 0; + const bestTotal = Number(best.total_tokens) || 0; + if (rowTotal !== bestTotal) { + if (rowTotal > bestTotal) best = row; + continue; + } + const rowUpdated = parseMs(row.updated_at ?? "") ?? -Infinity; + const bestUpdated = parseMs(best.updated_at ?? "") ?? -Infinity; + if (rowUpdated !== bestUpdated) { + if (rowUpdated > bestUpdated) best = row; + continue; + } + if (String(row.device_id) < String(best.device_id)) best = row; + } + return best; +} + +/** + * Dedupe account-level hourly rows across devices. + * + * @param {Array<{device_id: string, hour_start: string, source: string, + * model: string, updated_at?: string, total_tokens: number}>} rows - + * every device's tokentracker_hourly-shaped rows for account-level sources. + * @param {Array<{device_id: string, source: string, window_start: string, + * window_end: string, updated_at?: string}>} watermarks - per-device + * verified sync windows (tokentracker_account_sync_watermarks shape). + * @returns {Array} canonical rows - one consistent snapshot per hour. + */ +function dedupeAccountLevelRows(rows, watermarks = []) { + if (!Array.isArray(rows)) return []; + const watermarkList = Array.isArray(watermarks) ? watermarks : []; + const watermarksBySource = new Map(); + for (const wm of watermarkList) { + if (!wm || typeof wm.source !== "string") continue; + const list = watermarksBySource.get(wm.source) || []; + list.push(wm); + watermarksBySource.set(wm.source, list); + } + + // Group rows by (source, hour_start); keep original row objects untouched. + const byHour = new Map(); + for (const row of rows) { + if (!row || typeof row.hour_start !== "string" || !row.hour_start) continue; + const key = `${row.source}||${row.hour_start}`; + const list = byHour.get(key) || []; + list.push(row); + byHour.set(key, list); + } + + const out = []; + for (const [key, hourRows] of byHour) { + const source = key.split("||")[0]; + const hourStartMs = parseMs(hourRows[0].hour_start); + if (hourStartMs === null) continue; + const candidates = watermarksBySource.get(String(source).toLowerCase()) || []; + const owner = pickOwnerWatermark(candidates, hourStartMs); + if (owner) { + // Watermark-covered hour: the owner's rows count exclusively; an owner + // with no rows at this hour means the account truly has 0 here and every + // other device's stale tuple is displaced. + for (const row of hourRows) { + if (String(row.device_id) === String(owner.device_id)) out.push(row); + } + continue; + } + // Uncovered hour (no watermark asserts it): legacy whole-row MAX dedup. + const byModel = new Map(); + for (const row of hourRows) { + const modelKey = row.model || "unknown"; + const list = byModel.get(modelKey) || []; + list.push(row); + byModel.set(modelKey, list); + } + for (const list of byModel.values()) { + out.push(pickLegacyRow(list)); + } + } + return out; +} + +/** Total tokens of a deduped row set - convenience for tests/diagnostics. */ +function sumTotalTokens(rows) { + return (Array.isArray(rows) ? rows : []).reduce( + (acc, row) => acc + (Number(row?.total_tokens) || 0), + 0, + ); +} + +module.exports = { + WATERMARK_KIND, + dedupeAccountLevelRows, + sumTotalTokens, +}; diff --git a/src/lib/local-api.js b/src/lib/local-api.js index be01075b8..8cb532351 100644 --- a/src/lib/local-api.js +++ b/src/lib/local-api.js @@ -338,6 +338,9 @@ function readQueueData(queuePath) { if (!line.trim()) continue; try { const row = JSON.parse(line); + // Account-sync watermarks are queue control records (cloud-side + // cross-device dedup), not usage rows - never surface them locally. + if (row?.kind === "account_sync_watermark") continue; // Deduplicate: each sync appends cumulative totals per bucket, so for // each (source, model, hour_start) keep only the latest (last) entry. const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`; diff --git a/src/lib/rollout.js b/src/lib/rollout.js index ab7646a72..ea7098dab 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16395,7 +16395,13 @@ const TRAE_CN_UNKNOWN_MODEL = "trae-cn-unknown"; function normalizeTraeCnState(raw) { if (raw === undefined || raw === null) { - return { version: TRAE_CN_STATE_VERSION, sessions: {}, prunedBeforeMs: 0, updatedAt: null }; + return { + version: TRAE_CN_STATE_VERSION, + sessions: {}, + prunedBeforeMs: 0, + updatedAt: null, + lastWatermark: null, + }; } if (typeof raw !== "object" || Array.isArray(raw)) { throw new Error("Trae CN cursor state is malformed."); @@ -16412,9 +16418,22 @@ function normalizeTraeCnState(raw) { prunedBeforeMs: Number.isFinite(raw.prunedBeforeMs) && raw.prunedBeforeMs > 0 ? raw.prunedBeforeMs : 0, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, + lastWatermark: normalizeTraeCnLastWatermark(raw.lastWatermark), }; } +// Advisory watermark echo of the last appended account_sync_watermark. An +// older CLI build silently drops it on state rewrite (it re-normalizes with +// its own field list), which only costs one redundant watermark append - +// cloud upserts are idempotent - so no state-version bump is needed. +function normalizeTraeCnLastWatermark(raw) { + if (!raw || typeof raw !== "object") return null; + const start = typeof raw.window_start === "string" ? Date.parse(raw.window_start) : NaN; + const end = typeof raw.window_end === "string" ? Date.parse(raw.window_end) : NaN; + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null; + return { window_start: raw.window_start, window_end: raw.window_end }; +} + function isValidTraeCnTotals(totals) { if (!totals || typeof totals !== "object") return false; for (const key of [ @@ -16594,12 +16613,60 @@ function assertTraeCnBucketCovers(bucketTotals, previousTotals) { } } +// Account-sync watermark: a queue record (kind: "account_sync_watermark") +// asserting that this device has just verified the account snapshot for the +// closed window [window_start, window_end] against the TRAE CN API. The +// cloud aggregation (account-usage-grouped RPC / leaderboard_hourly_dedup_v2, +// mirrored by src/lib/account-usage-dedup.js) uses it to pick, per hour, ONE +// canonical owning device - the freshest watermark whose window covers that +// hour - so cross-device downward/model/bucket corrections displace stale +// tuples from devices holding older snapshot versions, and a fresh device +// with no prior cursor state still displaces tuples it has never seen. +// Appended AFTER the bucket rows of the same sync so the queue ordering +// guarantees a device's rows always land before the watermark covering them. +// Bucket-row readers skip this record via its kind field (it carries no +// hour_start and is not a usage row). +function watermarkWindow(windowStartMs, windowEndMs) { + return { + window_start: new Date(windowStartMs).toISOString(), + window_end: new Date(windowEndMs).toISOString(), + }; +} + +function isSameTraeCnWatermark(lastWatermark, windowStartMs, windowEndMs) { + if (!lastWatermark) return false; + if (!Number.isFinite(windowStartMs) || !Number.isFinite(windowEndMs)) return false; + return ( + Date.parse(lastWatermark.window_start) === windowStartMs && + Date.parse(lastWatermark.window_end) === windowEndMs + ); +} + +async function appendTraeCnSyncWatermark({ queuePath, windowStartMs, windowEndMs }) { + if ( + !Number.isFinite(windowStartMs) || + !Number.isFinite(windowEndMs) || + windowEndMs <= windowStartMs + ) { + return false; + } + const record = JSON.stringify({ + kind: "account_sync_watermark", + source: TRAE_CN_SOURCE, + window_start: new Date(windowStartMs).toISOString(), + window_end: new Date(windowEndMs).toISOString(), + }); + await fs.appendFile(queuePath, record + "\n", "utf8"); + return true; +} + async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgress, windowStartMs, + windowEndMs, } = {}) { if (!Array.isArray(sessions)) { throw new Error("Trae CN sessions must be an array."); @@ -16617,7 +16684,24 @@ async function parseTraeCnApiIncremental({ const traeCnState = structuredClone(normalizeTraeCnState(cursors?.traeCn)); if (sessions.length === 0) { - // Empty payload is a successful no-op: it never erases prior contributions. + // Empty payload is a successful no-op for usage state: it never erases + // prior contributions. The verified-empty window is still asserted + // cloud-side via the watermark so stale cross-device tuples inside the + // window are displaced (fresh-device invariant D) without rewriting any + // usage rows locally. Re-asserting the SAME window as the last sync is + // redundant (the cloud upsert is idempotent), so skip it to keep a + // fixed-now no-change sync byte-identical; a window that ADVANCED (the + // production rolling window moves with now) is always re-asserted. + if (!isSameTraeCnWatermark(traeCnState.lastWatermark, windowStartMs, windowEndMs)) { + const appended = await appendTraeCnSyncWatermark({ queuePath, windowStartMs, windowEndMs }); + if (appended) { + traeCnState.lastWatermark = watermarkWindow(windowStartMs, windowEndMs); + traeCnState.updatedAt = new Date().toISOString(); + // Sessions/prunedBeforeMs pass through the clone untouched, so this + // commits ONLY the watermark echo - prior contributions survive. + cursors.traeCn = traeCnState; + } + } return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; } @@ -16738,6 +16822,17 @@ async function parseTraeCnApiIncremental({ } const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }); + // Assert the verified window only after the reconciled bucket rows are + // durably queued; a failure here aborts before cursor commit, so the next + // sync replays the (idempotent) reconciliation and re-appends both. As in + // the empty-payload branch, re-asserting the SAME window as the last sync + // is redundant - skip it so a fixed-now no-change sync stays byte-identical. + if (!isSameTraeCnWatermark(traeCnState.lastWatermark, windowStartMs, windowEndMs)) { + const watermarkAppended = await appendTraeCnSyncWatermark({ queuePath, windowStartMs, windowEndMs }); + if (watermarkAppended) { + traeCnState.lastWatermark = watermarkWindow(windowStartMs, windowEndMs); + } + } const updatedAt = new Date().toISOString(); hourlyState.updatedAt = updatedAt; traeCnState.updatedAt = updatedAt; diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js index 7379b8e19..f9bf1a483 100644 --- a/src/lib/trae-cn-config.js +++ b/src/lib/trae-cn-config.js @@ -212,8 +212,10 @@ function parseTraeCnAuthValue(value) { /** * Read + decrypt the auth object from storage.json. Returns null when the - * file or the auth key is absent (not configured). Throws a credential-safe - * error when present data is malformed. + * file or the auth key is absent (not signed in). Throws a credential-safe + * error when present data is malformed, and a distinct unreadable error for + * real IO failures (permission denied, I/O error) - an unreadable storage + * file must not be reported as "not signed in". */ function readTraeCnAuthFromStorage({ env, home, platform, fsModule = fs } = {}) { const storagePath = resolveTraeCnStoragePath({ env, home, platform }); @@ -221,8 +223,18 @@ function readTraeCnAuthFromStorage({ env, home, platform, fsModule = fs } = {}) let raw; try { raw = fsModule.readFileSync(storagePath, "utf8"); - } catch (_error) { - return null; + } catch (error) { + // A missing file (or missing path component) simply means the app is not + // installed / not signed in. Any other failure - EACCES, EISDIR, a real + // I/O error - fails closed with a generic, credential-safe error. Only a + // stable machine-readable code is attached, never the OS detail, so + // storage contents cannot leak through logs or error messages. + if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { + return null; + } + const unreadable = new Error("Trae CN storage.json could not be read."); + unreadable.code = "TRAE_CN_STORAGE_UNREADABLE"; + throw unreadable; } let parsed; try { @@ -299,7 +311,15 @@ async function fetchTraeCnUsagePage({ try { let response; try { - // The locally stored TRAE JWT goes only to the fixed official HTTPS endpoint; never logged or persisted. + // Security note (CodeQL "File data -> outbound network request"): + // the credential read from TRAE's local storage.json is sent ONLY to + // TRAE_CN_USAGE_URL, a compile-time constant HTTPS endpoint of the + // official API - callers cannot redirect the destination (the url + // parameter is ignored; pinned by the "sends the exact request" test). + // The whole flow is behind the explicit TOKENTRACKER_TRAE_CN_USAGE + // opt-in, the JWT is never logged, never written to the queue/cursor + // state, and never uploaded to the TokenTracker cloud (queue rows carry + // token counters only; transport exceptions are sanitized). response = await fetchImpl(TRAE_CN_USAGE_URL, { method: "POST", headers: { @@ -347,8 +367,25 @@ async function fetchTraeCnUsagePage({ throw new Error("Trae CN usage API response is missing the session list."); } const sessions = data.user_usage_group_by_sessions; + // The declared total gates pagination termination (sessions.length >= + // total stops the walk), so a malformed value must fail closed as a + // schema error - never be coerced into a legal-looking stop condition. + // A negative, fractional, non-finite, or non-number total (the API + // speaks JSON numbers; a quoted digit string is a schema anomaly) would + // otherwise silently truncate the snapshot after page 1 and still look + // authoritative. const rawTotal = data?.total; - const total = rawTotal === undefined || rawTotal === null ? null : Number(rawTotal); + let total = null; + if (rawTotal !== undefined && rawTotal !== null) { + if ( + typeof rawTotal !== "number" || + !Number.isSafeInteger(rawTotal) || + rawTotal < 0 + ) { + throw new Error("Trae CN usage API returned an invalid total."); + } + total = rawTotal; + } return { sessions, total, page_num, page_size }; } finally { clearTimeout(timer); diff --git a/test/account-source-parity.test.js b/test/account-source-parity.test.js index b88eb031b..53ad40611 100644 --- a/test/account-source-parity.test.js +++ b/test/account-source-parity.test.js @@ -12,10 +12,11 @@ // that v0.44 fixed. This test fails loudly on any mismatch. // // NOT covered here: scripts/ops/leaderboard-usage-grouped-rpc.sql (SUPERSEDED -// rollback reference — do not edit or verify) and the live -// leaderboard_hourly_dedup_v2 function's account_sources array, which lives -// only in the deployed database (maintainer-side; a PR adding an account-level -// source must call it out in its deployment notes). +// rollback reference — do not edit or verify). Since the account-sync +// watermark work, leaderboard_hourly_dedup_v2's account_sources array IS +// versioned in migrations/20260817120000_account-sync-watermarks.sql, so it +// is verified below; applying that migration to production stays a +// maintainer-side deployment step. const fs = require("node:fs"); const path = require("node:path"); @@ -65,6 +66,9 @@ test("account-level source list is identical across source-metadata, the account "account-usage-grouped-rpc.sql": extractSqlAccountSources( readFile("scripts/ops/account-usage-grouped-rpc.sql"), ), + "20260817120000_account-sync-watermarks.sql (leaderboard_hourly_dedup_v2)": extractSqlAccountSources( + readFile("migrations/20260817120000_account-sync-watermarks.sql"), + ), "tokentracker-leaderboard-profile.ts": extractJsSet( readFile("dashboard/edge-patches/tokentracker-leaderboard-profile.ts"), "ACCOUNT_LEVEL_SOURCES", diff --git a/test/account-usage-dedup.test.js b/test/account-usage-dedup.test.js new file mode 100644 index 000000000..dd3b407c4 --- /dev/null +++ b/test/account-usage-dedup.test.js @@ -0,0 +1,399 @@ +/** + * Account-level cross-device dedup regression tests (PR #474 review). + * + * TRAE CN's usage API returns CORRECTABLE snapshots, not append-only events: + * a session's totals can be revised downward, moved to another model, or + * shifted to another half-hour. Two devices of one account therefore hold + * different snapshot VERSIONS of the same hour, and the legacy cloud + * aggregation - per (hour, source, model) pick MAX(total_tokens) across + * devices - stitches incompatible versions together. These tests pin the + * FIXED semantics implemented by src/lib/account-usage-dedup.js (executable + * spec) and mirrored by: + * - scripts/ops/account-usage-grouped-rpc.sql (account_usage_grouped) + * - migrations/20260817120000_account-sync-watermarks.sql + * (leaderboard_hourly_dedup_v2 + watermark table) + * + * Scenarios A-E are the exact merge-blocker cases from the PR review; they + * exercise the real aggregation logic, not the ACCOUNT_LEVEL_SOURCES config + * drift checks in account-source-parity.test.js. + */ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { + dedupeAccountLevelRows, + sumTotalTokens, +} = require("../src/lib/account-usage-dedup"); + +const ROOT = path.join(__dirname, ".."); + +// Device ids / hours used across the scenarios. +const OLD = "11111111-1111-1111-1111-111111111111"; +const FRESH = "22222222-2222-2222-2222-222222222222"; +const H10 = "2027-01-10T10:00:00.000Z"; +const H1030 = "2027-01-10T10:30:00.000Z"; +const H11 = "2027-01-10T11:00:00.000Z"; + +function row(deviceId, hourStart, model, totalTokens) { + return { + device_id: deviceId, + hour_start: hourStart, + source: "trae-cn", + model, + total_tokens: totalTokens, + input_tokens: totalTokens, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + conversations: 1, + updated_at: "2027-01-15T00:00:00.000Z", + }; +} + +function watermark(deviceId, start, end, updatedAt) { + return { + device_id: deviceId, + source: "trae-cn", + window_start: start, + window_end: end, + updated_at: updatedAt || "2027-01-15T00:00:00.000Z", + }; +} + +// A watermark window covering the whole test day (mirrors the CLI's rolling +// 30-day sync window, which always covers every corrected hour). +const DAY_WINDOW = { start: "2027-01-09T00:00:00.000Z", end: "2027-01-12T00:00:00.000Z" }; + +function totalByModel(rows) { + const byModel = new Map(); + for (const r of rows) { + byModel.set(r.model, (byModel.get(r.model) || 0) + r.total_tokens); + } + return byModel; +} + +// --------------------------------------------------------------------------- +// A. Cross-device downward correction +// --------------------------------------------------------------------------- +test("A. downward correction: old device 100, fresh device 60 -> account total is 60", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 60), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), // older sync + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), // freshest verified window + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "must pick the corrected 60, not MAX 100"); +}); + +// --------------------------------------------------------------------------- +// B. Cross-device model migration +// --------------------------------------------------------------------------- +test("B. model migration: old device A=100, fresh device B=100 -> A displaced, total 100", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-b", 100), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + const byModel = totalByModel(deduped); + assert.equal(byModel.get("model-a") || 0, 0, "stale model-a tuple must be displaced, not kept"); + assert.equal(byModel.get("model-b"), 100, "fresh model-b row counts"); + assert.equal(sumTotalTokens(deduped), 100, "must be 100, not the stitched 200"); +}); + +// --------------------------------------------------------------------------- +// C. Cross-device bucket (hour) migration +// --------------------------------------------------------------------------- +test("C. bucket migration: old device 10:00=100, fresh device 10:30=100 -> only new bucket", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H1030, "model-a", 100), + ]; + // The fresh device verified a window covering BOTH hours (the rolling + // window does), so its lack of a 10:00 row means the account truly has 0 + // at 10:00 now. + const watermarks = [watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end)]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + const byHour = new Map(deduped.map((r) => [r.hour_start, r.total_tokens])); + assert.equal(byHour.get(H10), undefined, "old 10:00 tuple must be displaced"); + assert.equal(byHour.get(H1030), 100, "new 10:30 tuple survives"); + assert.equal(sumTotalTokens(deduped), 100, "total must be 100, not 200"); +}); + +// --------------------------------------------------------------------------- +// D. Fresh device first sync (no prior cursor state) +// --------------------------------------------------------------------------- +test("D. fresh device with no old cursors displaces stale tuples it has never seen", () => { + // Old device synced long ago; its watermark covers only up to its own + // (stale) sync moment. The brand-new device has no cursor state at all - + // it only knows what the API says NOW: the session is at 10:30, 60 tokens. + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H1030, "model-a", 60), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "fresh snapshot wins wholesale"); + const byHour = new Map(deduped.map((r) => [r.hour_start, r.total_tokens])); + assert.equal(byHour.get(H10), undefined); + assert.equal(byHour.get(H1030), 60); +}); + +test("D2. fresh device verified-empty window zeros out stale tuples completely", () => { + // The strongest displacement case: the API now reports NOTHING for the + // window that the old device's stale tuples live in. The fresh device + // contributes no rows at all - only its watermark. + const rows = [row(OLD, H10, "model-a", 100), row(OLD, H11, "model-b", 50)]; + const watermarks = [watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end)]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 0, "stale tuples must not survive a verified-empty window"); + assert.equal(deduped.length, 0); +}); + +// --------------------------------------------------------------------------- +// E. Two devices hold the identical snapshot +// --------------------------------------------------------------------------- +test("E. identical snapshots on two devices count exactly once", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 100), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T01:00:00.000Z"), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T02:00:00.000Z"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 100, "same account snapshot must not double"); + assert.equal(deduped.length, 1, "exactly one canonical row"); +}); + +// --------------------------------------------------------------------------- +// Additional ownership semantics +// --------------------------------------------------------------------------- +test("upward correction: fresh larger total wins (owner is per-hour, not per-tuple MAX)", () => { + const rows = [ + row(OLD, H10, "model-a", 60), + row(FRESH, H10, "model-a", 100), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 100); +}); + +test("an old device that never comes back online cannot keep owning newer hours", () => { + // The stale device's watermark window ENDS before the fresher sync; hours + // inside the fresher window belong to the fresh device even though the old + // device's numbers are larger (revoked / inactive / retired device). + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 40), + ]; + const watermarks = [ + watermark(OLD, "2027-01-09T00:00:00.000Z", "2027-01-10T09:59:00.000Z"), + watermark(FRESH, "2027-01-09T00:00:00.000Z", "2027-01-12T00:00:00.000Z"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 40, "freshest covering window owns the hour"); +}); + +test("hours covered by no watermark keep the legacy whole-row MAX dedup (cursor / watermark-less history)", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 120), + row(OLD, H10, "model-b", 30), + row(FRESH, H10, "model-b", 30), + ]; + const deduped = dedupeAccountLevelRows(rows, []); + assert.equal(sumTotalTokens(deduped), 150, "per-model MAX pick, identical rows count once"); + assert.equal(deduped.length, 2); +}); + +test("partial coverage: covered hours use ownership, uncovered hours stay legacy", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 60), + row(OLD, H1030, "model-a", 70), + row(FRESH, H1030, "model-a", 90), + ]; + // The watermark window covers ONLY 10:00; 10:30 falls back to legacy MAX. + const watermarks = [watermark(FRESH, "2027-01-10T10:00:00.000Z", "2027-01-10T10:30:00.000Z")]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + const byHour = new Map(deduped.map((r) => [r.hour_start, r.total_tokens])); + assert.equal(byHour.get(H10), 60, "covered hour: owner row wins even though smaller"); + assert.equal(byHour.get(H1030), 90, "uncovered hour: legacy MAX"); +}); + +test("owner selection tiebreak is deterministic: window_end, then updated_at, then device_id", () => { + const rows = [ + row("aaaa0000-0000-0000-0000-000000000000", H10, "model-a", 10), + row("bbbb0000-0000-0000-0000-000000000000", H10, "model-a", 20), + ]; + // Equal windows + equal updated_at -> smaller device_id wins (matches the + // SQL ORDER BY ... w.device_id LIMIT 1). + const watermarks = [ + watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T00:00:00.000Z"), + watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T00:00:00.000Z"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 10, "device_id aaa... owns the tie"); + + // Later updated_at on the same window_end wins over device_id order. + const watermarks2 = [ + watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T00:00:00.000Z"), + watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-16T00:00:00.000Z"), + ]; + const deduped2 = dedupeAccountLevelRows(rows, watermarks2); + assert.equal(sumTotalTokens(deduped2), 20, "later updated_at owns the tie"); + + // A strictly later window_end wins outright (aaaa verified through + // 01-13, bbbb only through 01-12 -> aaaa owns despite the device_id order). + const watermarks3 = [ + watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end), + watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, "2027-01-13T00:00:00.000Z"), + ]; + const deduped3 = dedupeAccountLevelRows(rows, watermarks3); + assert.equal(sumTotalTokens(deduped3), 10, "larger window_end owns"); +}); + +test("watermarks are scoped per source: a cursor watermark never owns trae-cn hours", () => { + const rows = [row(OLD, H10, "model-a", 100)]; + const watermarks = [ + { + device_id: FRESH, + source: "cursor", // different account source + window_start: DAY_WINDOW.start, + window_end: DAY_WINDOW.end, + }, + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + // No trae-cn watermark -> uncovered -> legacy MAX keeps the single row. + assert.equal(sumTotalTokens(deduped), 100); +}); + +test("degenerate watermarks (empty window / unparseable dates) are ignored", () => { + const rows = [row(OLD, H10, "model-a", 100)]; + const watermarks = [ + watermark(FRESH, DAY_WINDOW.end, DAY_WINDOW.start), // inverted + watermark(FRESH, "not-a-date", DAY_WINDOW.end), + watermark(FRESH, DAY_WINDOW.start, "not-a-date"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 100, "falls back to legacy dedup"); +}); + +test("sumTotalTokens tolerates junk input", () => { + assert.equal(sumTotalTokens(null), 0); + assert.equal(sumTotalTokens([null, { total_tokens: "x" }, { total_tokens: 5 }]), 5); +}); + +// --------------------------------------------------------------------------- +// SQL / deployment parity: the deployed aggregation must implement the SAME +// owner-selection semantics as the JS spec above. +// --------------------------------------------------------------------------- +function readRepoFile(rel) { + return fs.readFileSync(path.join(ROOT, rel), "utf8"); +} + +test("account_usage_grouped RPC implements watermark ownership with the same tiebreak as the JS spec", () => { + const sql = readRepoFile("scripts/ops/account-usage-grouped-rpc.sql"); + assert.match(sql, /tokentracker_account_sync_watermarks/, "RPC reads the watermark table"); + assert.match( + sql, + /ORDER BY w\.window_end DESC, w\.updated_at DESC, w\.device_id/, + "owner tiebreak must be window_end DESC, updated_at DESC, device_id - same as pickOwnerWatermark", + ); + assert.match( + sql, + /o\.owner_device_id = h\.device_id/, + "covered hours must count ONLY the owner device's rows", + ); + assert.match( + sql, + /o\.owner_device_id IS NULL/, + "uncovered hours must keep the legacy whole-row MAX branch", + ); + // The covered branch must NOT be active-device filtered (ownership follows + // verified information, not device liveness). + const coveredBranch = sql.split("WATERMARK-COVERED hours: the owner device's rows count")[1] + .split("UNION ALL")[0]; + assert.doesNotMatch( + coveredBranch, + /revoked_at IS NULL/, + "covered branch must not active-filter (a since-revoked owner still owns its verified hours)", + ); +}); + +test("leaderboard_hourly_dedup_v2 migration mirrors the RPC ownership semantics and includes trae-cn", () => { + const sql = readRepoFile("migrations/20260817120000_account-sync-watermarks.sql"); + assert.match(sql, /CREATE TABLE IF NOT EXISTS public\.tokentracker_account_sync_watermarks/); + assert.match( + sql, + /PRIMARY KEY \(user_id, device_id, source\)/, + "watermark identity is per (user, device, source) - last upsert per device wins", + ); + assert.match(sql, /CHECK \(window_end > window_start\)/); + assert.match(sql, /ENABLE ROW LEVEL SECURITY/); + // The leaderboard function must agree with account_usage_grouped. + assert.match( + sql, + /ORDER BY w\.window_end DESC, w\.updated_at DESC, w\.device_id/, + "leaderboard owner tiebreak must equal the account RPC tiebreak", + ); + assert.match(sql, /ARRAY\['cursor', 'trae-cn'\]::text\[\] AS account_sources/); + assert.match(sql, /o\.owner_device_id = h\.device_id/); + assert.match(sql, /o\.owner_device_id IS NULL/); +}); + +test("ingest edge accepts account_watermarks and upserts them after the bucket rows", () => { + const ts = readRepoFile("dashboard/edge-patches/tokentracker-ingest.ts"); + assert.match(ts, /account_watermarks/, "edge reads the account_watermarks payload"); + assert.match( + ts, + /tokentracker_account_sync_watermarks/, + "edge upserts into the watermark table", + ); + assert.match( + ts, + /onConflict: "user_id,device_id,source"/, + "upsert conflict target matches the table PK", + ); + // Watermark upsert must come AFTER the hourly upsert in file order. + assert.ok( + ts.indexOf("from(\"tokentracker_hourly\")") < ts.indexOf("tokentracker_account_sync_watermarks"), + "bucket rows land before the watermark that covers them", + ); + // Malformed watermarks fail closed. + assert.match(ts, /Invalid account watermark/); +}); + +test("local queue readers never surface watermark control records as usage rows", () => { + const localApi = readRepoFile("src/lib/local-api.js"); + assert.match( + localApi, + /kind === "account_sync_watermark"/, + "readQueueData must skip account_sync_watermark records", + ); + const sync = readRepoFile("src/commands/sync.js"); + assert.match( + sync, + /account_watermarks/, + "drainQueueToCloud must forward watermarks to the ingest edge", + ); +}); diff --git a/test/status.test.js b/test/status.test.js index 78d5d0825..87be8cc2f 100644 --- a/test/status.test.js +++ b/test/status.test.js @@ -474,3 +474,197 @@ test("status renders the Trae SOLO entitlement snapshot from Local State", async await fs.rm(tmp, { recursive: true, force: true }); } }); + + +// --------------------------------------------------------------------------- +// TRAE Work CN install detection + auth state (PR #474): a RESOLVABLE default +// path does not mean the app is installed - the macOS resolver always derives +// one. installed must mean the storage file actually EXISTS (same semantics +// as the sync path), and the auth state must distinguish not-signed-in / +// readable / malformed / unreadable. +// --------------------------------------------------------------------------- + +test("status trae-cn installed=false when the default path resolves but no storage file exists", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tokentracker-status-traecn-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevTraeCnHome = process.env.TOKENTRACKER_TRAE_CN_HOME; + const prevTraeCnUsage = process.env.TOKENTRACKER_TRAE_CN_USAGE; + const prevWrite = process.stdout.write; + + try { + process.env.HOME = tmp; + process.env.USERPROFILE = tmp; + delete process.env.TOKENTRACKER_TRAE_CN_HOME; + delete process.env.TOKENTRACKER_TRAE_CN_USAGE; + + // Nothing is created under the default TRAE SOLO CN path: the resolver + // still derives it, but the file is absent -> NOT installed. + let out = ""; + process.stdout.write = (chunk, enc, cb) => { + out += typeof chunk === "string" ? chunk : chunk.toString(enc || "utf8"); + if (typeof cb === "function") cb(); + return true; + }; + await cmdStatus(["--json"]); + const summary = JSON.parse(out); + assert.equal(summary.providers["trae-cn"].installed, false, "absent storage file must not report installed"); + + out = ""; + await cmdStatus(); + assert.doesNotMatch(out, /- Trae SOLO CN: usage sync/); + } finally { + process.stdout.write = prevWrite; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevTraeCnHome === undefined) delete process.env.TOKENTRACKER_TRAE_CN_HOME; + else process.env.TOKENTRACKER_TRAE_CN_HOME = prevTraeCnHome; + if (prevTraeCnUsage === undefined) delete process.env.TOKENTRACKER_TRAE_CN_USAGE; + else process.env.TOKENTRACKER_TRAE_CN_USAGE = prevTraeCnUsage; + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("status trae-cn installed=true with auth readable when the storage file exists", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tokentracker-status-traecn-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevTraeCnHome = process.env.TOKENTRACKER_TRAE_CN_HOME; + const prevTraeCnUsage = process.env.TOKENTRACKER_TRAE_CN_USAGE; + const prevWrite = process.stdout.write; + + try { + process.env.HOME = tmp; + process.env.USERPROFILE = tmp; + delete process.env.TOKENTRACKER_TRAE_CN_HOME; + delete process.env.TOKENTRACKER_TRAE_CN_USAGE; + + const storageDir = path.join( + tmp, "Library", "Application Support", "TRAE SOLO CN", "User", "globalStorage", + ); + await fs.mkdir(storageDir, { recursive: true }); + await fs.writeFile( + path.join(storageDir, "storage.json"), + JSON.stringify({ "iCubeAuthInfo://icube.cloudide": { token: "fake.jwt.value", refreshToken: "synthetic" } }), + "utf8", + ); + + let out = ""; + process.stdout.write = (chunk, enc, cb) => { + out += typeof chunk === "string" ? chunk : chunk.toString(enc || "utf8"); + if (typeof cb === "function") cb(); + return true; + }; + await cmdStatus(["--json"]); + const summary = JSON.parse(out); + assert.equal(summary.providers["trae-cn"].installed, true); + assert.equal(summary.providers["trae-cn"].auth, "readable"); + assert.equal(summary.providers["trae-cn"].usage_opt_in, false, "opt-in stays off by default"); + + out = ""; + await cmdStatus(); + assert.match(out, /- Trae SOLO CN: usage sync off \(set TOKENTRACKER_TRAE_CN_USAGE=1 to enable\), auth readable/); + } finally { + process.stdout.write = prevWrite; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevTraeCnHome === undefined) delete process.env.TOKENTRACKER_TRAE_CN_HOME; + else process.env.TOKENTRACKER_TRAE_CN_HOME = prevTraeCnHome; + if (prevTraeCnUsage === undefined) delete process.env.TOKENTRACKER_TRAE_CN_USAGE; + else process.env.TOKENTRACKER_TRAE_CN_USAGE = prevTraeCnUsage; + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("status trae-cn installed=false for a custom TOKENTRACKER_TRAE_CN_HOME that does not exist", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tokentracker-status-traecn-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevTraeCnHome = process.env.TOKENTRACKER_TRAE_CN_HOME; + const prevWrite = process.stdout.write; + + try { + process.env.HOME = tmp; + process.env.USERPROFILE = tmp; + process.env.TOKENTRACKER_TRAE_CN_HOME = path.join(tmp, "no-such-install"); + + let out = ""; + process.stdout.write = (chunk, enc, cb) => { + out += typeof chunk === "string" ? chunk : chunk.toString(enc || "utf8"); + if (typeof cb === "function") cb(); + return true; + }; + await cmdStatus(["--json"]); + const summary = JSON.parse(out); + assert.equal(summary.providers["trae-cn"].installed, false, "env override pointing at nothing is not installed"); + } finally { + process.stdout.write = prevWrite; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevTraeCnHome === undefined) delete process.env.TOKENTRACKER_TRAE_CN_HOME; + else process.env.TOKENTRACKER_TRAE_CN_HOME = prevTraeCnHome; + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("status trae-cn distinguishes malformed vs unreadable storage", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tokentracker-status-traecn-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevTraeCnHome = process.env.TOKENTRACKER_TRAE_CN_HOME; + const prevWrite = process.stdout.write; + + try { + process.env.HOME = tmp; + process.env.USERPROFILE = tmp; + const traeCnHome = path.join(tmp, "trae-cn-data"); + process.env.TOKENTRACKER_TRAE_CN_HOME = traeCnHome; + + const capture = () => { + let out = ""; + process.stdout.write = (chunk, enc, cb) => { + out += typeof chunk === "string" ? chunk : chunk.toString(enc || "utf8"); + if (typeof cb === "function") cb(); + return true; + }; + return async () => { + await cmdStatus(["--json"]); + return JSON.parse(out); + }; + }; + + // Present but malformed JSON -> malformed (NOT not-signed-in, NOT unreadable). + const storageDir = path.join(traeCnHome, "User", "globalStorage"); + await fs.mkdir(storageDir, { recursive: true }); + await fs.writeFile(path.join(storageDir, "storage.json"), "{not json", "utf8"); + let run = capture(); + let summary = await run(); + assert.equal(summary.providers["trae-cn"].installed, true); + assert.equal(summary.providers["trae-cn"].auth, "malformed"); + + // A storage path that exists but cannot be READ as a file (EISDIR: the + // resolved storage.json is itself a directory) -> unreadable, the + // fail-closed IO state, distinct from malformed. + await fs.rm(path.join(storageDir, "storage.json"), { force: true }); + await fs.mkdir(path.join(storageDir, "storage.json"), { recursive: true }); + run = capture(); + summary = await run(); + assert.equal(summary.providers["trae-cn"].installed, true); + assert.equal(summary.providers["trae-cn"].auth, "unreadable"); + } finally { + process.stdout.write = prevWrite; + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevTraeCnHome === undefined) delete process.env.TOKENTRACKER_TRAE_CN_HOME; + else process.env.TOKENTRACKER_TRAE_CN_HOME = prevTraeCnHome; + await fs.rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js index 9ec7673b0..f90ac2c38 100644 --- a/test/sync-trae-cn.test.js +++ b/test/sync-trae-cn.test.js @@ -101,6 +101,10 @@ function writeTraeCnStorage(traeCnHome, auth = { token: "fake-jwt-token", refres ); } +function readWatermarkRows(home) { + return readQueueRows(home).filter((row) => row.kind === "account_sync_watermark"); +} + function readQueueRows(home) { const queuePath = path.join(home, ".tokentracker", "tracker", "queue.jsonl"); if (!fs.existsSync(queuePath)) return []; @@ -170,7 +174,9 @@ test("non-background auto trae-cn sync fetches the exact rolling range and queue assert.equal(body.page_num, 1); assert.equal(body.page_size, 20); - const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn"); + const traeRows = readQueueRows(home).filter( + (row) => row.source === "trae-cn" && row.kind !== "account_sync_watermark", + ); assert.equal(traeRows.length, 1); assert.equal(traeRows[0].model, "doubao-pro"); assert.equal(traeRows[0].input_tokens, 100); @@ -179,6 +185,20 @@ test("non-background auto trae-cn sync fetches the exact rolling range and queue assert.equal(traeRows[0].conversation_count, 1); assert.equal(traeRows[0].hour_start, halfHourBucket(usageTime)); + // The sync appends exactly one account-sync watermark asserting the full + // verified window (bucket row first, watermark after - queue order). + const queueLines = fs + .readFileSync(path.join(home, ".tokentracker", "tracker", "queue.jsonl"), "utf8") + .trim() + .split("\n"); + assert.equal(queueLines.length, 2, "bucket row then watermark"); + const watermarks = readWatermarkRows(home); + assert.equal(watermarks.length, 1); + assert.equal(watermarks[0].source, "trae-cn"); + assert.equal(Date.parse(watermarks[0].window_start), startTime * 1000); + assert.equal(Date.parse(watermarks[0].window_end), endTime * 1000); + assert.equal(readCursors(home).traeCn.lastWatermark.window_end, watermarks[0].window_end); + const cursors = readCursors(home); assert.equal(cursors.traeCn.version, 1); assert.equal(cursors.traeCn.sessions["s1"].totals.input_tokens, 100); @@ -202,9 +222,14 @@ test("repeated fixed-now sync is idempotent (no token growth / no new queue row) await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS }); assert.equal(fs.readFileSync(queuePath, "utf8"), before, "queue unchanged on repeat"); - const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn"); + const traeRows = readQueueRows(home).filter( + (row) => row.source === "trae-cn" && row.kind !== "account_sync_watermark", + ); assert.equal(traeRows.length, 1); assert.equal(traeRows[0].total_tokens, 110); + // The repeat neither re-appends the watermark nor grows the queue: the + // cursor lastWatermark echo suppresses the redundant re-assertion. + assert.equal(readWatermarkRows(home).length, 1); }); }); @@ -357,10 +382,18 @@ test("over-capacity TRAE CN window is split and the staggered halves import atom }), [[startTime, endTime], [startTime, mid], [mid + 1, endTime]], ); - const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn"); + const traeRows = readQueueRows(home).filter( + (row) => row.source === "trae-cn" && row.kind !== "account_sync_watermark", + ); assert.equal(traeRows.length, 2, "both halves queue their row"); const cursorSessions = readCursors(home).traeCn.sessions; assert.deepEqual(Object.keys(cursorSessions).sort(), ["split-left", "split-right"]); + // Split sub-windows union back to ONE watermark over the full window - + // ownership cloud-side must cover the whole requested range. + const watermarks = readWatermarkRows(home); + assert.equal(watermarks.length, 1); + assert.equal(Date.parse(watermarks[0].window_start), startTime * 1000); + assert.equal(Date.parse(watermarks[0].window_end), endTime * 1000); }); }); diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js index ee762ff10..422db412f 100644 --- a/test/trae-cn-config.test.js +++ b/test/trae-cn-config.test.js @@ -854,3 +854,109 @@ test("fetchTraeCnUsageWithAuth fails when credentials are missing", async () => /credentials are not configured/, ); }); + + +// --------------------------------------------------------------------------- +// Usage API total validation (PR #474): the declared total gates pagination +// termination, so a malformed value must fail closed as a schema error - +// never be coerced into a legal-looking stop condition that silently +// truncates the snapshot after page 1. +// --------------------------------------------------------------------------- + +test("fetchTraeCnUsagePage rejects a malformed declared total", async () => { + const badTotals = [ + -1, // negative: 20 >= -1 would stop pagination after page 1 + -0.5, // negative fraction + 2.5, // fractional row count + "45", // quoted digits: the API speaks JSON numbers (see the + // start_time convention above - string digits are a schema + // anomaly, not a numeric representation we accept) + "", // empty string + true, // wrong primitive + ]; + for (const total of badTotals) { + await assert.rejects( + fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }], total }), + }), + (error) => { + assert.match(error.message, /invalid total/); + assert.ok(!error.message.includes(JWT), "schema error must not leak the JWT"); + return true; + }, + `total ${JSON.stringify(total)} must be rejected`, + ); + } +}); + +test("fetchTraeCnUsagePage still accepts valid totals (0, exact page multiple) and absent total", async () => { + const zero = await fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [], total: 0 }), + }); + assert.equal(zero.total, 0); + + const big = await fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }], total: 9007199254740991 }), + }); + assert.equal(big.total, Number.MAX_SAFE_INTEGER); + + const absent = await fetchTraeCnUsagePage({ + jwt: JWT, start_time: START, end_time: END, page_num: 1, + fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }] }), + }); + assert.equal(absent.total, null); +}); + +test("fetchTraeCnUsage fails closed on a malformed total instead of returning a partial snapshot", async () => { + // Page 1 is full (20 rows) and declares total = -1: the pre-fix code + // evaluated 20 >= -1 and silently stopped, returning a truncated snapshot. + const rows = Array.from({ length: 20 }, (_, i) => ({ id: i + 1 })); + const fetchImpl = async () => jsonResponse(200, { user_usage_group_by_sessions: rows, total: -1 }); + await assert.rejects( + fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0 }), + /invalid total/, + ); +}); + +// --------------------------------------------------------------------------- +// Auth storage IO errors (PR #474): a missing file is "not signed in", but a +// real IO failure (EACCES / EISDIR / I/O error) must fail closed with a +// generic, credential-safe error - never be reported as "not signed in" and +// never leak the OS detail, the path, or the storage contents. +// --------------------------------------------------------------------------- + +test("readTraeCnAuthFromStorage: ENOENT / ENOTDIR mean absent (null), other IO errors fail closed", () => { + const home = "/Users/tester"; + const env = { [TRAE_CN_HOME_ENV]: home }; + + const ioError = (code) => ({ + readFileSync: () => { + const err = new Error(`os-level detail ${code} /Users/tester/secret-path`); + err.code = code; + throw err; + }, + }); + + // Absent file / absent path component -> not signed in. + assert.equal(readTraeCnAuthFromStorage({ env, platform: "darwin", fsModule: ioError("ENOENT") }), null); + assert.equal(readTraeCnAuthFromStorage({ env, platform: "darwin", fsModule: ioError("ENOTDIR") }), null); + + // Real IO failures -> distinct unreadable error, credential-safe message. + for (const code of ["EACCES", "EISDIR", "EIO"]) { + assert.throws( + () => readTraeCnAuthFromStorage({ env, platform: "darwin", fsModule: ioError(code) }), + (error) => { + assert.equal(error.code, "TRAE_CN_STORAGE_UNREADABLE", code); + assert.match(error.message, /could not be read/); + // No OS detail, no filesystem path, no synthetic-JWT leak. + assert.ok(!error.message.includes(code), "must not surface the OS error code"); + assert.ok(!error.message.includes("secret-path"), "must not surface the storage path"); + return true; + }, + code, + ); + } +}); From c808a4f290de0bbcd5c73c6f48b8d2a435e33f69 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 08:21:30 +0800 Subject: [PATCH 12/19] fix(test): make trae-cn installed=true status test platform-independent The test built the darwin-only default storage path, so on Linux CI the resolver returned null and installed was false. Use the env override to point at a synthetic install; the semantics under test are unchanged. --- test/status.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/status.test.js b/test/status.test.js index 87be8cc2f..e7db8d4ba 100644 --- a/test/status.test.js +++ b/test/status.test.js @@ -538,11 +538,14 @@ test("status trae-cn installed=true with auth readable when the storage file exi try { process.env.HOME = tmp; process.env.USERPROFILE = tmp; - delete process.env.TOKENTRACKER_TRAE_CN_HOME; + // Platform-independent: the default-path resolver is darwin-only, so point + // the env override at a synthetic install. Same semantics under test: + // storage.json exists -> installed=true, auth readable. + process.env.TOKENTRACKER_TRAE_CN_HOME = path.join(tmp, "trae-install"); delete process.env.TOKENTRACKER_TRAE_CN_USAGE; const storageDir = path.join( - tmp, "Library", "Application Support", "TRAE SOLO CN", "User", "globalStorage", + process.env.TOKENTRACKER_TRAE_CN_HOME, "User", "globalStorage", ); await fs.mkdir(storageDir, { recursive: true }); await fs.writeFile( From 4b03aca32ab5e762475e46eb36b0f7cea8f7ec8b Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 09:09:52 +0800 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20TRAE=20CN=20watermark=20=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=89=80=E6=9C=89=E6=9D=83=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=20+=20snapshot=20=E4=BF=A1=E4=BB=BB=E8=AF=AD=E4=B9=89=E6=94=B6?= =?UTF-8?q?=E7=B4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - watermark 表 PK 扩展为 (user_id, device_id, source, window_start, window_end):历史窗口不可变,30 天滚动窗口滑走后纠正仍生效 - absence 语义未证实:empty payload 纯 no-op,不发布 watermark - 任何 malformed row 导致整个 snapshot fail-closed,不发布 authoritative watermark - fetch start 对齐半小时边界;watermark 仅声明完全覆盖的 bucket(SQL/JS/ingest 三处语义统一) - watermark upsert ignoreDuplicates:transport retry 不刷新 updated_at、不抢 ownership - queue 按 (source, window_start, window_end) 保留全部历史 watermark --- dashboard/edge-patches/tokentracker-ingest.ts | 15 +- ...20260817120000_account-sync-watermarks.sql | 28 ++- scripts/ops/account-usage-grouped-rpc.sql | 19 +- src/commands/sync.js | 59 +++--- src/lib/account-usage-dedup.js | 22 +- src/lib/rollout.js | 73 ++++--- test/account-usage-dedup.test.js | 195 ++++++++++++++++-- test/trae-cn-parser.test.js | 94 +++++++-- 8 files changed, 386 insertions(+), 119 deletions(-) diff --git a/dashboard/edge-patches/tokentracker-ingest.ts b/dashboard/edge-patches/tokentracker-ingest.ts index f288a0370..7f1201682 100644 --- a/dashboard/edge-patches/tokentracker-ingest.ts +++ b/dashboard/edge-patches/tokentracker-ingest.ts @@ -190,22 +190,25 @@ export default async function (req: Request): Promise { if (upsertErr) return json({ error: upsertErr.message }, 500); if (watermarks.length > 0) { - // Upsert AFTER the bucket rows of the same upload landed, so a watermark - // never covers hours whose owning rows failed to write. updated_at is - // set explicitly: freshness ordering (window_end DESC, updated_at DESC) - // must see this sync as newer than the previous one from this device. + // Insert AFTER the bucket rows of the same upload landed, so a watermark + // never covers hours whose owning rows failed to write. Watermark rows + // are IMMUTABLE history keyed by the full window identity + // (user_id, device_id, source, window_start, window_end): a re-delivered + // watermark (queue/upload retry of the SAME verified window) is a no-op + // (DO NOTHING), so updated_at stays the FIRST-upload time and a transport + // retry can never masquerade as a fresher snapshot in owner selection. const wmRows = watermarks.map((w) => ({ user_id: userId, device_id: deviceId, source: w.source, window_start: w.window_start, window_end: w.window_end, - updated_at: new Date().toISOString(), })); const { error: wmErr } = await client.database .from("tokentracker_account_sync_watermarks") .upsert(wmRows, { - onConflict: "user_id,device_id,source", + onConflict: "user_id,device_id,source,window_start,window_end", + ignoreDuplicates: true, }); if (wmErr) return json({ error: wmErr.message }, 500); } diff --git a/migrations/20260817120000_account-sync-watermarks.sql b/migrations/20260817120000_account-sync-watermarks.sql index 1ec1d7e1b..5149380f0 100644 --- a/migrations/20260817120000_account-sync-watermarks.sql +++ b/migrations/20260817120000_account-sync-watermarks.sql @@ -27,6 +27,17 @@ -- Rollback: DROP TABLE tokentracker_account_sync_watermarks; and re-apply the -- previous leaderboard_hourly_dedup_v2 definition. +-- Watermark rows are IMMUTABLE HISTORY: identity is the full window +-- (user_id, device_id, source, window_start, window_end). A device's newer +-- rolling window INSERTS a new row; it never overwrites the old one. This is +-- what keeps historical ownership stable: once device B displaced device A's +-- stale tuple for hour H, B's covering watermark row stays in the table even +-- after B's 30-day window slides past H, so H never falls back to the legacy +-- MAX dedup and the correction cannot resurrect. Growth is one row per +-- verified sync per device (~1.5k rows/device-year at 4 syncs/day) - cheap +-- for the owner lookup below; semantic-preserving compaction (merging +-- overlapping windows of the SAME device when coverage is unaffected) is a +-- possible future optimization, not needed at this scale. CREATE TABLE IF NOT EXISTS public.tokentracker_account_sync_watermarks ( user_id uuid NOT NULL, device_id uuid NOT NULL, @@ -34,7 +45,7 @@ CREATE TABLE IF NOT EXISTS public.tokentracker_account_sync_watermarks ( window_start timestamptz NOT NULL, window_end timestamptz NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, device_id, source), + PRIMARY KEY (user_id, device_id, source, window_start, window_end), CHECK (window_end > window_start) ); @@ -78,9 +89,14 @@ AS $func$ WHERE h.hour_start >= p_from AND h.hour_start < p_to AND h.source = ANY(cfg.account_sources) ), - -- Per (user, account source, hour): the freshest watermark window covering - -- that hour owns it (NULL = uncovered, legacy dedup below). Deterministic - -- tiebreak: window_end DESC, updated_at DESC, device_id. + -- Per (user, account source, hour): the watermark with the greatest + -- window_end whose window FULLY CONTAINS the half-hour bucket owns it + -- (NULL = uncovered, legacy dedup below). Full containment: bucket start + -- >= window_start AND bucket end <= window_end - a partially verified + -- bucket is never claimed. Freshness is window identity only (window_end, + -- then window_start, then device_id): updated_at is the FIRST-upload time + -- of an immutable row and must never participate, so a transport retry of + -- an old watermark cannot steal ownership from a genuinely newer snapshot. acct_own AS ( SELECT ah.user_id, ah.source, ah.hour_start, (SELECT w.device_id @@ -88,8 +104,8 @@ AS $func$ WHERE w.user_id = ah.user_id AND w.source = ah.source AND ah.hour_start >= w.window_start - AND ah.hour_start < w.window_end - ORDER BY w.window_end DESC, w.updated_at DESC, w.device_id + AND ah.hour_start + interval '30 minutes' <= w.window_end + ORDER BY w.window_end DESC, w.window_start DESC, w.device_id LIMIT 1) AS owner_device_id FROM acct_hours ah ) diff --git a/scripts/ops/account-usage-grouped-rpc.sql b/scripts/ops/account-usage-grouped-rpc.sql index 0299ba9f8..ce57f364e 100644 --- a/scripts/ops/account-usage-grouped-rpc.sql +++ b/scripts/ops/account-usage-grouped-rpc.sql @@ -113,10 +113,17 @@ AS $func$ cfg AS ( SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources ), - -- Per (account source, hour): the canonical owning device = the freshest - -- watermark window covering that hour (NULL when no watermark covers it, - -- i.e. cursor and any watermark-less history). Deterministic tiebreak: - -- window_end DESC, updated_at DESC, device_id. + -- Per (account source, hour): the canonical owning device = the watermark + -- with the greatest window_end whose window FULLY CONTAINS the half-hour + -- bucket (NULL when none does, i.e. cursor and any watermark-less or only + -- partially-covered history). Full containment: bucket start >= + -- window_start AND bucket end <= window_end - the rolling sync window + -- starts bucket-aligned and ends at floor(now), so the in-progress final + -- bucket is honestly uncovered until a later sync fully contains it. + -- Freshness is window identity only (window_end, window_start, device_id): + -- updated_at is the FIRST-upload time of an immutable row and must never + -- participate, so a transport retry of an old watermark cannot steal + -- ownership from a genuinely newer snapshot. acct_own AS ( SELECT ah.hour_start, ah.source, (SELECT w.device_id @@ -124,8 +131,8 @@ AS $func$ WHERE w.user_id = p_user_id AND w.source = ah.source AND ah.hour_start >= w.window_start - AND ah.hour_start < w.window_end - ORDER BY w.window_end DESC, w.updated_at DESC, w.device_id + AND ah.hour_start + interval '30 minutes' <= w.window_end + ORDER BY w.window_end DESC, w.window_start DESC, w.device_id LIMIT 1) AS owner_device_id FROM ( SELECT DISTINCT h.hour_start, h.source diff --git a/src/commands/sync.js b/src/commands/sync.js index 713eb2c94..a62fd38df 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -1701,7 +1701,15 @@ async function cmdSync(argv, context = {}) { const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now(); const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch; const endTime = Math.floor(nowMs / 1000); - const startTime = Math.max(1, endTime - 30 * 24 * 60 * 60); + // Align the REAL fetch start down to a half-hour boundary so the API + // range fully contains every bucket it can touch (a raw 08:37 start + // would only partially cover the 08:30 bucket) and the watermark + // asserts exactly the same range the API was queried with. + const HALF_HOUR_SEC = 30 * 60; + const startTime = Math.max( + 1, + Math.floor((endTime - 30 * 24 * 60 * 60) / HALF_HOUR_SEC) * HALF_HOUR_SEC, + ); try { // Absent storage means "not signed in" — a silent skip, not an error. const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home }); @@ -1717,10 +1725,11 @@ async function cmdSync(argv, context = {}) { home, }); const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : []; - // Parse even an empty payload: the parser is a local no-op but - // still appends the account-sync watermark asserting this window - // was verified, which is what displaces stale cross-device tuples - // for the covered hours (fresh-device invariant). + // An empty payload parses as a pure no-op: the TRAE absence + // contract is NOT PROVEN (no evidence that a missing session means + // deleted/zero), so an empty response asserts nothing - no usage + // mutation and no watermark. Non-empty snapshots append the + // watermark that displaces stale cross-device tuples. { if (progress?.enabled && traeCnSessions.length > 0) { progress.start( @@ -1737,15 +1746,10 @@ async function cmdSync(argv, context = {}) { windowStartMs: startTime * 1000, windowEndMs: endTime * 1000, }); - // Row-level isolation means a malformed row is skipped, not fatal; - // surface the skip count even on auto runs (warnProviderParseFailure - // is silenced there) so it stays diagnosable. - const skipped = Number(traeCnResult?.skippedRows) || 0; - if (skipped > 0) { - process.stderr.write( - `TRAE Work CN sync: skipped ${skipped} malformed session row${skipped !== 1 ? "s" : ""}.\n`, - ); - } + // A partially malformed snapshot throws inside the parser (fail + // closed - it must not become the authoritative window state); + // warnProviderParseFailure below reports it without sensitive + // data while unrelated providers continue. } } } catch (err) { @@ -3535,9 +3539,8 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa if (offset >= queueSize) break; const result = await readQueueBatch(queuePath, offset, limit); // A watermark-only tail (e.g. a sync whose reconciled buckets were all - // unchanged, or a verified-empty window) is still a meaningful upload: - // it advances the account-sync coverage that displaces stale - // cross-device tuples cloud-side. + // unchanged) is still a meaningful upload: it advances the account-sync + // coverage that displaces stale cross-device tuples cloud-side. if (result.buckets.length === 0 && result.watermarks.length === 0) break; const root = baseUrl.replace(/\/$/, ""); @@ -3594,8 +3597,12 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { // Account-sync watermarks (kind: "account_sync_watermark") are queue // control records, not usage rows: collect them separately so they ride // the same upload (after the bucket rows in file order) without entering - // the bucket stream. Last record per source wins - records are appended - // chronologically, one per verified sync window. + // the bucket stream. Watermarks are IMMUTABLE history: collect EVERY + // unique (source, window_start, window_end) — an older window must never + // be dropped just because a newer rolling window was appended later, or + // historical ownership would be lost exactly when the 30-day window + // slides past a corrected bucket. (The ingest edge caps a batch at 50; + // each sync appends one record, well under the cap.) const watermarkMap = new Map(); let offset = startOffset; let linesRead = 0; @@ -3611,11 +3618,15 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { } if (bucket?.kind === "account_sync_watermark") { if (typeof bucket.source === "string" && bucket.source.trim()) { - watermarkMap.set(bucket.source.trim().toLowerCase(), { - source: bucket.source.trim().toLowerCase(), - window_start: bucket.window_start, - window_end: bucket.window_end, - }); + const wmSource = bucket.source.trim().toLowerCase(); + const wmKey = wmSource + "|" + bucket.window_start + "|" + bucket.window_end; + if (!watermarkMap.has(wmKey)) { + watermarkMap.set(wmKey, { + source: wmSource, + window_start: bucket.window_start, + window_end: bucket.window_end, + }); + } } continue; } diff --git a/src/lib/account-usage-dedup.js b/src/lib/account-usage-dedup.js index aecf32d47..1a814cefd 100644 --- a/src/lib/account-usage-dedup.js +++ b/src/lib/account-usage-dedup.js @@ -40,14 +40,24 @@ function parseMs(value) { return Number.isFinite(ms) ? ms : null; } -// Mirrors SQL: ORDER BY w.window_end DESC, w.updated_at DESC, w.device_id LIMIT 1 +// Bucket span: usage buckets are half-hours, and a watermark only claims a +// bucket it FULLY contains (bucketStart >= window_start AND bucketEnd <= +// window_end). A partially verified bucket (e.g. the in-progress final +// half-hour of a rolling window ending at floor(now)) is NOT claimed. +const BUCKET_SPAN_MS = 30 * 60 * 1000; + +// Mirrors SQL owner selection: full-containment coverage, then +// ORDER BY window_end DESC, window_start DESC, device_id. updated_at never +// participates: watermark rows are immutable history and updated_at is the +// FIRST-upload time, so a transport retry cannot steal ownership from a +// genuinely newer snapshot. function pickOwnerWatermark(watermarks, hourStartMs) { let best = null; for (const wm of watermarks) { const startMs = parseMs(wm.window_start); const endMs = parseMs(wm.window_end); if (startMs === null || endMs === null || endMs <= startMs) continue; - if (hourStartMs < startMs || hourStartMs >= endMs) continue; + if (hourStartMs < startMs || hourStartMs + BUCKET_SPAN_MS > endMs) continue; if (!best) { best = wm; continue; @@ -58,17 +68,15 @@ function pickOwnerWatermark(watermarks, hourStartMs) { if (wmEnd > bestEnd) best = wm; continue; } - const bestUpdated = parseMs(best.updated_at ?? best.window_end) || 0; - const wmUpdated = parseMs(wm.updated_at ?? wm.window_end) || 0; - if (wmUpdated !== bestUpdated) { - if (wmUpdated > bestUpdated) best = wm; + const bestStart = parseMs(best.window_start) || 0; + if (startMs !== bestStart) { + if (startMs > bestStart) best = wm; continue; } if (String(wm.device_id) < String(best.device_id)) best = wm; } return best; } - // Mirrors the legacy SQL DISTINCT ON (hour, source, model) // ORDER BY total_tokens DESC, updated_at DESC (device_id asc as the final // deterministic tiebreak). diff --git a/src/lib/rollout.js b/src/lib/rollout.js index ea7098dab..b8f59f07d 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16617,11 +16617,17 @@ function assertTraeCnBucketCovers(bucketTotals, previousTotals) { // asserting that this device has just verified the account snapshot for the // closed window [window_start, window_end] against the TRAE CN API. The // cloud aggregation (account-usage-grouped RPC / leaderboard_hourly_dedup_v2, -// mirrored by src/lib/account-usage-dedup.js) uses it to pick, per hour, ONE -// canonical owning device - the freshest watermark whose window covers that -// hour - so cross-device downward/model/bucket corrections displace stale -// tuples from devices holding older snapshot versions, and a fresh device -// with no prior cursor state still displaces tuples it has never seen. +// mirrored by src/lib/account-usage-dedup.js) uses it to pick, per half-hour +// bucket, ONE canonical owning device - the watermark with the greatest +// window_end whose window FULLY CONTAINS the bucket (bucket start >= +// window_start AND bucket end <= window_end; a partially covered bucket is +// never claimed). Watermark rows are IMMUTABLE history: identity is +// (user_id, device_id, source, window_start, window_end), so an old window +// is never overwritten by a newer rolling window - once a device corrected a +// historical bucket, that ownership survives the 30-day window sliding past +// it (the covering watermark row is still in the table). Cloud-side owner +// freshness is window_end DESC, window_start DESC, device_id - updated_at is +// NOT freshness (a transport retry must not revive a stale snapshot). // Appended AFTER the bucket rows of the same sync so the queue ordering // guarantees a device's rows always land before the watermark covering them. // Bucket-row readers skip this record via its kind field (it carries no @@ -16684,33 +16690,33 @@ async function parseTraeCnApiIncremental({ const traeCnState = structuredClone(normalizeTraeCnState(cursors?.traeCn)); if (sessions.length === 0) { - // Empty payload is a successful no-op for usage state: it never erases - // prior contributions. The verified-empty window is still asserted - // cloud-side via the watermark so stale cross-device tuples inside the - // window are displaced (fresh-device invariant D) without rewriting any - // usage rows locally. Re-asserting the SAME window as the last sync is - // redundant (the cloud upsert is idempotent), so skip it to keep a - // fixed-now no-change sync byte-identical; a window that ADVANCED (the - // production rolling window moves with now) is always re-asserted. - if (!isSameTraeCnWatermark(traeCnState.lastWatermark, windowStartMs, windowEndMs)) { - const appended = await appendTraeCnSyncWatermark({ queuePath, windowStartMs, windowEndMs }); - if (appended) { - traeCnState.lastWatermark = watermarkWindow(windowStartMs, windowEndMs); - traeCnState.updatedAt = new Date().toISOString(); - // Sessions/prunedBeforeMs pass through the clone untouched, so this - // commits ONLY the watermark echo - prior contributions survive. - cursors.traeCn = traeCnState; - } - } + // Empty payload is a successful no-op: no usage mutation, NO watermark. + // + // Evidence check (2026-08-16, two real fetches 17min apart over the same + // account: 137 -> 141 sessions, 0 disappeared, 0 rows changed) shows the + // session set is stable, but NOTHING proves 'absent from the response' + // means 'authoritatively deleted / zero' — so the absence contract is + // NOT PROVEN and must stay symmetric everywhere: a session missing from + // a non-empty snapshot is never retracted locally, and an empty response + // must not assert an authoritative-zero window that would displace other + // devices' tuples cloud-side. Fresh-device displacement rides on + // non-empty snapshots only. return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; } // Full prevalidation + in-payload dedupe BEFORE any cursor/bucket mutation. - // Row-level schema failures are isolated per row: one malformed row (e.g. a - // negative or non-integer token count) is skipped and counted instead of - // discarding the whole payload — but structural failures (non-array - // sessions, unwritable cursors) and conflicting duplicate contributions - // still fail closed above/below. + // + // ANY malformed row fails the whole snapshot closed. The sync watermark + // asserts this device fully verified the account window, so a partially + // understood snapshot (99 valid + 1 uninterpretable row) must never be + // enqueued or watermarked: it would become the canonical cloud state for + // the window. The provider-level try/catch in cmdSync keeps the failure + // isolated (other providers sync on) and the error message carries only + // the count + reason - no session ids, tokens, or credentials. Confirmed + // LEGAL variations must not land here: absent cache fields are already + // coerced to 0 inside normalizeTraeCnSession (models without a prompt + // cache concept); only truly uninterpretable rows (bad session_id / + // usage_time / token numbers / model, impossible schema) throw. const bySession = new Map(); let skippedRows = 0; let firstSkipReason = ""; @@ -16736,14 +16742,15 @@ async function parseTraeCnApiIncremental({ } bySession.set(normalized.sessionId, normalized); } - if (bySession.size === 0 && skippedRows > 0) { - // Every row was malformed: fail closed rather than enqueue an empty, - // authoritative-looking snapshot that would read as "no usage". + if (skippedRows > 0) { + // Partial snapshots are not authoritative: one uninterpretable row means + // the window was NOT fully verified, so fail closed - no bucket rows, no + // watermark, no cursor commit. The next sync replays from the same + // cursor state (idempotent). throw new Error( - `Trae CN session rows are all malformed (${skippedRows} skipped; first: ${firstSkipReason}).`, + `Trae CN snapshot is not authoritative: ${skippedRows} malformed row${skippedRows !== 1 ? "s" : ""} (first: ${firstSkipReason}).`, ); } - const touchedBuckets = new Set(); const cb = typeof onProgress === "function" ? onProgress : null; const total = sessions.length; diff --git a/test/account-usage-dedup.test.js b/test/account-usage-dedup.test.js index dd3b407c4..74ff37e1a 100644 --- a/test/account-usage-dedup.test.js +++ b/test/account-usage-dedup.test.js @@ -153,10 +153,12 @@ test("D. fresh device with no old cursors displaces stale tuples it has never se assert.equal(byHour.get(H1030), 60); }); -test("D2. fresh device verified-empty window zeros out stale tuples completely", () => { - // The strongest displacement case: the API now reports NOTHING for the - // window that the old device's stale tuples live in. The fresh device - // contributes no rows at all - only its watermark. +test("D2. an owner with no rows at covered hours displaces every stale tuple", () => { + // Aggregation-level displacement: the owning device's snapshot holds + // nothing at a covered hour (e.g. every session there migrated away), so + // the stale tuples must not survive. NOTE: an EMPTY API response produces + // NO watermark at all (the TRAE absence contract is NOT PROVEN), so this + // displacement only ever rides on NON-EMPTY verified snapshots. const rows = [row(OLD, H10, "model-a", 100), row(OLD, H11, "model-b", 50)]; const watermarks = [watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end)]; const deduped = dedupeAccountLevelRows(rows, watermarks); @@ -240,27 +242,30 @@ test("partial coverage: covered hours use ownership, uncovered hours stay legacy assert.equal(byHour.get(H1030), 90, "uncovered hour: legacy MAX"); }); -test("owner selection tiebreak is deterministic: window_end, then updated_at, then device_id", () => { +test("owner selection tiebreak is deterministic: window_end, then window_start, then device_id", () => { const rows = [ row("aaaa0000-0000-0000-0000-000000000000", H10, "model-a", 10), row("bbbb0000-0000-0000-0000-000000000000", H10, "model-a", 20), ]; - // Equal windows + equal updated_at -> smaller device_id wins (matches the - // SQL ORDER BY ... w.device_id LIMIT 1). + // Equal window identity -> smaller device_id wins (deterministic, mirrors + // the SQL ORDER BY ... w.device_id LIMIT 1). const watermarks = [ - watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T00:00:00.000Z"), - watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T00:00:00.000Z"), + watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end), + watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end), ]; const deduped = dedupeAccountLevelRows(rows, watermarks); assert.equal(sumTotalTokens(deduped), 10, "device_id aaa... owns the tie"); - // Later updated_at on the same window_end wins over device_id order. + // updated_at is IRRELEVANT to ownership: watermark rows are immutable + // (first-upload timestamp only), so a wildly later updated_at on the SAME + // window identity must NOT flip the pick — this is what makes transport + // retries harmless (regression E.23). const watermarks2 = [ watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T00:00:00.000Z"), - watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-16T00:00:00.000Z"), + watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-02-16T00:00:00.000Z"), ]; const deduped2 = dedupeAccountLevelRows(rows, watermarks2); - assert.equal(sumTotalTokens(deduped2), 20, "later updated_at owns the tie"); + assert.equal(sumTotalTokens(deduped2), 10, "later updated_at on the same window must not steal ownership"); // A strictly later window_end wins outright (aaaa verified through // 01-13, bbbb only through 01-12 -> aaaa owns despite the device_id order). @@ -271,7 +276,6 @@ test("owner selection tiebreak is deterministic: window_end, then updated_at, th const deduped3 = dedupeAccountLevelRows(rows, watermarks3); assert.equal(sumTotalTokens(deduped3), 10, "larger window_end owns"); }); - test("watermarks are scoped per source: a cursor watermark never owns trae-cn hours", () => { const rows = [row(OLD, H10, "model-a", 100)]; const watermarks = [ @@ -316,8 +320,13 @@ test("account_usage_grouped RPC implements watermark ownership with the same tie assert.match(sql, /tokentracker_account_sync_watermarks/, "RPC reads the watermark table"); assert.match( sql, - /ORDER BY w\.window_end DESC, w\.updated_at DESC, w\.device_id/, - "owner tiebreak must be window_end DESC, updated_at DESC, device_id - same as pickOwnerWatermark", + /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.device_id/, + "owner tiebreak must be window_end DESC, window_start DESC, device_id - same as pickOwnerWatermark; updated_at never participates", + ); + assert.match( + sql, + /ah\.hour_start \+ interval '30 minutes' <= w\.window_end/, + "a bucket is owned only when FULLY contained (bucket end <= window end)", ); assert.match( sql, @@ -345,15 +354,15 @@ test("leaderboard_hourly_dedup_v2 migration mirrors the RPC ownership semantics assert.match(sql, /CREATE TABLE IF NOT EXISTS public\.tokentracker_account_sync_watermarks/); assert.match( sql, - /PRIMARY KEY \(user_id, device_id, source\)/, - "watermark identity is per (user, device, source) - last upsert per device wins", + /PRIMARY KEY \(user_id, device_id, source, window_start, window_end\)/, + "watermark identity is the full window - rows are immutable history, never overwritten", ); assert.match(sql, /CHECK \(window_end > window_start\)/); assert.match(sql, /ENABLE ROW LEVEL SECURITY/); // The leaderboard function must agree with account_usage_grouped. assert.match( sql, - /ORDER BY w\.window_end DESC, w\.updated_at DESC, w\.device_id/, + /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.device_id/, "leaderboard owner tiebreak must equal the account RPC tiebreak", ); assert.match(sql, /ARRAY\['cursor', 'trae-cn'\]::text\[\] AS account_sources/); @@ -371,8 +380,13 @@ test("ingest edge accepts account_watermarks and upserts them after the bucket r ); assert.match( ts, - /onConflict: "user_id,device_id,source"/, - "upsert conflict target matches the table PK", + /onConflict: "user_id,device_id,source,window_start,window_end"/, + "upsert conflict target matches the immutable window identity PK", + ); + assert.match( + ts, + /ignoreDuplicates: true/, + "a re-delivered watermark is a no-op (retry never refreshes updated_at / freshness)", ); // Watermark upsert must come AFTER the hourly upsert in file order. assert.ok( @@ -397,3 +411,144 @@ test("local queue readers never surface watermark control records as usage rows" "drainQueueToCloud must forward watermarks to the ingest edge", ); }); +// --------------------------------------------------------------------------- +// F. Historical ownership survives the 30-day window sliding past (>30 days +// regression - merge-blocker): watermark rows are IMMUTABLE history, so the +// covering watermark that displaced a stale tuple stays in the table even +// after BOTH devices' current rolling windows no longer cover the hour. +// --------------------------------------------------------------------------- +test("F. corrected history stays corrected after the 30-day window slides past it", () => { + // Day 0: OLD synced H10/model-a=100 (its watermark covered H10 then). + // Day 1: TRAE corrects the session to model-b=60; FRESH syncs the + // corrected snapshot - FRESH's day-1 watermark (greater window_end) owns H10. + // Day 31+: both devices' CURRENT windows have slid past H10; only the + // immutable historical rows still cover it. FRESH's day-1 watermark row + // must STILL own H10 - the correction must never resurrect via the legacy + // MAX fallback, and the stale model-a tuple must stay displaced. + const rows = [ + row(OLD, H10, "model-a", 100), // OLD's stale row (uploaded day 0) + row(FRESH, H10, "model-b", 60), // FRESH's corrected row (uploaded day 1) + ]; + const watermarks = [ + // OLD: day-0 window (covered H10) + current day-31 window (does not). + watermark(OLD, "2026-12-11T00:00:00.000Z", "2027-01-10T11:00:00.000Z"), + watermark(OLD, "2027-02-10T00:00:00.000Z", "2027-03-12T00:00:00.000Z"), + // FRESH: day-1 corrected window (still covers H10 - immutable history) + // + current day-32 window (does not). + watermark(FRESH, "2026-12-11T00:00:00.000Z", "2027-01-12T00:00:00.000Z"), + watermark(FRESH, "2027-02-11T00:00:00.000Z", "2027-03-13T00:00:00.000Z"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + const byModel = totalByModel(deduped); + assert.equal(byModel.get("model-a") || 0, 0, "stale model-a must stay displaced"); + assert.equal(byModel.get("model-b"), 60, "corrected value survives the window slide"); + assert.equal(sumTotalTokens(deduped), 60, "must stay 60 - never 100, never 160"); +}); + +test("F2. downward correction also survives the 30-day window slide", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 60), + ]; + const watermarks = [ + watermark(OLD, "2026-12-11T00:00:00.000Z", "2027-01-10T11:00:00.000Z"), + watermark(OLD, "2027-02-10T00:00:00.000Z", "2027-03-12T00:00:00.000Z"), + watermark(FRESH, "2026-12-11T00:00:00.000Z", "2027-01-12T00:00:00.000Z"), + watermark(FRESH, "2027-02-11T00:00:00.000Z", "2027-03-13T00:00:00.000Z"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "must stay the corrected 60, never MAX 100"); +}); + +test("F3. no fallback to stale MAX: a once-covered hour never reverts to legacy dedup", () => { + // Even when the ONLY covering watermark is months old (the correcting + // device long retired), the hour is covered - legacy MAX must not run. + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 60), + ]; + const watermarks = [ + watermark(FRESH, "2026-12-11T00:00:00.000Z", "2027-01-12T00:00:00.000Z", "2027-06-01T00:00:00.000Z"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60); +}); + +// --------------------------------------------------------------------------- +// G. Bucket-coverage boundary contract: a watermark claims a half-hour +// bucket only when the bucket is FULLY inside [window_start, window_end]. +// The rolling fetch start is bucket-aligned down and the end is floor(now), +// so honest gaps stay uncovered (legacy fallback) instead of being claimed. +// --------------------------------------------------------------------------- +test("G1. left boundary: a window starting mid-bucket does not claim that bucket", () => { + // Unaligned start 08:37: the 08:30 bucket is only PARTIALLY verified. + const rows = [ + row(OLD, "2027-01-10T08:30:00.000Z", "model-a", 100), + row(FRESH, "2027-01-10T08:30:00.000Z", "model-a", 60), + ]; + const watermarks = [watermark(FRESH, "2027-01-10T08:37:00.000Z", DAY_WINDOW.end)]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal( + sumTotalTokens(deduped), + 100, + "partial bucket falls back to legacy MAX - never claimed by a partial window", + ); +}); + +test("G2. right boundary: window_end exactly at a bucket start does not claim it", () => { + // end = 10:30:00: the 10:30 bucket spans [10:30, 11:00) - only its first + // instant is verified. Not claimed. + const rows = [ + row(OLD, H1030, "model-a", 100), + row(FRESH, H1030, "model-a", 60), + ]; + const watermarks = [watermark(FRESH, DAY_WINDOW.start, "2027-01-10T10:30:00.000Z")]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 100, "the 10:30 bucket is NOT claimed by a window ending 10:30"); +}); + +test("G3. right boundary: window_end mid-bucket does not claim the in-progress bucket", () => { + // end = 10:47: the 10:30 bucket [10:30, 11:00) is only verified through + // 10:47. Not claimed. + const rows = [ + row(OLD, H1030, "model-a", 100), + row(FRESH, H1030, "model-a", 60), + ]; + const watermarks = [watermark(FRESH, DAY_WINDOW.start, "2027-01-10T10:47:00.000Z")]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 100, "in-progress bucket stays uncovered (legacy fallback)"); +}); + +test("G4. right boundary: window_end at the bucket END fully claims the bucket", () => { + // end = 11:00: the 10:30 bucket [10:30, 11:00) is fully contained. + const rows = [ + row(OLD, H1030, "model-a", 100), + row(FRESH, H1030, "model-a", 60), + ]; + const watermarks = [watermark(FRESH, DAY_WINDOW.start, "2027-01-10T11:00:00.000Z")]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "a fully-contained bucket is owned by its verifier"); +}); + +// --------------------------------------------------------------------------- +// H. Transport retry idempotence (E.23): a re-delivered watermark of an +// older snapshot must never outrank a genuinely newer verified window, +// even if its updated_at looks fresher. +// --------------------------------------------------------------------------- +test("H. an old watermark re-delivered late never steals ownership from a newer snapshot", () => { + const A = "aaaa0000-0000-0000-0000-000000000000"; + const B = "bbbb0000-0000-0000-0000-000000000000"; + const rows = [ + row(A, H10, "model-a", 100), // A's stale row + row(B, H10, "model-a", 60), // B's corrected row + ]; + const watermarks = [ + // A's snapshot verified at T1; its queue record re-delivered at T3 + // (updated_at refreshed by the retry - the legacy failure mode). + watermark(A, DAY_WINDOW.start, "2027-01-11T00:00:00.000Z", "2027-03-01T00:00:00.000Z"), + // B's genuinely newer window (verified T2): later window_end. + watermark(B, DAY_WINDOW.start, "2027-01-12T00:00:00.000Z", "2027-01-20T00:00:00.000Z"), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "window identity, not updated_at, decides ownership"); +}); diff --git a/test/trae-cn-parser.test.js b/test/trae-cn-parser.test.js index f067a817e..b3fb76925 100644 --- a/test/trae-cn-parser.test.js +++ b/test/trae-cn-parser.test.js @@ -326,28 +326,39 @@ test("conflicting duplicate ids and malformed rows fail before any mutation", as assert.equal(cursorsOptional.traeCn.sessions[canary].totals.cached_input_tokens, 0); fs.rmSync(optionalQueue.dir, { recursive: true, force: true }); - // A malformed row is isolated: skipped and counted, the rest still imports. + // A malformed row FAILS THE WHOLE SNAPSHOT closed (P0: a partially + // understood snapshot must never become the authoritative window state - + // no bucket rows, no watermark, no cursor commit). Confirmed-legal + // variations (absent cache fields) are handled above and stay legal. const mixedQueue = tempQueue(); const cursorsMixed = {}; - const mixedResult = await parseTraeCnApiIncremental({ - sessions: [ - sessionRow({ session_id: canary, cache_read_token: "lots" }), - sessionRow({ session_id: "healthy-row" }), - ], - cursors: cursorsMixed, - queuePath: mixedQueue.queuePath, - }); - assert.equal(mixedResult.skippedRows, 1); - assert.equal(cursorsMixed.traeCn.sessions[canary], undefined, "bad row never imported"); - assert.ok(cursorsMixed.traeCn.sessions["healthy-row"], "healthy row imported"); + await assert.rejects( + parseTraeCnApiIncremental({ + sessions: [ + sessionRow({ session_id: canary, cache_read_token: "lots" }), + sessionRow({ session_id: "healthy-row" }), + ], + cursors: cursorsMixed, + queuePath: mixedQueue.queuePath, + }), + (error) => { + assert.match(error.message, /not authoritative/); + assert.match(error.message, /1 malformed row/); + assert.match(error.message, /invalid cache_read_token/); + assert.ok(!error.message.includes(canary)); + return true; + }, + ); + assert.equal(cursorsMixed.hourly, undefined, "no state assigned on a partial snapshot"); + assert.equal(cursorsMixed.traeCn, undefined, "no traeCn state on a partial snapshot"); + assert.equal(fs.existsSync(mixedQueue.queuePath), false, "no queue rows written (no buckets, no watermark)"); fs.rmSync(mixedQueue.dir, { recursive: true, force: true }); - - // A payload where EVERY row is malformed fails closed (never an empty, - // authoritative-looking snapshot) and the reason stays diagnosable + id-free. + // A payload where EVERY row is malformed fails with the same unified + // not-authoritative error (reason stays diagnosable + id-free). await assert.rejects( parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, cache_read_token: "lots" })], cursors, queuePath }), (error) => { - assert.match(error.message, /all malformed/); + assert.match(error.message, /not authoritative/); assert.match(error.message, /invalid cache_read_token/); assert.ok(!error.message.includes(canary)); return true; @@ -357,7 +368,7 @@ test("conflicting duplicate ids and malformed rows fail before any mutation", as await assert.rejects( parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, model_name: "bad|model" })], cursors, queuePath }), (error) => { - assert.match(error.message, /all malformed/); + assert.match(error.message, /not authoritative/); assert.match(error.message, /unsupported model name/); assert.ok(!error.message.includes(canary)); return true; @@ -398,6 +409,55 @@ test("empty payload is a no-op and preserves existing contributions", async (t) assert.equal(lastTraeRow(queuePath, "doubao-pro").total_tokens, 110); }); +test("empty payload asserts nothing: no watermark, no cursor commit (absence contract NOT PROVEN)", async (t) => { + // Evidence: two real fetches 17min apart (137 -> 141 sessions, 0 + // disappeared, 0 changed) show a stable session set, but nothing proves + // that an absent session means deleted/zero. An empty response therefore + // asserts NOTHING - no usage mutation AND no account_sync_watermark that + // would displace other devices' tuples cloud-side. + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + await parseTraeCnApiIncremental({ sessions: [sessionRow()], cursors, queuePath }); + const before = fs.readFileSync(queuePath, "utf8"); + + const empty = await parseTraeCnApiIncremental({ + sessions: [], + cursors, + queuePath, + windowStartMs: 1_700_000_000_000, + windowEndMs: 1_700_000_000_000 + 30 * 24 * 3600 * 1000, + }); + assert.equal(empty.recordsProcessed, 0); + assert.equal(empty.bucketsQueued, 0); + assert.equal(fs.readFileSync(queuePath, "utf8"), before, "no watermark appended for an empty response"); + assert.equal( + cursors.traeCn.lastWatermark, + null, + "the absence contract is not proven - empty asserts no verified window", + ); + assert.equal(cursors.traeCn.sessions.s1.totals.input_tokens, 100, "contribution preserved"); +}); + +test("a partial snapshot (99 valid + 1 malformed) publishes no watermark even with a window", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + await assert.rejects( + parseTraeCnApiIncremental({ + sessions: [sessionRow({ session_id: "bad-row", usage_time: "x" })].concat( + Array.from({ length: 3 }, (_, i) => sessionRow({ session_id: "ok-" + i })), + ), + cursors, + queuePath, + windowStartMs: 1_700_000_000_000, + windowEndMs: 1_700_000_000_000 + 30 * 24 * 3600 * 1000, + }), + /not authoritative/, + ); + assert.equal(fs.existsSync(queuePath), false, "neither bucket rows nor a watermark may land"); + assert.equal(cursors.hourly, undefined, "previous canonical state untouched locally"); +}); test("cursors survive a serialize/parse restart and a later correction", async (t) => { const { dir, queuePath } = tempQueue(); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); From 35df017a83ea6d5a895eb8abb9e90724e5e76c93 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 09:48:14 +0800 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20TRAE=20CN=20watermark=20=E6=94=B6?= =?UTF-8?q?=E7=AA=84=E5=88=B0=E5=8F=AF=E8=AF=81=E6=98=8E=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=20+=20=E9=80=BB=E8=BE=91=20fetch=20=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0(absence 语义矛盾): - contract 实验(trae-cn-contract-evidence-2026-08-17.json)证明从首个数据 bucket 起窗口过滤为确定性完整枚举(E4 精确子集 82/82、E2 历史可寻址、 E1 api_total==rows、E5 closed interval),但"首个数据点之前无返回"无法 与 API 索引边界区分(E3 total=0 不可判定) - watermark 新增 first_covered_hour:ownership 只覆盖 [snapshot 首个数据 bucket, window_end)——非空 snapshot 不再对 未见证历史做 absence 推断压零其他设备数据;该范围回退 legacy - empty payload 不发 watermark 从特判变为同一规则的推论(无首个数据点 → 无覆盖) P1(同 window 双 snapshot 无法判新旧): - watermark 新增 snapshot_verified_at:真实 fetch 时打点一次, append-only queue 逐字重放 → transport retry 不伪造 freshness - owner 排序改为 window_end DESC, window_start DESC, snapshot_verified_at DESC, device_id:同 window 的更新真实 fetch 必胜, device_id 仅承担最终 deterministic tiebreak - ingest fail-closed 校验新字段(window_start <= first_covered < window_end) --- dashboard/edge-patches/tokentracker-ingest.ts | 22 ++- ...20260817120000_account-sync-watermarks.sql | 31 +++- scripts/ops/account-usage-grouped-rpc.sql | 24 ++- src/commands/sync.js | 13 +- src/lib/account-usage-dedup.js | 31 +++- src/lib/rollout.js | 63 ++++++- test/account-usage-dedup.test.js | 154 +++++++++++++++++- test/trae-cn-parser.test.js | 46 ++++++ 8 files changed, 342 insertions(+), 42 deletions(-) diff --git a/dashboard/edge-patches/tokentracker-ingest.ts b/dashboard/edge-patches/tokentracker-ingest.ts index 7f1201682..19aae6f21 100644 --- a/dashboard/edge-patches/tokentracker-ingest.ts +++ b/dashboard/edge-patches/tokentracker-ingest.ts @@ -125,15 +125,31 @@ export default async function (req: Request): Promise { const source = typeof w?.source === "string" ? w.source.trim().toLowerCase() : ""; const start = Date.parse(String(w?.window_start ?? "")); const end = Date.parse(String(w?.window_end ?? "")); + const firstCovered = Date.parse(String(w?.first_covered_hour ?? "")); + const verifiedAt = Date.parse(String(w?.snapshot_verified_at ?? "")); // Fail closed on a malformed watermark: writing a bogus window would - // corrupt cross-device ownership for the whole account. - if (!source || !Number.isFinite(start) || !Number.isFinite(end) || end <= start) { + // corrupt cross-device ownership for the whole account. Coverage must + // start inside the window (the snapshot's first data bucket), and the + // logical verification stamp must be present so owner freshness never + // depends on server receive time. + if ( + !source || + !Number.isFinite(start) || + !Number.isFinite(end) || + end <= start || + !Number.isFinite(firstCovered) || + firstCovered < start || + firstCovered >= end || + !Number.isFinite(verifiedAt) + ) { return json({ error: "Invalid account watermark" }, 400); } watermarks.push({ source, window_start: new Date(start).toISOString(), window_end: new Date(end).toISOString(), + first_covered_hour: new Date(firstCovered).toISOString(), + snapshot_verified_at: new Date(verifiedAt).toISOString(), }); } @@ -203,6 +219,8 @@ export default async function (req: Request): Promise { source: w.source, window_start: w.window_start, window_end: w.window_end, + first_covered_hour: w.first_covered_hour, + snapshot_verified_at: w.snapshot_verified_at, })); const { error: wmErr } = await client.database .from("tokentracker_account_sync_watermarks") diff --git a/migrations/20260817120000_account-sync-watermarks.sql b/migrations/20260817120000_account-sync-watermarks.sql index 5149380f0..8057af57e 100644 --- a/migrations/20260817120000_account-sync-watermarks.sql +++ b/migrations/20260817120000_account-sync-watermarks.sql @@ -44,9 +44,18 @@ CREATE TABLE IF NOT EXISTS public.tokentracker_account_sync_watermarks ( source text NOT NULL, window_start timestamptz NOT NULL, window_end timestamptz NOT NULL, + -- Coverage starts at the snapshot's FIRST data bucket, not window_start: + -- enumeration is only PROVEN from the first observed session onward, so + -- [window_start, first_covered_hour) asserts nothing (legacy dedup). + first_covered_hour timestamptz NOT NULL, + -- Logical fetch time, stamped once per real fetch and replayed verbatim + -- by the append-only queue; a transport retry reuses the original value + -- and can never fake freshness. + snapshot_verified_at timestamptz NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (user_id, device_id, source, window_start, window_end), - CHECK (window_end > window_start) + CHECK (window_end > window_start), + CHECK (first_covered_hour >= window_start AND first_covered_hour < window_end) ); ALTER TABLE public.tokentracker_account_sync_watermarks ENABLE ROW LEVEL SECURITY; @@ -90,22 +99,26 @@ AS $func$ AND h.source = ANY(cfg.account_sources) ), -- Per (user, account source, hour): the watermark with the greatest - -- window_end whose window FULLY CONTAINS the half-hour bucket owns it + -- window_end whose coverage FULLY CONTAINS the half-hour bucket owns it -- (NULL = uncovered, legacy dedup below). Full containment: bucket start - -- >= window_start AND bucket end <= window_end - a partially verified - -- bucket is never claimed. Freshness is window identity only (window_end, - -- then window_start, then device_id): updated_at is the FIRST-upload time - -- of an immutable row and must never participate, so a transport retry of - -- an old watermark cannot steal ownership from a genuinely newer snapshot. + -- >= first_covered_hour AND bucket end <= window_end - a partially + -- verified bucket is never claimed, and neither is a bucket before the + -- snapshot's first data point (enumeration is only proven from there). + -- Freshness is window identity then LOGICAL fetch time (window_end, + -- window_start, snapshot_verified_at, device_id): a genuinely newer fetch + -- of the SAME window beats an older one via snapshot_verified_at, while a + -- transport retry replays the original stamp and cannot steal ownership. + -- updated_at is the FIRST-upload time of an immutable row and never + -- participates. acct_own AS ( SELECT ah.user_id, ah.source, ah.hour_start, (SELECT w.device_id FROM tokentracker_account_sync_watermarks w WHERE w.user_id = ah.user_id AND w.source = ah.source - AND ah.hour_start >= w.window_start + AND ah.hour_start >= w.first_covered_hour AND ah.hour_start + interval '30 minutes' <= w.window_end - ORDER BY w.window_end DESC, w.window_start DESC, w.device_id + ORDER BY w.window_end DESC, w.window_start DESC, w.snapshot_verified_at DESC, w.device_id LIMIT 1) AS owner_device_id FROM acct_hours ah ) diff --git a/scripts/ops/account-usage-grouped-rpc.sql b/scripts/ops/account-usage-grouped-rpc.sql index ce57f364e..5424641de 100644 --- a/scripts/ops/account-usage-grouped-rpc.sql +++ b/scripts/ops/account-usage-grouped-rpc.sql @@ -114,25 +114,31 @@ AS $func$ SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources ), -- Per (account source, hour): the canonical owning device = the watermark - -- with the greatest window_end whose window FULLY CONTAINS the half-hour + -- with the greatest window_end whose coverage FULLY CONTAINS the half-hour -- bucket (NULL when none does, i.e. cursor and any watermark-less or only -- partially-covered history). Full containment: bucket start >= - -- window_start AND bucket end <= window_end - the rolling sync window - -- starts bucket-aligned and ends at floor(now), so the in-progress final - -- bucket is honestly uncovered until a later sync fully contains it. - -- Freshness is window identity only (window_end, window_start, device_id): + -- first_covered_hour AND bucket end <= window_end - the rolling sync + -- window starts bucket-aligned and ends at floor(now), so the in-progress + -- final bucket is honestly uncovered until a later sync fully contains it, + -- and coverage only starts at the snapshot's FIRST data bucket: the API + -- contract probes PROVE enumeration from the first observed session + -- onward, but nothing distinguishes "no rows before it" from an index + -- boundary, so that leading range stays legacy. + -- Freshness is window identity then LOGICAL fetch time (window_end, + -- window_start, snapshot_verified_at, device_id): a genuinely newer fetch + -- of the SAME window wins via snapshot_verified_at while a transport + -- retry replays the original stamp and cannot steal ownership. -- updated_at is the FIRST-upload time of an immutable row and must never - -- participate, so a transport retry of an old watermark cannot steal - -- ownership from a genuinely newer snapshot. + -- participate. acct_own AS ( SELECT ah.hour_start, ah.source, (SELECT w.device_id FROM tokentracker_account_sync_watermarks w WHERE w.user_id = p_user_id AND w.source = ah.source - AND ah.hour_start >= w.window_start + AND ah.hour_start >= w.first_covered_hour AND ah.hour_start + interval '30 minutes' <= w.window_end - ORDER BY w.window_end DESC, w.window_start DESC, w.device_id + ORDER BY w.window_end DESC, w.window_start DESC, w.snapshot_verified_at DESC, w.device_id LIMIT 1) AS owner_device_id FROM ( SELECT DISTINCT h.hour_start, h.source diff --git a/src/commands/sync.js b/src/commands/sync.js index a62fd38df..3318d2ea5 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -3601,8 +3601,11 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { // unique (source, window_start, window_end) — an older window must never // be dropped just because a newer rolling window was appended later, or // historical ownership would be lost exactly when the 30-day window - // slides past a corrected bucket. (The ingest edge caps a batch at 50; - // each sync appends one record, well under the cap.) + // slides past a corrected bucket. first_covered_hour / snapshot_verified_at + // ride along verbatim: the append-only queue replays the ORIGINAL fetch + // stamp on every transport retry, so a retry never fakes logical + // freshness. (The ingest edge caps a batch at 50; each sync appends one + // record, well under the cap.) const watermarkMap = new Map(); let offset = startOffset; let linesRead = 0; @@ -3625,6 +3628,12 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { source: wmSource, window_start: bucket.window_start, window_end: bucket.window_end, + ...(typeof bucket.first_covered_hour === "string" && bucket.first_covered_hour + ? { first_covered_hour: bucket.first_covered_hour } + : {}), + ...(typeof bucket.snapshot_verified_at === "string" && bucket.snapshot_verified_at + ? { snapshot_verified_at: bucket.snapshot_verified_at } + : {}), }); } } diff --git a/src/lib/account-usage-dedup.js b/src/lib/account-usage-dedup.js index 1a814cefd..a051bdfa3 100644 --- a/src/lib/account-usage-dedup.js +++ b/src/lib/account-usage-dedup.js @@ -46,18 +46,26 @@ function parseMs(value) { // half-hour of a rolling window ending at floor(now)) is NOT claimed. const BUCKET_SPAN_MS = 30 * 60 * 1000; -// Mirrors SQL owner selection: full-containment coverage, then -// ORDER BY window_end DESC, window_start DESC, device_id. updated_at never -// participates: watermark rows are immutable history and updated_at is the -// FIRST-upload time, so a transport retry cannot steal ownership from a -// genuinely newer snapshot. +// Mirrors SQL owner selection: full-containment coverage starting at +// first_covered_hour, then ORDER BY window_end DESC, window_start DESC, +// snapshot_verified_at DESC, device_id. A watermark missing +// first_covered_hour / snapshot_verified_at owns nothing (the SQL columns are +// NOT NULL; only the current CLI emits complete rows). snapshot_verified_at +// is the LOGICAL fetch time replayed verbatim by the append-only queue, so a +// genuinely newer fetch of the SAME window wins while a transport retry of +// an old one cannot. updated_at never participates: watermark rows are +// immutable history and updated_at is the FIRST-upload time. function pickOwnerWatermark(watermarks, hourStartMs) { let best = null; for (const wm of watermarks) { const startMs = parseMs(wm.window_start); const endMs = parseMs(wm.window_end); if (startMs === null || endMs === null || endMs <= startMs) continue; - if (hourStartMs < startMs || hourStartMs + BUCKET_SPAN_MS > endMs) continue; + const coveredMs = parseMs(wm.first_covered_hour); + const verifiedMs = parseMs(wm.snapshot_verified_at); + if (coveredMs === null || verifiedMs === null) continue; + if (coveredMs < startMs || coveredMs >= endMs) continue; + if (hourStartMs < coveredMs || hourStartMs + BUCKET_SPAN_MS > endMs) continue; if (!best) { best = wm; continue; @@ -73,6 +81,11 @@ function pickOwnerWatermark(watermarks, hourStartMs) { if (startMs > bestStart) best = wm; continue; } + const bestVerified = parseMs(best.snapshot_verified_at) ?? -Infinity; + if (verifiedMs !== bestVerified) { + if (verifiedMs > bestVerified) best = wm; + continue; + } if (String(wm.device_id) < String(best.device_id)) best = wm; } return best; @@ -111,8 +124,10 @@ function pickLegacyRow(rows) { * model: string, updated_at?: string, total_tokens: number}>} rows - * every device's tokentracker_hourly-shaped rows for account-level sources. * @param {Array<{device_id: string, source: string, window_start: string, - * window_end: string, updated_at?: string}>} watermarks - per-device - * verified sync windows (tokentracker_account_sync_watermarks shape). + * window_end: string, first_covered_hour: string, snapshot_verified_at: + * string, updated_at?: string}>} watermarks - per-device verified sync + * windows (tokentracker_account_sync_watermarks shape; coverage runs from + * first_covered_hour to window_end, NOT from window_start). * @returns {Array} canonical rows - one consistent snapshot per hour. */ function dedupeAccountLevelRows(rows, watermarks = []) { diff --git a/src/lib/rollout.js b/src/lib/rollout.js index b8f59f07d..66af39203 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16619,15 +16619,30 @@ function assertTraeCnBucketCovers(bucketTotals, previousTotals) { // cloud aggregation (account-usage-grouped RPC / leaderboard_hourly_dedup_v2, // mirrored by src/lib/account-usage-dedup.js) uses it to pick, per half-hour // bucket, ONE canonical owning device - the watermark with the greatest -// window_end whose window FULLY CONTAINS the bucket (bucket start >= -// window_start AND bucket end <= window_end; a partially covered bucket is -// never claimed). Watermark rows are IMMUTABLE history: identity is +// window_end whose coverage FULLY CONTAINS the bucket (bucket start >= +// first_covered_hour AND bucket end <= window_end; a partially covered bucket +// is never claimed). Coverage starts at first_covered_hour - the half-hour +// bucket of the snapshot's FIRST session - NOT at window_start: the contract +// probes (2026-08-17, trae-cn-contract-evidence-2026-08-17.json) PROVE +// deterministic window-filtered enumeration from the first observed data +// point onward (exact-subset cross-check, historical rows addressable, +// api_total == rows across 9 pages), but "no row before the first data +// point" cannot be distinguished from an API index/truncation boundary, so +// that leading range asserts nothing and keeps the legacy dedup. This also +// makes the empty-payload no-watermark rule a corollary (no first data +// point -> no coverage) instead of a special case. Watermark rows are +// IMMUTABLE history: identity is // (user_id, device_id, source, window_start, window_end), so an old window // is never overwritten by a newer rolling window - once a device corrected a // historical bucket, that ownership survives the 30-day window sliding past // it (the covering watermark row is still in the table). Cloud-side owner -// freshness is window_end DESC, window_start DESC, device_id - updated_at is -// NOT freshness (a transport retry must not revive a stale snapshot). +// freshness is window_end DESC, window_start DESC, snapshot_verified_at +// DESC, device_id. snapshot_verified_at is the LOGICAL fetch time (stamped +// once per real fetch, replayed verbatim from the append-only queue on +// every transport retry), so a genuinely newer fetch of the SAME window +// beats an older one regardless of device_id, while an old snapshot's late +// retry - reusing its original timestamp - cannot. updated_at (first-upload +// wall time) is NOT freshness. // Appended AFTER the bucket rows of the same sync so the queue ordering // guarantees a device's rows always land before the watermark covering them. // Bucket-row readers skip this record via its kind field (it carries no @@ -16648,11 +16663,24 @@ function isSameTraeCnWatermark(lastWatermark, windowStartMs, windowEndMs) { ); } -async function appendTraeCnSyncWatermark({ queuePath, windowStartMs, windowEndMs }) { +async function appendTraeCnSyncWatermark({ + queuePath, + windowStartMs, + windowEndMs, + firstCoveredMs, + verifiedAtMs, +}) { if ( !Number.isFinite(windowStartMs) || !Number.isFinite(windowEndMs) || - windowEndMs <= windowStartMs + windowEndMs <= windowStartMs || + // Coverage must start inside the verified window (the snapshot's first + // data bucket) and carry a logical verification time; without both the + // record asserts nothing and is not appended. + !Number.isFinite(firstCoveredMs) || + firstCoveredMs < windowStartMs || + firstCoveredMs >= windowEndMs || + !Number.isFinite(verifiedAtMs) ) { return false; } @@ -16661,6 +16689,8 @@ async function appendTraeCnSyncWatermark({ queuePath, windowStartMs, windowEndMs source: TRAE_CN_SOURCE, window_start: new Date(windowStartMs).toISOString(), window_end: new Date(windowEndMs).toISOString(), + first_covered_hour: new Date(firstCoveredMs).toISOString(), + snapshot_verified_at: new Date(verifiedAtMs).toISOString(), }); await fs.appendFile(queuePath, record + "\n", "utf8"); return true; @@ -16673,6 +16703,7 @@ async function parseTraeCnApiIncremental({ onProgress, windowStartMs, windowEndMs, + snapshotVerifiedAtMs, } = {}) { if (!Array.isArray(sessions)) { throw new Error("Trae CN sessions must be an array."); @@ -16835,7 +16866,23 @@ async function parseTraeCnApiIncremental({ // the empty-payload branch, re-asserting the SAME window as the last sync // is redundant - skip it so a fixed-now no-change sync stays byte-identical. if (!isSameTraeCnWatermark(traeCnState.lastWatermark, windowStartMs, windowEndMs)) { - const watermarkAppended = await appendTraeCnSyncWatermark({ queuePath, windowStartMs, windowEndMs }); + // Coverage starts at the snapshot's FIRST data bucket (min bucketStart): + // enumeration is only proven from the first observed session onward. + let firstCoveredMs = Infinity; + for (const id of sessionIds) { + const bucketMs = Date.parse(bySession.get(id).bucketStart); + if (Number.isFinite(bucketMs) && bucketMs < firstCoveredMs) firstCoveredMs = bucketMs; + } + // snapshot_verified_at is stamped once per real fetch; the append-only + // queue replays it verbatim, so transport retries never fake freshness. + const verifiedAtMs = Number.isFinite(snapshotVerifiedAtMs) ? snapshotVerifiedAtMs : Date.now(); + const watermarkAppended = await appendTraeCnSyncWatermark({ + queuePath, + windowStartMs, + windowEndMs, + firstCoveredMs, + verifiedAtMs, + }); if (watermarkAppended) { traeCnState.lastWatermark = watermarkWindow(windowStartMs, windowEndMs); } diff --git a/test/account-usage-dedup.test.js b/test/account-usage-dedup.test.js index 74ff37e1a..2cef79928 100644 --- a/test/account-usage-dedup.test.js +++ b/test/account-usage-dedup.test.js @@ -55,12 +55,20 @@ function row(deviceId, hourStart, model, totalTokens) { }; } -function watermark(deviceId, start, end, updatedAt) { +function watermark(deviceId, start, end, updatedAt, opts = {}) { return { device_id: deviceId, source: "trae-cn", window_start: start, window_end: end, + // Coverage starts at the snapshot's FIRST data bucket. Defaults to + // window_start (data spanning the whole window); tests for the narrowed + // contract pass a later first_covered_hour via opts. + first_covered_hour: opts.firstCovered ?? start, + // Logical fetch time (stamped once per real fetch, replayed on + // retry). Deliberately independent of updated_at: updated_at is the + // TRANSPORT first-upload time and must never masquerade as freshness. + snapshot_verified_at: opts.verifiedAt ?? "2027-01-15T00:00:00.000Z", updated_at: updatedAt || "2027-01-15T00:00:00.000Z", }; } @@ -320,8 +328,13 @@ test("account_usage_grouped RPC implements watermark ownership with the same tie assert.match(sql, /tokentracker_account_sync_watermarks/, "RPC reads the watermark table"); assert.match( sql, - /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.device_id/, - "owner tiebreak must be window_end DESC, window_start DESC, device_id - same as pickOwnerWatermark; updated_at never participates", + /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.snapshot_verified_at DESC, w\.device_id/, + "owner tiebreak must be window_end DESC, window_start DESC, snapshot_verified_at DESC, device_id - same as pickOwnerWatermark; updated_at never participates", + ); + assert.match( + sql, + /ah\.hour_start >= w\.first_covered_hour/, + "coverage starts at the snapshot's first data bucket, not window_start", ); assert.match( sql, @@ -362,9 +375,19 @@ test("leaderboard_hourly_dedup_v2 migration mirrors the RPC ownership semantics // The leaderboard function must agree with account_usage_grouped. assert.match( sql, - /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.device_id/, + /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.snapshot_verified_at DESC, w\.device_id/, "leaderboard owner tiebreak must equal the account RPC tiebreak", ); + assert.match( + sql, + /ah\.hour_start >= w\.first_covered_hour/, + "leaderboard coverage also starts at first_covered_hour", + ); + assert.match( + sql, + /first_covered_hour timestamptz NOT NULL/, + "coverage start is a required column (absence cannot be faked)", + ); assert.match(sql, /ARRAY\['cursor', 'trae-cn'\]::text\[\] AS account_sources/); assert.match(sql, /o\.owner_device_id = h\.device_id/); assert.match(sql, /o\.owner_device_id IS NULL/); @@ -552,3 +575,126 @@ test("H. an old watermark re-delivered late never steals ownership from a newer const deduped = dedupeAccountLevelRows(rows, watermarks); assert.equal(sumTotalTokens(deduped), 60, "window identity, not updated_at, decides ownership"); }); + +// --------------------------------------------------------------------------- +// I. P0 absence narrowing: enumeration is only PROVEN from the snapshot's +// FIRST data bucket (first_covered_hour) onward. A non-empty snapshot must +// NOT assert authoritative zero for hours BEFORE its first observed session +// (contract probe 2026-08-17: "no rows before the first data point" cannot +// be distinguished from an API index boundary). +// --------------------------------------------------------------------------- +test("I1. hours before the snapshot's first data bucket are NOT owned (no absence inference)", () => { + // Old device B holds 10:00/model-a=100; new device A's non-empty snapshot + // only contains 11:00/model-b=20 (first data at 11:00). The P0 review case: + // A must NOT zero out 10:00 merely because its response lacked the session. + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H11, "model-b", 20), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { + firstCovered: H11, // A's snapshot starts at 11:00 + verifiedAt: "2027-01-16T00:00:00.000Z", // A fetched after B + }), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + const byModel = totalByModel(deduped); + assert.equal(byModel.get("model-a"), 100, "10:00 stays legacy: B's row is kept, not zeroed"); + assert.equal(byModel.get("model-b"), 20, "11:00 is owned by FRESH"); +}); + +test("I2. the first covered bucket itself IS owned (data anchors enumeration from there)", () => { + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 60), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { + firstCovered: H10, // FRESH's first data bucket is exactly H10 + verifiedAt: "2027-01-16T00:00:00.000Z", // FRESH fetched after OLD + }), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "downward correction still lands on the first covered bucket"); +}); + +test("I3. a watermark missing first_covered_hour / snapshot_verified_at owns nothing", () => { + const rows = [row(OLD, H10, "model-a", 100)]; + const wm = watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end); + const broken1 = { ...wm, first_covered_hour: undefined }; + const broken2 = { ...wm, snapshot_verified_at: undefined }; + for (const broken of [broken1, broken2]) { + const deduped = dedupeAccountLevelRows(rows, [broken]); + assert.equal(sumTotalTokens(deduped), 100, "incomplete watermark rows fall back to legacy dedup"); + } +}); + +test("I4. first_covered_hour outside the window is rejected (owns nothing)", () => { + const rows = [row(OLD, H10, "model-a", 100)]; + const watermarks = [ + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { + firstCovered: "2027-01-20T00:00:00.000Z", // after window_end: invalid + }), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 100, "invalid coverage rows assert nothing"); +}); + +// --------------------------------------------------------------------------- +// J. P1 same-window logical freshness: two devices can produce the SAME +// window identity (same-second floor(now)) with different real fetch times. +// snapshot_verified_at - the logical fetch stamp replayed verbatim by the +// append-only queue - decides; device_id is only the final tiebreak. +// --------------------------------------------------------------------------- +test("J1. same window: the genuinely newer fetch wins regardless of device_id", () => { + // OLD fetches at T1 (100); FRESH fetches at T2 > T1 after a correction (60). + // OLD has the smaller device_id, which previously decided the tie - wrongly. + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 60), + ]; + const watermarks = [ + watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { + verifiedAt: "2027-01-10T10:00:00.000Z", + }), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { + verifiedAt: "2027-01-10T10:05:00.000Z", + }), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "newer logical fetch owns the hour, not the smaller device_id"); +}); + +test("J2. a late transport retry of the older snapshot cannot steal ownership", () => { + // OLD verified at T1; FRESH verified at T2 > T1; OLD's queue record is only + // uploaded at T3 > T2 (network retry). The retry replays the ORIGINAL T1 + // stamp - and updated_at (upload time) never participates - so FRESH keeps + // the hour. + const rows = [ + row(OLD, H10, "model-a", 100), + row(FRESH, H10, "model-a", 60), + ]; + const watermarks = [ + // OLD re-delivered at T3: original verifiedAt T1, late updated_at T3. + watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-11T00:00:00.000Z", { + verifiedAt: "2027-01-10T10:00:00.000Z", + }), + watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-10T10:30:00.000Z", { + verifiedAt: "2027-01-10T10:05:00.000Z", + }), + ]; + const deduped = dedupeAccountLevelRows(rows, watermarks); + assert.equal(sumTotalTokens(deduped), 60, "FRESH (verified T2) still owns despite OLD's T3 re-delivery"); +}); + +test("J3. replaying the same watermark record is idempotent (no new logical version)", () => { + const rows = [row(OLD, H10, "model-a", 100), row(FRESH, H10, "model-a", 60)]; + const wm = watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { + verifiedAt: "2027-01-10T10:05:00.000Z", + }); + const once = dedupeAccountLevelRows(rows, [wm]); + const thrice = dedupeAccountLevelRows(rows, [wm, { ...wm }, { ...wm }]); + assert.deepEqual(once, thrice, "duplicate records of one snapshot do not change ownership"); +}); diff --git a/test/trae-cn-parser.test.js b/test/trae-cn-parser.test.js index b3fb76925..6866f63a0 100644 --- a/test/trae-cn-parser.test.js +++ b/test/trae-cn-parser.test.js @@ -657,3 +657,49 @@ test("window prune drops pre-window sessions monotonically and never rewinds", a assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs, "watermark never rewinds"); assert.ok(cursors.traeCn.sessions["new-s"]); }); + +test("watermark record carries first_covered_hour + snapshot_verified_at (coverage + logical freshness)", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const T_LATE = 1_700_003_500; // 23:05:00Z -> 23:00 bucket (the FIRST data bucket here is 22:00 from s1) + const verifiedAt = 1_700_010_000_000; + await parseTraeCnApiIncremental({ + sessions: [sessionRow(), sessionRow({ session_id: "s2", usage_time: T_LATE })], + cursors, + queuePath, + windowStartMs: 1_699_000_000_000, + windowEndMs: 1_700_010_000_000, + snapshotVerifiedAtMs: verifiedAt, + }); + const wm = queueRows(queuePath).find((r) => r.kind === "account_sync_watermark"); + assert.ok(wm, "watermark appended for a non-empty snapshot"); + assert.equal(wm.first_covered_hour, B1, "coverage starts at the snapshot's FIRST data bucket (22:00)"); + assert.equal( + wm.snapshot_verified_at, + new Date(verifiedAt).toISOString(), + "logical fetch stamp is the injected real-fetch time", + ); + assert.ok(Date.parse(wm.first_covered_hour) >= 1_699_000_000_000, "coverage start within the window"); +}); + +test("transport retry replays the ORIGINAL watermark record verbatim (append-only queue)", async (t) => { + const { dir, queuePath } = tempQueue(); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const cursors = {}; + const verifiedAt = 1_700_010_000_000; + await parseTraeCnApiIncremental({ + sessions: [sessionRow()], + cursors, + queuePath, + windowStartMs: 1_699_000_000_000, + windowEndMs: 1_700_010_000_000, + snapshotVerifiedAtMs: verifiedAt, + }); + const first = fs.readFileSync(queuePath, "utf8").trim().split("\n").at(-1); + // A "retry" re-reads the same append-only record: the bytes - including + // snapshot_verified_at - are identical, so no new logical version exists. + const replayed = fs.readFileSync(queuePath, "utf8").trim().split("\n").at(-1); + assert.equal(replayed, first, "re-delivery carries the original logical fetch stamp"); + assert.ok(JSON.parse(first).snapshot_verified_at); +}); From ce9c9af85dad289f1303e6324de6b9bfe14b4fd4 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 10:47:24 +0800 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20TRAE=20CN=20=E8=B7=A8=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=20correction=20=E6=94=B9=E4=B8=BA=20session-level=20c?= =?UTF-8?q?anonical=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit first_covered_hour watermark 无法表达 bucket migration(fresh device 的 first data bucket 无法安全 reclaim 旧 hour,10:00→10:30 双计 200)。 session identity 已 PROVEN(137/137 跨抓取稳定、修正保留 id、无重复), canonical truth 改为 (user_id, source, session_id) 整行替换: downward / model migration / bucket migration 收敛为同一 LWW upsert (严格 > stamp,retry 幂等)。absence 保持 NOT PROVEN:不删除任何 session。watermark 表/逻辑退出 correctness path(migration 未部署, 直接改为最终设计,不留兼容 baggage)。R1-R6 走真实 parser→queue→LWW→聚合 链路。 --- dashboard/edge-patches/tokentracker-ingest.ts | 137 +-- .../20260817120000_account-session-states.sql | 264 +++++ ...20260817120000_account-sync-watermarks.sql | 217 ---- scripts/ops/account-usage-grouped-rpc.sql | 133 +-- src/commands/sync.js | 90 +- src/lib/account-usage-dedup.js | 274 +++-- src/lib/local-api.js | 7 +- src/lib/rollout.js | 227 ++-- test/account-source-parity.test.js | 12 +- test/account-usage-dedup.test.js | 971 +++++++----------- test/sync-trae-cn.test.js | 57 +- test/trae-cn-parser.test.js | 52 +- 12 files changed, 1149 insertions(+), 1292 deletions(-) create mode 100644 migrations/20260817120000_account-session-states.sql delete mode 100644 migrations/20260817120000_account-sync-watermarks.sql diff --git a/dashboard/edge-patches/tokentracker-ingest.ts b/dashboard/edge-patches/tokentracker-ingest.ts index 19aae6f21..71db980f4 100644 --- a/dashboard/edge-patches/tokentracker-ingest.ts +++ b/dashboard/edge-patches/tokentracker-ingest.ts @@ -106,54 +106,74 @@ export default async function (req: Request): Promise { ? body.hourly : []; - // Account-sync watermarks (kind: "account_sync_watermark" queue records) - // assert the closed window this device just verified against an - // account-level source API (trae-cn). The cloud aggregation - // (account_usage_grouped / leaderboard_hourly_dedup_v2) picks, per hour, - // the freshest covering watermark device as the canonical owner, so a - // newer snapshot displaces stale tuples from devices holding older - // versions. A watermark-only batch is a meaningful upload (e.g. a - // verified-empty window) and must not fall into the no-buckets reject. - const rawWatermarks = Array.isArray(body.account_watermarks) - ? body.account_watermarks - : []; - if (rawWatermarks.length > 50) { - return json({ error: "Too many account watermarks (max 50)" }, 400); + // Account session states (kind: "account_session_state" queue records) + // are canonical observations of one provider-side session (trae-cn): + // identity is (user, source, session_id) - device_id is NOT identity, so + // every device of the account converges onto the same row via the LWW + // upsert rpc. Downward / model / bucket corrections are one whole-row + // replace; absence never deletes (contract NOT PROVEN). A states-only + // batch is a meaningful upload and must not fall into the no-buckets + // reject. + const rawStates = Array.isArray(body.account_session_states) ? body.account_session_states : []; + if (rawStates.length > 500) { + return json({ error: "Too many account session states (max 500)" }, 400); } - const watermarks = []; - for (const w of rawWatermarks) { - const source = typeof w?.source === "string" ? w.source.trim().toLowerCase() : ""; - const start = Date.parse(String(w?.window_start ?? "")); - const end = Date.parse(String(w?.window_end ?? "")); - const firstCovered = Date.parse(String(w?.first_covered_hour ?? "")); - const verifiedAt = Date.parse(String(w?.snapshot_verified_at ?? "")); - // Fail closed on a malformed watermark: writing a bogus window would - // corrupt cross-device ownership for the whole account. Coverage must - // start inside the window (the snapshot's first data bucket), and the - // logical verification stamp must be present so owner freshness never - // depends on server receive time. + const TOKEN_KEYS = [ + "input_tokens", + "output_tokens", + "cached_input_tokens", + "cache_creation_input_tokens", + "reasoning_output_tokens", + "total_tokens", + ] as const; + const states: Record[] = []; + for (const r of rawStates) { + const source = typeof r?.source === "string" ? r.source.trim().toLowerCase() : ""; + const sessionId = typeof r?.session_id === "string" ? r.session_id.trim() : ""; + const model = typeof r?.model === "string" ? r.model.trim() : ""; + const bucketStart = Date.parse(String(r?.bucket_start ?? "")); + const verifiedAt = Date.parse(String(r?.snapshot_verified_at ?? "")); + const tokens: Record = {}; + let tokensOk = true; + for (const k of TOKEN_KEYS) { + const v = Number(r?.[k]); + if (!Number.isSafeInteger(v) || v < 0) tokensOk = false; + else tokens[k] = v; + } + // Fail closed on a malformed state record: a bogus canonical session row + // would corrupt the account's cross-device truth. The parser guarantees + // total = input + cached + cacheCreation + output; enforce it here too. if ( - !source || - !Number.isFinite(start) || - !Number.isFinite(end) || - end <= start || - !Number.isFinite(firstCovered) || - firstCovered < start || - firstCovered >= end || - !Number.isFinite(verifiedAt) + source !== "trae-cn" || + !sessionId || + sessionId.length > 256 || + !model || + model.length > 200 || + !Number.isFinite(bucketStart) || + !Number.isFinite(verifiedAt) || + !tokensOk || + tokens.total_tokens !== + tokens.input_tokens + tokens.cached_input_tokens + tokens.cache_creation_input_tokens + tokens.output_tokens ) { - return json({ error: "Invalid account watermark" }, 400); + return json({ error: "Invalid account session state" }, 400); } - watermarks.push({ + states.push({ source, - window_start: new Date(start).toISOString(), - window_end: new Date(end).toISOString(), - first_covered_hour: new Date(firstCovered).toISOString(), + session_id: sessionId, + model, + bucket_start: new Date(bucketStart).toISOString(), snapshot_verified_at: new Date(verifiedAt).toISOString(), + ...tokens, }); } - - if ((!Array.isArray(buckets) || buckets.length === 0) && watermarks.length === 0) { + // Same-session collisions inside one batch would make the LWW upsert's + // ON CONFLICT affect a row twice (Postgres error). Keep the LAST record: + // the append-only CLI queue writes later records with newer stamps. + const stateMap = new Map>(); + for (const r of states) stateMap.set(r.source + "|" + r.session_id, r); + const stateRows = Array.from(stateMap.values()); + + if ((!Array.isArray(buckets) || buckets.length === 0) && stateRows.length === 0) { return json({ error: "No usage buckets provided" }, 400); } if (buckets.length > 500) { @@ -205,30 +225,19 @@ export default async function (req: Request): Promise { if (upsertErr) return json({ error: upsertErr.message }, 500); - if (watermarks.length > 0) { - // Insert AFTER the bucket rows of the same upload landed, so a watermark - // never covers hours whose owning rows failed to write. Watermark rows - // are IMMUTABLE history keyed by the full window identity - // (user_id, device_id, source, window_start, window_end): a re-delivered - // watermark (queue/upload retry of the SAME verified window) is a no-op - // (DO NOTHING), so updated_at stays the FIRST-upload time and a transport - // retry can never masquerade as a fresher snapshot in owner selection. - const wmRows = watermarks.map((w) => ({ - user_id: userId, - device_id: deviceId, - source: w.source, - window_start: w.window_start, - window_end: w.window_end, - first_covered_hour: w.first_covered_hour, - snapshot_verified_at: w.snapshot_verified_at, - })); - const { error: wmErr } = await client.database - .from("tokentracker_account_sync_watermarks") - .upsert(wmRows, { - onConflict: "user_id,device_id,source,window_start,window_end", - ignoreDuplicates: true, - }); - if (wmErr) return json({ error: wmErr.message }, 500); + if (stateRows.length > 0) { + // Write AFTER the bucket rows of the same upload landed, so canonical + // session state never advances for an upload whose device-level rows + // failed. The rpc applies a STRICTLY-newer LWW guard + // (EXCLUDED.snapshot_verified_at > stored): replays are idempotent and + // a transport retry of an older observation can never displace a newer + // one. Equal-stamp conflicts keep the first-applied row (stable under + // retries). + const { error: stateErr } = await client.database.rpc( + "tokentracker_upsert_account_session_states", + { p_user_id: userId, p_states: stateRows }, + ); + if (stateErr) return json({ error: stateErr.message }, 500); } return json({ ok: true, inserted: rows.length, skipped: 0 }); diff --git a/migrations/20260817120000_account-session-states.sql b/migrations/20260817120000_account-session-states.sql new file mode 100644 index 000000000..7b8633b72 --- /dev/null +++ b/migrations/20260817120000_account-session-states.sql @@ -0,0 +1,264 @@ +-- Account-level sources serve CORRECTABLE account snapshots, not append-only +-- machine logs: TRAE CN (trae-cn) can revise a session's totals downward, +-- move it to another model, or shift its half-hour bucket. Deduplicating +-- those corrections at the HOUR level cannot work: a fresh device's snapshot +-- starts at its first observed session, so an hour-level owner can never +-- safely displace a stale tuple for an hour the fresh snapshot has no data +-- for (that would require an unproven "absent = deleted" contract), and a +-- bucket migration (10:00 -> 10:30) leaves the old hour stranded. +-- +-- Fix: canonical truth at the SESSION level. TRAE's usage API carries a +-- stable session_id (PROVEN 2026-08-17: 137/137 sessions persisted across two +-- independent fetches with 0 disappearances; one session was revised upward +-- mid-window and KEPT its session_id; account responses have no duplicate +-- ids; cross-window cross-checks return exact subsets). The CLI parser +-- (src/lib/rollout.js) emits one queue record per CHANGED session +-- (kind: "account_session_state"); the ingest edge upserts them here via +-- tokentracker_upsert_account_session_states(). Identity is +-- (user_id, source, session_id) - device_id is NOT part of the identity: the +-- API request carries no device discriminator, so every device of the +-- account observes the SAME server-side session namespace. +-- +-- Three corrections become ONE whole-row replace: +-- downward S tokens 100 -> 60 row replaced, total 60 +-- model S model A -> B row replaced, only B=100 remains +-- bucket S bucket 10:00 -> 10:30 row replaced, only 10:30=100 +-- A fresh device with no cursor history simply uploads the sessions it sees; +-- the upsert's LWW guard reconciles versions across devices. +-- +-- Absence is NOT PROVEN to mean deletion (see the parser's empty-payload +-- note): nothing here ever deletes a session row. Corrections are explicit +-- observations only. +-- +-- Freshness: the API exposes NO provider-side ordering signal (headers carry +-- only CDN trace ids; rows have no revision/updated_at - probed 2026-08-17). +-- snapshot_verified_at is the CLIENT fetch stamp, set once per real fetch +-- and replayed verbatim by the append-only queue. The upsert applies an +-- observation only when its stamp is STRICTLY NEWER (>) than the stored +-- one, so a transport retry of an old observation is a no-op, and the same +-- observation replayed is idempotent. This is best-effort cross-device +-- ordering, NOT strict correctness: two devices with skewed clocks can +-- mis-order two conflicting observations of one session (residual risk, +-- bounded by the skew; observations of the same session converge because +-- they reflect one server-side state). +-- +-- Semantics mirrored by src/lib/account-usage-dedup.js and pinned by +-- test/account-usage-dedup.test.js. Apply BEFORE re-deploying the updated +-- scripts/ops/account-usage-grouped-rpc.sql and the ingest edge (both read / +-- write this table). +-- Rollback: DROP TABLE tokentracker_account_session_states; DROP FUNCTION +-- tokentracker_upsert_account_session_states(uuid, jsonb); and re-apply the +-- previous leaderboard_hourly_dedup_v2 definition. + +CREATE TABLE IF NOT EXISTS public.tokentracker_account_session_states ( + user_id uuid NOT NULL, + source text NOT NULL, + -- Stable provider-side session identity (TRAE session_id). Not a device + -- attribute: all devices of one account observe the same namespace. + session_id text NOT NULL, + model text NOT NULL, + -- Canonical UTC half-hour bucket of this session's CURRENT placement. + bucket_start timestamptz NOT NULL, + input_tokens bigint NOT NULL, + output_tokens bigint NOT NULL, + cached_input_tokens bigint NOT NULL, + cache_creation_input_tokens bigint NOT NULL, + reasoning_output_tokens bigint NOT NULL, + total_tokens bigint NOT NULL, + -- Client logical fetch stamp (see the freshness note above). Ops metadata + -- only - updated_at (first server write time) never participates in + -- correctness. + snapshot_verified_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, source, session_id), + CHECK ( + total_tokens = + input_tokens + cached_input_tokens + cache_creation_input_tokens + output_tokens + ), + CHECK ( + input_tokens >= 0 AND output_tokens >= 0 AND cached_input_tokens >= 0 + AND cache_creation_input_tokens >= 0 AND reasoning_output_tokens >= 0 + AND total_tokens >= 0 + ) +); + +ALTER TABLE public.tokentracker_account_session_states ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.tokentracker_account_session_states FROM PUBLIC, anon, authenticated; +GRANT ALL ON public.tokentracker_account_session_states TO project_admin; + +-- Aggregation reads group by (user, source, bucket). +CREATE INDEX IF NOT EXISTS tokentracker_account_session_states_bucket_idx + ON public.tokentracker_account_session_states (user_id, source, bucket_start); + +-- Batch LWW upsert used by the ingest edge (the SDK's upsert cannot express +-- a conditional DO UPDATE). SECURITY DEFINER with a locked search_path; the +-- edge calls it with the service-role token after authenticating the device. +-- A strict ">" guard makes replay idempotent and prevents a transport retry +-- of an older observation from displacing a newer one; conflicting +-- observations with EQUAL stamps keep the first-applied row (stable under +-- retries). +CREATE OR REPLACE FUNCTION public.tokentracker_upsert_account_session_states( + p_user_id uuid, + p_states jsonb +) RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER SET search_path = public +AS $fn$ +DECLARE + v_applied integer; +BEGIN + INSERT INTO tokentracker_account_session_states AS t + (user_id, source, session_id, model, bucket_start, + input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, reasoning_output_tokens, total_tokens, + snapshot_verified_at) + SELECT + p_user_id, x.source, x.session_id, x.model, x.bucket_start, + x.input_tokens, x.output_tokens, x.cached_input_tokens, + x.cache_creation_input_tokens, x.reasoning_output_tokens, x.total_tokens, + x.snapshot_verified_at + FROM jsonb_to_recordset(p_states) AS x( + source text, session_id text, model text, bucket_start timestamptz, + input_tokens bigint, output_tokens bigint, cached_input_tokens bigint, + cache_creation_input_tokens bigint, reasoning_output_tokens bigint, + total_tokens bigint, snapshot_verified_at timestamptz) + ON CONFLICT (user_id, source, session_id) DO UPDATE SET + model = EXCLUDED.model, + bucket_start = EXCLUDED.bucket_start, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cached_input_tokens = EXCLUDED.cached_input_tokens, + cache_creation_input_tokens = EXCLUDED.cache_creation_input_tokens, + reasoning_output_tokens = EXCLUDED.reasoning_output_tokens, + total_tokens = EXCLUDED.total_tokens, + snapshot_verified_at = EXCLUDED.snapshot_verified_at, + updated_at = now() + WHERE EXCLUDED.snapshot_verified_at > t.snapshot_verified_at; + + GET DIAGNOSTICS v_applied = ROW_COUNT; + RETURN v_applied; +END +$fn$; + +REVOKE ALL ON FUNCTION public.tokentracker_upsert_account_session_states(uuid, jsonb) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.tokentracker_upsert_account_session_states(uuid, jsonb) + TO project_admin; + +-- Leaderboard: account-level sources now aggregate from SESSION STATES (the +-- canonical cross-device truth), machine-level sources keep the cluster +-- dedup. trae-cn hourly rows (per-device local reconciliations) never enter +-- the leaderboard - the session-state branch replaces both the previous +-- watermark-owner branch and the legacy MAX fallback for trae-cn. 'cursor' +-- (identical rows across devices, no session identity) keeps the legacy +-- whole-row MAX dedup. Historical rollup days self-heal through the existing +-- 7-day repair loop in leaderboard_rollup_daily_advance_v2; an immediate +-- full rebuild is optional (call leaderboard_rollup_daily_replace_v2 for the +-- affected range). +CREATE OR REPLACE FUNCTION public.leaderboard_hourly_dedup_v2( + p_from timestamptz, + p_to timestamptz +) RETURNS TABLE ( + user_id uuid, + source text, + model text, + hour_start timestamptz, + total_tokens bigint, + input_tokens bigint, + output_tokens bigint, + cached_input_tokens bigint, + cache_creation_input_tokens bigint, + reasoning_output_tokens bigint +) +LANGUAGE sql STABLE +AS $func$ + WITH cfg AS ( + SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources + ) + -- Deduplicate device-id drift/replays inside one physical machine cluster, + -- then add genuinely distinct machines for the same user/hour/model. + SELECT mac.user_id, mac.source, mac.model, mac.hour_start, + SUM(mac.total_tokens)::bigint AS total_tokens, + SUM(mac.input_tokens)::bigint AS input_tokens, + SUM(mac.output_tokens)::bigint AS output_tokens, + SUM(mac.cached_input_tokens)::bigint AS cached_input_tokens, + SUM(mac.cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, + SUM(mac.reasoning_output_tokens)::bigint AS reasoning_output_tokens + FROM ( + SELECT DISTINCT ON ( + h.user_id, + COALESCE(dm.machine_cluster_id, h.device_id::text), + h.source, + h.model, + h.hour_start + ) + h.user_id, + COALESCE(dm.machine_cluster_id, h.device_id::text) AS machine_cluster_id, + h.source, h.model, h.hour_start, + h.total_tokens::bigint AS total_tokens, + h.input_tokens::bigint AS input_tokens, + h.output_tokens::bigint AS output_tokens, + h.cached_input_tokens::bigint AS cached_input_tokens, + h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, + h.reasoning_output_tokens::bigint AS reasoning_output_tokens + FROM tokentracker_hourly h + CROSS JOIN cfg + JOIN tokentracker_devices d + ON d.id = h.device_id AND d.revoked_at IS NULL + LEFT JOIN tokentracker_device_machine dm + ON dm.device_id = h.device_id + WHERE h.hour_start >= p_from AND h.hour_start < p_to + AND NOT (h.source = ANY(cfg.account_sources)) + ORDER BY + h.user_id, + COALESCE(dm.machine_cluster_id, h.device_id::text), + h.source, + h.model, + h.hour_start, + h.total_tokens DESC, + h.updated_at DESC + ) mac + GROUP BY mac.user_id, mac.source, mac.model, mac.hour_start + + UNION ALL + + -- 'cursor' (account-level but with NO stable session identity): rows are + -- identical across devices, so the legacy whole-row MAX pick per + -- (user, hour, source, model) dedups them. + SELECT acct.user_id, acct.source, acct.model, acct.hour_start, + acct.total_tokens, acct.input_tokens, acct.output_tokens, + acct.cached_input_tokens, acct.cache_creation_input_tokens, + acct.reasoning_output_tokens + FROM ( + SELECT DISTINCT ON (h.user_id, h.source, h.model, h.hour_start) + h.user_id, h.source, h.model, h.hour_start, + h.total_tokens::bigint AS total_tokens, + h.input_tokens::bigint AS input_tokens, + h.output_tokens::bigint AS output_tokens, + h.cached_input_tokens::bigint AS cached_input_tokens, + h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, + h.reasoning_output_tokens::bigint AS reasoning_output_tokens + FROM tokentracker_hourly h + WHERE h.hour_start >= p_from AND h.hour_start < p_to + AND h.source = 'cursor' + ORDER BY h.user_id, h.source, h.model, h.hour_start, h.total_tokens DESC, h.updated_at DESC + ) acct + + UNION ALL + + -- trae-cn: canonical account truth aggregated from session states. Every + -- device's observations of the same session collapsed to ONE row by the + -- LWW upsert; corrections (downward / model / bucket) are already + -- reflected because each session exists exactly once. + SELECT s.user_id, s.source, s.model, s.bucket_start AS hour_start, + SUM(s.total_tokens)::bigint AS total_tokens, + SUM(s.input_tokens)::bigint AS input_tokens, + SUM(s.output_tokens)::bigint AS output_tokens, + SUM(s.cached_input_tokens)::bigint AS cached_input_tokens, + SUM(s.cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, + SUM(s.reasoning_output_tokens)::bigint AS reasoning_output_tokens + FROM tokentracker_account_session_states s + WHERE s.bucket_start >= p_from AND s.bucket_start < p_to + AND s.source = 'trae-cn' + GROUP BY s.user_id, s.source, s.model, s.bucket_start +$func$; diff --git a/migrations/20260817120000_account-sync-watermarks.sql b/migrations/20260817120000_account-sync-watermarks.sql deleted file mode 100644 index 8057af57e..000000000 --- a/migrations/20260817120000_account-sync-watermarks.sql +++ /dev/null @@ -1,217 +0,0 @@ --- Account-level sources serve CORRECTABLE account snapshots, not append-only --- machine logs: TRAE CN (trae-cn) can revise a session's totals downward, --- move it to another model, or shift its time bucket. Each device reconciles --- those corrections against its own cursor state, but two devices can hold --- different snapshot VERSIONS of the same account hour, and the previous --- cloud aggregation (per-(hour, model) MAX across devices) stitches --- incompatible versions together: --- downward correction old device H/A=100, fresh device H/A=60 -> 100 (should be 60) --- model migration old device H/A=100, fresh device H/B=100 -> 200 (should be 100) --- bucket migration old device 10:00/A=100, fresh 10:30/A=100 -> 200 (should be 100) --- --- Fix: every successful account-source sync appends a watermark record to the --- CLI queue (kind: account_sync_watermark; src/lib/rollout.js) right after --- the reconciled bucket rows; the ingest edge upserts it here. An hour --- covered by a watermark is OWNED by the freshest covering watermark's --- device: only that device's rows for the hour count (all models; a missing --- model is 0), so a fresher snapshot displaces stale tuples from devices --- that never saw the correction — including displacement by a brand-new --- device with no prior cursor state, because ownership is asserted per hour --- RANGE, not per tuple. Hours covered by no watermark (cursor, or trae-cn --- history older than every verified window) keep the legacy whole-row MAX --- dedup, so watermark-less sources do not regress. --- --- Semantics mirrored by src/lib/account-usage-dedup.js and pinned by --- test/account-usage-dedup.test.js. Apply BEFORE re-deploying the updated --- scripts/ops/account-usage-grouped-rpc.sql (that RPC reads this table). --- Rollback: DROP TABLE tokentracker_account_sync_watermarks; and re-apply the --- previous leaderboard_hourly_dedup_v2 definition. - --- Watermark rows are IMMUTABLE HISTORY: identity is the full window --- (user_id, device_id, source, window_start, window_end). A device's newer --- rolling window INSERTS a new row; it never overwrites the old one. This is --- what keeps historical ownership stable: once device B displaced device A's --- stale tuple for hour H, B's covering watermark row stays in the table even --- after B's 30-day window slides past H, so H never falls back to the legacy --- MAX dedup and the correction cannot resurrect. Growth is one row per --- verified sync per device (~1.5k rows/device-year at 4 syncs/day) - cheap --- for the owner lookup below; semantic-preserving compaction (merging --- overlapping windows of the SAME device when coverage is unaffected) is a --- possible future optimization, not needed at this scale. -CREATE TABLE IF NOT EXISTS public.tokentracker_account_sync_watermarks ( - user_id uuid NOT NULL, - device_id uuid NOT NULL, - source text NOT NULL, - window_start timestamptz NOT NULL, - window_end timestamptz NOT NULL, - -- Coverage starts at the snapshot's FIRST data bucket, not window_start: - -- enumeration is only PROVEN from the first observed session onward, so - -- [window_start, first_covered_hour) asserts nothing (legacy dedup). - first_covered_hour timestamptz NOT NULL, - -- Logical fetch time, stamped once per real fetch and replayed verbatim - -- by the append-only queue; a transport retry reuses the original value - -- and can never fake freshness. - snapshot_verified_at timestamptz NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, device_id, source, window_start, window_end), - CHECK (window_end > window_start), - CHECK (first_covered_hour >= window_start AND first_covered_hour < window_end) -); - -ALTER TABLE public.tokentracker_account_sync_watermarks ENABLE ROW LEVEL SECURITY; -REVOKE ALL ON public.tokentracker_account_sync_watermarks FROM PUBLIC, anon, authenticated; -GRANT ALL ON public.tokentracker_account_sync_watermarks TO project_admin; - -CREATE INDEX IF NOT EXISTS tokentracker_account_sync_watermarks_lookup_idx - ON public.tokentracker_account_sync_watermarks (user_id, source, window_end); - --- Leaderboard: same per-hour canonical ownership for account-level sources, --- plus trae-cn joins the account_sources list (it was missing, which sent --- trae-cn rows down the machine-cluster SUM path and double-counted any --- multi-device trae-cn user). Historical rollup days self-heal through the --- existing 7-day repair loop in leaderboard_rollup_daily_advance_v2; an --- immediate full rebuild is optional (call leaderboard_rollup_daily_replace_v2 --- for the affected range). -CREATE OR REPLACE FUNCTION public.leaderboard_hourly_dedup_v2( - p_from timestamptz, - p_to timestamptz -) RETURNS TABLE ( - user_id uuid, - source text, - model text, - hour_start timestamptz, - total_tokens bigint, - input_tokens bigint, - output_tokens bigint, - cached_input_tokens bigint, - cache_creation_input_tokens bigint, - reasoning_output_tokens bigint -) -LANGUAGE sql STABLE -AS $func$ - WITH cfg AS ( - SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources - ), - acct_hours AS ( - SELECT DISTINCT h.user_id, h.source, h.hour_start - FROM tokentracker_hourly h CROSS JOIN cfg - WHERE h.hour_start >= p_from AND h.hour_start < p_to - AND h.source = ANY(cfg.account_sources) - ), - -- Per (user, account source, hour): the watermark with the greatest - -- window_end whose coverage FULLY CONTAINS the half-hour bucket owns it - -- (NULL = uncovered, legacy dedup below). Full containment: bucket start - -- >= first_covered_hour AND bucket end <= window_end - a partially - -- verified bucket is never claimed, and neither is a bucket before the - -- snapshot's first data point (enumeration is only proven from there). - -- Freshness is window identity then LOGICAL fetch time (window_end, - -- window_start, snapshot_verified_at, device_id): a genuinely newer fetch - -- of the SAME window beats an older one via snapshot_verified_at, while a - -- transport retry replays the original stamp and cannot steal ownership. - -- updated_at is the FIRST-upload time of an immutable row and never - -- participates. - acct_own AS ( - SELECT ah.user_id, ah.source, ah.hour_start, - (SELECT w.device_id - FROM tokentracker_account_sync_watermarks w - WHERE w.user_id = ah.user_id - AND w.source = ah.source - AND ah.hour_start >= w.first_covered_hour - AND ah.hour_start + interval '30 minutes' <= w.window_end - ORDER BY w.window_end DESC, w.window_start DESC, w.snapshot_verified_at DESC, w.device_id - LIMIT 1) AS owner_device_id - FROM acct_hours ah - ) - -- Deduplicate device-id drift/replays inside one physical machine cluster, - -- then add genuinely distinct machines for the same user/hour/model. - SELECT mac.user_id, mac.source, mac.model, mac.hour_start, - SUM(mac.total_tokens)::bigint AS total_tokens, - SUM(mac.input_tokens)::bigint AS input_tokens, - SUM(mac.output_tokens)::bigint AS output_tokens, - SUM(mac.cached_input_tokens)::bigint AS cached_input_tokens, - SUM(mac.cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, - SUM(mac.reasoning_output_tokens)::bigint AS reasoning_output_tokens - FROM ( - SELECT DISTINCT ON ( - h.user_id, - COALESCE(dm.machine_cluster_id, h.device_id::text), - h.source, - h.model, - h.hour_start - ) - h.user_id, - COALESCE(dm.machine_cluster_id, h.device_id::text) AS machine_cluster_id, - h.source, h.model, h.hour_start, - h.total_tokens::bigint AS total_tokens, - h.input_tokens::bigint AS input_tokens, - h.output_tokens::bigint AS output_tokens, - h.cached_input_tokens::bigint AS cached_input_tokens, - h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, - h.reasoning_output_tokens::bigint AS reasoning_output_tokens - FROM tokentracker_hourly h - CROSS JOIN cfg - JOIN tokentracker_devices d - ON d.id = h.device_id AND d.revoked_at IS NULL - LEFT JOIN tokentracker_device_machine dm - ON dm.device_id = h.device_id - WHERE h.hour_start >= p_from AND h.hour_start < p_to - AND NOT (h.source = ANY(cfg.account_sources)) - ORDER BY - h.user_id, - COALESCE(dm.machine_cluster_id, h.device_id::text), - h.source, - h.model, - h.hour_start, - h.total_tokens DESC, - h.updated_at DESC - ) mac - GROUP BY mac.user_id, mac.source, mac.model, mac.hour_start - - UNION ALL - - -- Account-level, WATERMARK-COVERED hours: the owner device's rows count - -- exclusively (all models of that one snapshot; a missing model is 0 and - -- another device's stale tuple for the hour is displaced). - SELECT h.user_id, h.source, h.model, h.hour_start, - h.total_tokens::bigint AS total_tokens, - h.input_tokens::bigint AS input_tokens, - h.output_tokens::bigint AS output_tokens, - h.cached_input_tokens::bigint AS cached_input_tokens, - h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, - h.reasoning_output_tokens::bigint AS reasoning_output_tokens - FROM tokentracker_hourly h - JOIN acct_own o - ON o.user_id = h.user_id - AND o.hour_start = h.hour_start - AND o.source = h.source - AND o.owner_device_id = h.device_id - WHERE h.hour_start >= p_from AND h.hour_start < p_to - - UNION ALL - - -- Account-level, UNCOVERED hours (cursor, watermark-less history): the - -- legacy whole-row MAX pick per (user, hour, source, model). - SELECT acct.user_id, acct.source, acct.model, acct.hour_start, - acct.total_tokens, acct.input_tokens, acct.output_tokens, - acct.cached_input_tokens, acct.cache_creation_input_tokens, - acct.reasoning_output_tokens - FROM ( - SELECT DISTINCT ON (h.user_id, h.source, h.model, h.hour_start) - h.user_id, h.source, h.model, h.hour_start, - h.total_tokens::bigint AS total_tokens, - h.input_tokens::bigint AS input_tokens, - h.output_tokens::bigint AS output_tokens, - h.cached_input_tokens::bigint AS cached_input_tokens, - h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, - h.reasoning_output_tokens::bigint AS reasoning_output_tokens - FROM tokentracker_hourly h CROSS JOIN cfg - JOIN acct_own o - ON o.user_id = h.user_id - AND o.hour_start = h.hour_start - AND o.source = h.source - AND o.owner_device_id IS NULL - WHERE h.hour_start >= p_from AND h.hour_start < p_to - AND h.source = ANY(cfg.account_sources) - ORDER BY h.user_id, h.source, h.model, h.hour_start, h.total_tokens DESC, h.updated_at DESC - ) acct -$func$; diff --git a/scripts/ops/account-usage-grouped-rpc.sql b/scripts/ops/account-usage-grouped-rpc.sql index 5424641de..42d6453f9 100644 --- a/scripts/ops/account-usage-grouped-rpc.sql +++ b/scripts/ops/account-usage-grouped-rpc.sql @@ -28,30 +28,23 @@ -- * ACCOUNT-LEVEL sources (cursor, trae-cn) come from a per-ACCOUNT cloud -- API, NOT machine logs, so SUMming across devices multiplies one -- account's usage by its device count (the v0.42.0 double-count bug). --- Dedup, do not add. HOW to dedup depends on whether the API is --- append-only per window: --- - cursor rows are identical across devices, and hours with NO --- watermark coverage fall back to the legacy whole-row --- per-(hour, model) MAX pick below. +-- Dedup, do not add. HOW to dedup depends on the source's identity: +-- - cursor rows are identical across devices (no session identity): +-- the legacy whole-row per-(hour, model) MAX pick below. -- - trae-cn is a CORRECTABLE snapshot: totals can be revised down, a --- session can move to another model or another half-hour. Two --- devices then hold different snapshot VERSIONS of the same hour, --- and a per-(hour, model) MAX stitches incompatible versions --- together (a model migration keeps BOTH the old device's stale --- model row and the fresh device's new row -> 2x). Every successful --- account-source sync therefore appends a WATERMARK record --- (kind: account_sync_watermark in the CLI queue; upserted by the --- ingest edge into tokentracker_account_sync_watermarks) asserting --- the closed window that device verified against the API. For an --- hour covered by ANY watermark, the freshest covering watermark's --- device OWNS the whole hour: only its rows count (all models; a --- model it lacks is 0) and every other device's row for that hour --- is displaced. A brand-new device with no prior cursor state still --- displaces stale tuples, because ownership is asserted per hour --- RANGE, not per tuple. Mirrored by src/lib/account-usage-dedup.js --- (executable spec; semantics pinned by test/account-usage-dedup.test.js). --- DEPLOYMENT: this function reads tokentracker_account_sync_watermarks — --- apply the account-sync-watermarks migration BEFORE this updated RPC. +-- session can move to another model or another half-hour. Hour-level +-- dedup cannot express that (a fresh device's first data bucket +-- cannot safely displace earlier hours), so trae-cn aggregates from +-- tokentracker_account_session_states - ONE canonical row per +-- (user, source, session_id), whole-row-replaced on every change by +-- the LWW upsert (tokentracker_upsert_account_session_states). +-- Downward / model / bucket corrections all collapse into that one +-- replace; cross-device duplication collapses because device_id is +-- NOT part of the identity. Absence never deletes (contract NOT +-- PROVEN). Mirrored by src/lib/account-usage-dedup.js (executable +-- spec; semantics pinned by test/account-usage-dedup.test.js). +-- DEPLOYMENT: this function reads tokentracker_account_session_states — +-- apply the account-session-states migration BEFORE this updated RPC. -- The account-level source list MUST stay in sync with ACCOUNT_LEVEL_SOURCES in -- src/lib/source-metadata.js (parity asserted by test/account-source-parity.test.js). -- @@ -113,42 +106,6 @@ AS $func$ cfg AS ( SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources ), - -- Per (account source, hour): the canonical owning device = the watermark - -- with the greatest window_end whose coverage FULLY CONTAINS the half-hour - -- bucket (NULL when none does, i.e. cursor and any watermark-less or only - -- partially-covered history). Full containment: bucket start >= - -- first_covered_hour AND bucket end <= window_end - the rolling sync - -- window starts bucket-aligned and ends at floor(now), so the in-progress - -- final bucket is honestly uncovered until a later sync fully contains it, - -- and coverage only starts at the snapshot's FIRST data bucket: the API - -- contract probes PROVE enumeration from the first observed session - -- onward, but nothing distinguishes "no rows before it" from an index - -- boundary, so that leading range stays legacy. - -- Freshness is window identity then LOGICAL fetch time (window_end, - -- window_start, snapshot_verified_at, device_id): a genuinely newer fetch - -- of the SAME window wins via snapshot_verified_at while a transport - -- retry replays the original stamp and cannot steal ownership. - -- updated_at is the FIRST-upload time of an immutable row and must never - -- participate. - acct_own AS ( - SELECT ah.hour_start, ah.source, - (SELECT w.device_id - FROM tokentracker_account_sync_watermarks w - WHERE w.user_id = p_user_id - AND w.source = ah.source - AND ah.hour_start >= w.first_covered_hour - AND ah.hour_start + interval '30 minutes' <= w.window_end - ORDER BY w.window_end DESC, w.window_start DESC, w.snapshot_verified_at DESC, w.device_id - LIMIT 1) AS owner_device_id - FROM ( - SELECT DISTINCT h.hour_start, h.source - FROM tokentracker_hourly h CROSS JOIN cfg - WHERE h.user_id = p_user_id - AND h.hour_start >= p_from - AND h.hour_start < p_to - AND h.source = ANY(cfg.account_sources) - ) ah - ), -- Stage 1: canonicalize to the raw hour grain. hourly AS ( -- Machine-level: ONE canonical whole row per (hour, source, model) across @@ -218,36 +175,10 @@ AS $func$ UNION ALL - -- Account-level, WATERMARK-COVERED hours: the owner device's rows count - -- exclusively (every model of that one snapshot; a model the owner lacks - -- is 0). NOT active-device-filtered: ownership follows verified - -- information, and an active-only filter would drop an hour whose - -- freshest covering sync came from a since-revoked device. An owner with - -- NO row at a covered hour legitimately yields nothing here — that is - -- the displacement of another device's stale tuple. - SELECT h.hour_start, h.source, h.model, - h.total_tokens::bigint AS total_tokens, - h.input_tokens::bigint AS input_tokens, - h.output_tokens::bigint AS output_tokens, - h.cached_input_tokens::bigint AS cached_input_tokens, - h.cache_creation_input_tokens::bigint AS cache_creation_input_tokens, - h.reasoning_output_tokens::bigint AS reasoning_output_tokens, - h.conversations::bigint AS conversations - FROM tokentracker_hourly h - JOIN acct_own o - ON o.hour_start = h.hour_start - AND o.source = h.source - AND o.owner_device_id = h.device_id - WHERE h.user_id = p_user_id - AND h.hour_start >= p_from - AND h.hour_start < p_to - - UNION ALL - - -- Account-level, UNCOVERED hours (no watermark asserts them: cursor, or - -- trae-cn history older than every device's verified window): the legacy - -- whole-row MAX pick per (hour, source, model) — one real, internally - -- consistent row, never a per-column synth. + -- 'cursor' (account-level, no session identity): rows are identical + -- across devices, so the legacy whole-row MAX pick per + -- (hour, source, model) dedups them. NOT active-device-filtered: + -- account truth follows the account, not which device last synced. SELECT d.hour_start, d.source, d.model, d.total_tokens, d.input_tokens, d.output_tokens, d.cached_input_tokens, d.cache_creation_input_tokens, @@ -263,15 +194,31 @@ AS $func$ h.reasoning_output_tokens::bigint AS reasoning_output_tokens, h.conversations::bigint AS conversations FROM tokentracker_hourly h - JOIN acct_own o - ON o.hour_start = h.hour_start - AND o.source = h.source - AND o.owner_device_id IS NULL WHERE h.user_id = p_user_id AND h.hour_start >= p_from AND h.hour_start < p_to + AND h.source = 'cursor' ORDER BY h.hour_start, h.source, h.model, h.total_tokens DESC, h.updated_at DESC ) d + + UNION ALL + + -- trae-cn: canonical account truth from session states (one row per + -- session after the LWW upsert). NOT active-device-filtered. + SELECT s.bucket_start AS hour_start, s.source, s.model, + SUM(s.total_tokens)::bigint AS total_tokens, + SUM(s.input_tokens)::bigint AS input_tokens, + SUM(s.output_tokens)::bigint AS output_tokens, + SUM(s.cached_input_tokens)::bigint AS cached_input_tokens, + SUM(s.cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, + SUM(s.reasoning_output_tokens)::bigint AS reasoning_output_tokens, + COUNT(*)::bigint AS conversations + FROM tokentracker_account_session_states s + WHERE s.user_id = p_user_id + AND s.bucket_start >= p_from + AND s.bucket_start < p_to + AND s.source = 'trae-cn' + GROUP BY s.bucket_start, s.source, s.model ), -- Stage 2: bucket the canonical hour rows to tz-local trunc, then aggregate. loc AS ( diff --git a/src/commands/sync.js b/src/commands/sync.js index 3318d2ea5..3ae5e2e44 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -1703,8 +1703,8 @@ async function cmdSync(argv, context = {}) { const endTime = Math.floor(nowMs / 1000); // Align the REAL fetch start down to a half-hour boundary so the API // range fully contains every bucket it can touch (a raw 08:37 start - // would only partially cover the 08:30 bucket) and the watermark - // asserts exactly the same range the API was queried with. + // would only partially cover the 08:30 bucket) - session bucket floors + // never straddle the queried range. const HALF_HOUR_SEC = 30 * 60; const startTime = Math.max( 1, @@ -1728,8 +1728,9 @@ async function cmdSync(argv, context = {}) { // An empty payload parses as a pure no-op: the TRAE absence // contract is NOT PROVEN (no evidence that a missing session means // deleted/zero), so an empty response asserts nothing - no usage - // mutation and no watermark. Non-empty snapshots append the - // watermark that displaces stale cross-device tuples. + // mutation and no session states. Non-empty snapshots append one + // canonical session-state observation per CHANGED session (the + // cloud LWW upsert reconciles cross-device versions). { if (progress?.enabled && traeCnSessions.length > 0) { progress.start( @@ -1745,6 +1746,12 @@ async function cmdSync(argv, context = {}) { onProgress: makeProviderProgress("TRAE Work CN"), windowStartMs: startTime * 1000, windowEndMs: endTime * 1000, + // The LOGICAL fetch stamp carried into every session state's + // snapshot_verified_at: the same clock that produced the query + // range, not the later enqueue moment (append-only queue + // replays it verbatim, so transport retries never fake + // freshness). + snapshotVerifiedAtMs: nowMs, }); // A partially malformed snapshot throws inside the parser (fail // closed - it must not become the authoritative window state); @@ -3538,10 +3545,10 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa for (let batch = 0; batch < maxBatches; batch++) { if (offset >= queueSize) break; const result = await readQueueBatch(queuePath, offset, limit); - // A watermark-only tail (e.g. a sync whose reconciled buckets were all - // unchanged) is still a meaningful upload: it advances the account-sync - // coverage that displaces stale cross-device tuples cloud-side. - if (result.buckets.length === 0 && result.watermarks.length === 0) break; + // A states-only tail (e.g. a retry batch whose bucket rows already + // uploaded) is still a meaningful upload: it advances the canonical + // session corrections cloud-side. + if (result.buckets.length === 0 && result.sessionStates.length === 0) break; const root = baseUrl.replace(/\/$/, ""); const anonKey = process.env.TOKENTRACKER_INSFORGE_ANON_KEY || ""; @@ -3556,7 +3563,9 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa headers, body: JSON.stringify({ hourly: result.buckets, - ...(result.watermarks.length > 0 ? { account_watermarks: result.watermarks } : {}), + ...(result.sessionStates.length > 0 + ? { account_session_states: result.sessionStates } + : {}), }), }); @@ -3594,19 +3603,18 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const bucketMap = new Map(); - // Account-sync watermarks (kind: "account_sync_watermark") are queue + // Account session states (kind: "account_session_state") are queue // control records, not usage rows: collect them separately so they ride // the same upload (after the bucket rows in file order) without entering - // the bucket stream. Watermarks are IMMUTABLE history: collect EVERY - // unique (source, window_start, window_end) — an older window must never - // be dropped just because a newer rolling window was appended later, or - // historical ownership would be lost exactly when the 30-day window - // slides past a corrected bucket. first_covered_hour / snapshot_verified_at - // ride along verbatim: the append-only queue replays the ORIGINAL fetch - // stamp on every transport retry, so a retry never fakes logical - // freshness. (The ingest edge caps a batch at 50; each sync appends one - // record, well under the cap.) - const watermarkMap = new Map(); + // the bucket stream. Keyed by (source, session_id) LAST-WINS: the + // append-only queue writes a session's newer observation after its older + // one, and only the latest per session may reach the ingest batch (a + // duplicate would make the cloud ON CONFLICT affect one row twice). + // snapshot_verified_at rides along verbatim from the ORIGINAL fetch, so a + // transport retry never fakes logical freshness. (The ingest edge caps a + // batch at 500 states; a fresh device's first sync appends one record per + // observed session, well under the cap for a 30-day TRAE window.) + const sessionStateMap = new Map(); let offset = startOffset; let linesRead = 0; for await (const line of rl) { @@ -3619,26 +3627,32 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { } catch (_e) { continue; } - if (bucket?.kind === "account_sync_watermark") { - if (typeof bucket.source === "string" && bucket.source.trim()) { - const wmSource = bucket.source.trim().toLowerCase(); - const wmKey = wmSource + "|" + bucket.window_start + "|" + bucket.window_end; - if (!watermarkMap.has(wmKey)) { - watermarkMap.set(wmKey, { - source: wmSource, - window_start: bucket.window_start, - window_end: bucket.window_end, - ...(typeof bucket.first_covered_hour === "string" && bucket.first_covered_hour - ? { first_covered_hour: bucket.first_covered_hour } - : {}), - ...(typeof bucket.snapshot_verified_at === "string" && bucket.snapshot_verified_at - ? { snapshot_verified_at: bucket.snapshot_verified_at } - : {}), - }); - } + if (bucket?.kind === "account_session_state") { + if (typeof bucket.source === "string" && typeof bucket.session_id === "string" && bucket.session_id.trim()) { + const ssSource = bucket.source.trim().toLowerCase(); + sessionStateMap.set(ssSource + "|" + bucket.session_id.trim(), { + source: ssSource, + session_id: bucket.session_id.trim(), + model: typeof bucket.model === "string" ? bucket.model : "", + bucket_start: typeof bucket.bucket_start === "string" ? bucket.bucket_start : "", + input_tokens: Number(bucket.input_tokens) || 0, + output_tokens: Number(bucket.output_tokens) || 0, + cached_input_tokens: Number(bucket.cached_input_tokens) || 0, + cache_creation_input_tokens: Number(bucket.cache_creation_input_tokens) || 0, + reasoning_output_tokens: Number(bucket.reasoning_output_tokens) || 0, + total_tokens: Number(bucket.total_tokens) || 0, + ...(typeof bucket.snapshot_verified_at === "string" && bucket.snapshot_verified_at + ? { snapshot_verified_at: bucket.snapshot_verified_at } + : {}), + }); } continue; } + // Pre-release watermark records from this feature branch's dev queues + // are obsolete control records: drop them instead of letting them enter + // the bucket stream (they carry no hour_start and would be skipped + // downstream anyway, but be explicit). + if (bucket?.kind === "account_sync_watermark") continue; const hourStart = typeof bucket?.hour_start === "string" ? bucket.hour_start : null; if (!hourStart) continue; const source = (typeof bucket?.source === "string" ? bucket.source.trim().toLowerCase() : "") || "codex"; @@ -3660,7 +3674,7 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { stream.close?.(); return { buckets: Array.from(bucketMap.values()), - watermarks: Array.from(watermarkMap.values()), + sessionStates: Array.from(sessionStateMap.values()), nextOffset: offset, }; } diff --git a/src/lib/account-usage-dedup.js b/src/lib/account-usage-dedup.js index a051bdfa3..6a434d843 100644 --- a/src/lib/account-usage-dedup.js +++ b/src/lib/account-usage-dedup.js @@ -1,98 +1,186 @@ "use strict"; /** - * Account-level cross-device dedup - JS reference implementation. + * Account-level cross-device truth - JS reference implementation. * * Account-level sources (ACCOUNT_LEVEL_SOURCES in src/lib/source-metadata.js) * come from a per-ACCOUNT cloud API, so every device that syncs them stores - * its own copy of the SAME account data. Worse, TRAE CN's API returns - * correctable snapshots, not append-only events: a session can be re-reported - * with a smaller total, a different model, or a shifted time bucket. Two - * devices therefore hold different snapshot VERSIONS of one account hour, and - * the old cloud aggregation - per (hour, source, model) pick MAX(total_tokens) - * - stitches incompatible versions together: + * its own copy of the SAME account data. How to dedup depends on the source: * - * downward correction: old device H/A=100, fresh device H/A=60 -> MAX=100 - * model migration: old device H/A=100, fresh device H/B=100 -> 200 - * bucket migration: old device 10:00/A=100, fresh 10:30/A=100 -> 200 + * - 'cursor' has NO stable session identity: its rows are identical across + * devices, so the legacy whole-row MAX pick per (hour, source, model) + * dedups them (dedupeAccountLevelRows below). + * - 'trae-cn' is a CORRECTABLE snapshot: totals can be revised down, a + * session can move to another model or another half-hour. Hour-level + * dedup CANNOT express that (a fresh device's first data bucket cannot + * safely displace earlier hours - that would need an unproven "absent = + * deleted" contract - and a 10:00 -> 10:30 bucket migration strands the + * old hour). Canonical truth for trae-cn therefore lives at the SESSION + * level (tokentracker_account_session_states; migrations/ + * 20260817120000_account-session-states.sql): identity is + * (user_id, source, session_id) - device_id is NOT identity, because the + * usage API carries no device discriminator. Every observation of one + * session whole-row-replaces the previous state under a strict LWW + * guard, so the three correction classes collapse into ONE operation: * - * The fix: every successful account-source sync appends a WATERMARK record - * (kind: "account_sync_watermark") asserting the closed window that device - * just verified against the API. Aggregation then picks, per hour, ONE - * canonical owning device - the freshest watermark whose window covers that - * hour - and counts ONLY that device's rows for the whole hour (every model; - * a model the owner lacks counts as 0). A fresh device that has never seen - * the stale tuples still displaces them, because ownership is asserted per - * hour RANGE, not per tuple. Hours covered by no watermark (sources or - * devices predating watermarks, e.g. cursor) keep the legacy whole-row MAX - * dedup, so nothing regresses for watermark-less sources. + * downward S tokens 100 -> 60 replaced, total 60 + * model S model A -> B replaced, only B remains + * bucket S bucket 10:00 -> 10:30 replaced, only 10:30 remains + * + * ABSENCE is NOT PROVEN to mean deletion, so nothing ever deletes a session + * row: a session missing from a non-empty snapshot asserts nothing, and an + * empty snapshot asserts nothing at all. + * + * Freshness: the TRAE API exposes no provider-side ordering signal (probed + * 2026-08-17: headers carry only CDN trace ids, rows carry no revision), so + * snapshot_verified_at is the CLIENT logical fetch stamp (best-effort + * cross-device ordering under clock skew - documented residual risk, NOT + * strict correctness). It is stamped once per real fetch and replayed + * verbatim by the append-only queue; the LWW guard applies strictly newer + * (>) stamps only, so retries are idempotent and a re-delivered older + * observation cannot displace a newer one. Equal stamps keep the + * first-applied row (stable under retries). * * This module is the executable specification of that semantics; the - * deployed SQL (scripts/ops/account-usage-grouped-rpc.sql account branch and - * migrations/* leaderboard_hourly_dedup_v2) implements the same algorithm and - * MUST stay in sync - test/account-usage-dedup.test.js pins both. + * deployed SQL (migrations/20260817120000_account-session-states.sql upsert, + * scripts/ops/account-usage-grouped-rpc.sql + leaderboard_hourly_dedup_v2 + * aggregation branches) implements the same algorithm and MUST stay in sync + * - test/account-usage-dedup.test.js pins both. */ -const WATERMARK_KIND = "account_sync_watermark"; +// Queue record kind the TRAE CN parser emits for one changed session's +// canonical observation (src/lib/rollout.js appendTraeCnSessionStates). +const SESSION_STATE_KIND = "account_session_state"; function parseMs(value) { const ms = Date.parse(value); return Number.isFinite(ms) ? ms : null; } -// Bucket span: usage buckets are half-hours, and a watermark only claims a -// bucket it FULLY contains (bucketStart >= window_start AND bucketEnd <= -// window_end). A partially verified bucket (e.g. the in-progress final -// half-hour of a rolling window ending at floor(now)) is NOT claimed. -const BUCKET_SPAN_MS = 30 * 60 * 1000; +// Token columns that make up a canonical session state row (the SQL table's +// non-key, non-metadata columns). +const TOKEN_COLUMNS = [ + "input_tokens", + "output_tokens", + "cached_input_tokens", + "cache_creation_input_tokens", + "reasoning_output_tokens", + "total_tokens", +]; -// Mirrors SQL owner selection: full-containment coverage starting at -// first_covered_hour, then ORDER BY window_end DESC, window_start DESC, -// snapshot_verified_at DESC, device_id. A watermark missing -// first_covered_hour / snapshot_verified_at owns nothing (the SQL columns are -// NOT NULL; only the current CLI emits complete rows). snapshot_verified_at -// is the LOGICAL fetch time replayed verbatim by the append-only queue, so a -// genuinely newer fetch of the SAME window wins while a transport retry of -// an old one cannot. updated_at never participates: watermark rows are -// immutable history and updated_at is the FIRST-upload time. -function pickOwnerWatermark(watermarks, hourStartMs) { - let best = null; - for (const wm of watermarks) { - const startMs = parseMs(wm.window_start); - const endMs = parseMs(wm.window_end); - if (startMs === null || endMs === null || endMs <= startMs) continue; - const coveredMs = parseMs(wm.first_covered_hour); - const verifiedMs = parseMs(wm.snapshot_verified_at); - if (coveredMs === null || verifiedMs === null) continue; - if (coveredMs < startMs || coveredMs >= endMs) continue; - if (hourStartMs < coveredMs || hourStartMs + BUCKET_SPAN_MS > endMs) continue; - if (!best) { - best = wm; - continue; - } - const bestEnd = parseMs(best.window_end) || 0; - const wmEnd = endMs; - if (wmEnd !== bestEnd) { - if (wmEnd > bestEnd) best = wm; - continue; - } - const bestStart = parseMs(best.window_start) || 0; - if (startMs !== bestStart) { - if (startMs > bestStart) best = wm; +function normalizeStateRow(observation) { + const source = String(observation?.source ?? "").trim().toLowerCase(); + const sessionId = String(observation?.session_id ?? "").trim(); + if (!source || !sessionId) return null; + const verifiedMs = parseMs(observation?.snapshot_verified_at); + if (verifiedMs === null) return null; + const bucketStart = String(observation?.bucket_start ?? ""); + if (parseMs(bucketStart) === null) return null; + const model = String(observation?.model ?? ""); + if (!model) return null; + const row = { + source, + session_id: sessionId, + model, + bucket_start: bucketStart, + snapshot_verified_at: observation.snapshot_verified_at, + }; + for (const column of TOKEN_COLUMNS) { + const value = Number(observation?.[column]); + row[column] = Number.isFinite(value) ? value : 0; + } + return row; +} + +/** + * Whole-row LWW replace of canonical session states - mirrors + * tokentracker_upsert_account_session_states() (migrations/ + * 20260817120000_account-session-states.sql): a row applies only when its + * snapshot_verified_at is STRICTLY newer (>) than the stored one. Replays of + * the same observation are no-ops; equal stamps keep the first-applied row. + * + * @param {Map} states - canonical table keyed by + * `${source}|${session_id}` (mirrors the (user_id, source, session_id) PK + * for one account; mutated in place). + * @param {Array} observations - ingest batch (already deduped to one + * row per session by the edge, like the SQL batch upsert requires). + * @returns {number} applied row count (mirrors the SQL ROW_COUNT return). + */ +function upsertAccountSessionStates(states, observations) { + const table = states instanceof Map ? states : new Map(); + const batch = Array.isArray(observations) ? observations : []; + let applied = 0; + for (const observation of batch) { + const row = normalizeStateRow(observation); + if (!row) continue; + const key = `${row.source}|${row.session_id}`; + const stored = table.get(key); + if (!stored) { + table.set(key, row); + applied += 1; continue; } - const bestVerified = parseMs(best.snapshot_verified_at) ?? -Infinity; - if (verifiedMs !== bestVerified) { - if (verifiedMs > bestVerified) best = wm; - continue; + const storedMs = parseMs(stored.snapshot_verified_at) ?? -Infinity; + const rowMs = parseMs(row.snapshot_verified_at) ?? -Infinity; + if (rowMs > storedMs) { + table.set(key, row); + applied += 1; } - if (String(wm.device_id) < String(best.device_id)) best = wm; } - return best; + return applied; +} + +/** + * Aggregate canonical session states into hourly rows - mirrors the trae-cn + * branch of account_usage_grouped (scripts/ops/account-usage-grouped-rpc.sql) + * and leaderboard_hourly_dedup_v2: SUM per (bucket_start, source, model), + * conversations = session count. + * + * @param {Map|Array} states + * @param {{from?: string, to?: string}} [opts] - optional [from, to) window. + * @returns {Array} rows shaped like tokentracker_hourly aggregates. + */ +function aggregateSessionStates(states, opts = {}) { + const values = states instanceof Map ? [...states.values()] : Array.isArray(states) ? states : []; + const fromMs = parseMs(opts.from ?? ""); + const toMs = parseMs(opts.to ?? ""); + const grouped = new Map(); + for (const state of values) { + const row = normalizeStateRow(state); + if (!row) continue; + const bucketMs = parseMs(row.bucket_start); + if (bucketMs === null) continue; + if (fromMs !== null && bucketMs < fromMs) continue; + if (toMs !== null && bucketMs >= toMs) continue; + const key = `${row.bucket_start}||${row.source}||${row.model}`; + const acc = grouped.get(key) || { + hour_start: row.bucket_start, + source: row.source, + model: row.model, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 0, + conversations: 0, + }; + for (const column of TOKEN_COLUMNS) acc[column] += row[column]; + acc.conversations += 1; + grouped.set(key, acc); + } + return [...grouped.values()].sort( + (a, b) => + String(a.hour_start).localeCompare(String(b.hour_start)) || + String(a.source).localeCompare(String(b.source)) || + String(a.model).localeCompare(String(b.model)), + ); } + // Mirrors the legacy SQL DISTINCT ON (hour, source, model) // ORDER BY total_tokens DESC, updated_at DESC (device_id asc as the final -// deterministic tiebreak). +// deterministic tiebreak). Used ONLY for account-level sources without a +// stable session identity ('cursor') and watermark-less history. function pickLegacyRow(rows) { let best = null; for (const row of rows) { @@ -118,29 +206,19 @@ function pickLegacyRow(rows) { } /** - * Dedupe account-level hourly rows across devices. + * Legacy whole-row MAX dedup for account-level hourly rows WITHOUT a stable + * session identity ('cursor'): rows are identical across devices, so one + * canonical row per (hour, source, model) suffices. Sources with a session + * identity ('trae-cn') must instead aggregate from canonical session states + * (aggregateSessionStates) - never from per-device hourly rows. * * @param {Array<{device_id: string, hour_start: string, source: string, * model: string, updated_at?: string, total_tokens: number}>} rows - - * every device's tokentracker_hourly-shaped rows for account-level sources. - * @param {Array<{device_id: string, source: string, window_start: string, - * window_end: string, first_covered_hour: string, snapshot_verified_at: - * string, updated_at?: string}>} watermarks - per-device verified sync - * windows (tokentracker_account_sync_watermarks shape; coverage runs from - * first_covered_hour to window_end, NOT from window_start). - * @returns {Array} canonical rows - one consistent snapshot per hour. + * every device's tokentracker_hourly-shaped rows. + * @returns {Array} canonical rows - one consistent row per (hour, model). */ -function dedupeAccountLevelRows(rows, watermarks = []) { +function dedupeAccountLevelRows(rows) { if (!Array.isArray(rows)) return []; - const watermarkList = Array.isArray(watermarks) ? watermarks : []; - const watermarksBySource = new Map(); - for (const wm of watermarkList) { - if (!wm || typeof wm.source !== "string") continue; - const list = watermarksBySource.get(wm.source) || []; - list.push(wm); - watermarksBySource.set(wm.source, list); - } - // Group rows by (source, hour_start); keep original row objects untouched. const byHour = new Map(); for (const row of rows) { @@ -152,22 +230,7 @@ function dedupeAccountLevelRows(rows, watermarks = []) { } const out = []; - for (const [key, hourRows] of byHour) { - const source = key.split("||")[0]; - const hourStartMs = parseMs(hourRows[0].hour_start); - if (hourStartMs === null) continue; - const candidates = watermarksBySource.get(String(source).toLowerCase()) || []; - const owner = pickOwnerWatermark(candidates, hourStartMs); - if (owner) { - // Watermark-covered hour: the owner's rows count exclusively; an owner - // with no rows at this hour means the account truly has 0 here and every - // other device's stale tuple is displaced. - for (const row of hourRows) { - if (String(row.device_id) === String(owner.device_id)) out.push(row); - } - continue; - } - // Uncovered hour (no watermark asserts it): legacy whole-row MAX dedup. + for (const [, hourRows] of byHour) { const byModel = new Map(); for (const row of hourRows) { const modelKey = row.model || "unknown"; @@ -191,7 +254,10 @@ function sumTotalTokens(rows) { } module.exports = { - WATERMARK_KIND, + SESSION_STATE_KIND, + TOKEN_COLUMNS, + upsertAccountSessionStates, + aggregateSessionStates, dedupeAccountLevelRows, sumTotalTokens, }; diff --git a/src/lib/local-api.js b/src/lib/local-api.js index 8cb532351..2031d5dc1 100644 --- a/src/lib/local-api.js +++ b/src/lib/local-api.js @@ -338,9 +338,10 @@ function readQueueData(queuePath) { if (!line.trim()) continue; try { const row = JSON.parse(line); - // Account-sync watermarks are queue control records (cloud-side - // cross-device dedup), not usage rows - never surface them locally. - if (row?.kind === "account_sync_watermark") continue; + // Account session states (and the pre-release watermark records + // this branch may still hold in a dev queue) are cloud-side control + // records, not usage rows - never surface them locally. + if (row?.kind === "account_session_state" || row?.kind === "account_sync_watermark") continue; // Deduplicate: each sync appends cumulative totals per bucket, so for // each (source, model, hour_start) keep only the latest (last) entry. const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`; diff --git a/src/lib/rollout.js b/src/lib/rollout.js index 66af39203..69fbcc055 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16400,7 +16400,6 @@ function normalizeTraeCnState(raw) { sessions: {}, prunedBeforeMs: 0, updatedAt: null, - lastWatermark: null, }; } if (typeof raw !== "object" || Array.isArray(raw)) { @@ -16418,22 +16417,9 @@ function normalizeTraeCnState(raw) { prunedBeforeMs: Number.isFinite(raw.prunedBeforeMs) && raw.prunedBeforeMs > 0 ? raw.prunedBeforeMs : 0, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, - lastWatermark: normalizeTraeCnLastWatermark(raw.lastWatermark), }; } -// Advisory watermark echo of the last appended account_sync_watermark. An -// older CLI build silently drops it on state rewrite (it re-normalizes with -// its own field list), which only costs one redundant watermark append - -// cloud upserts are idempotent - so no state-version bump is needed. -function normalizeTraeCnLastWatermark(raw) { - if (!raw || typeof raw !== "object") return null; - const start = typeof raw.window_start === "string" ? Date.parse(raw.window_start) : NaN; - const end = typeof raw.window_end === "string" ? Date.parse(raw.window_end) : NaN; - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null; - return { window_start: raw.window_start, window_end: raw.window_end }; -} - function isValidTraeCnTotals(totals) { if (!totals || typeof totals !== "object") return false; for (const key of [ @@ -16613,87 +16599,67 @@ function assertTraeCnBucketCovers(bucketTotals, previousTotals) { } } -// Account-sync watermark: a queue record (kind: "account_sync_watermark") -// asserting that this device has just verified the account snapshot for the -// closed window [window_start, window_end] against the TRAE CN API. The -// cloud aggregation (account-usage-grouped RPC / leaderboard_hourly_dedup_v2, -// mirrored by src/lib/account-usage-dedup.js) uses it to pick, per half-hour -// bucket, ONE canonical owning device - the watermark with the greatest -// window_end whose coverage FULLY CONTAINS the bucket (bucket start >= -// first_covered_hour AND bucket end <= window_end; a partially covered bucket -// is never claimed). Coverage starts at first_covered_hour - the half-hour -// bucket of the snapshot's FIRST session - NOT at window_start: the contract -// probes (2026-08-17, trae-cn-contract-evidence-2026-08-17.json) PROVE -// deterministic window-filtered enumeration from the first observed data -// point onward (exact-subset cross-check, historical rows addressable, -// api_total == rows across 9 pages), but "no row before the first data -// point" cannot be distinguished from an API index/truncation boundary, so -// that leading range asserts nothing and keeps the legacy dedup. This also -// makes the empty-payload no-watermark rule a corollary (no first data -// point -> no coverage) instead of a special case. Watermark rows are -// IMMUTABLE history: identity is -// (user_id, device_id, source, window_start, window_end), so an old window -// is never overwritten by a newer rolling window - once a device corrected a -// historical bucket, that ownership survives the 30-day window sliding past -// it (the covering watermark row is still in the table). Cloud-side owner -// freshness is window_end DESC, window_start DESC, snapshot_verified_at -// DESC, device_id. snapshot_verified_at is the LOGICAL fetch time (stamped -// once per real fetch, replayed verbatim from the append-only queue on -// every transport retry), so a genuinely newer fetch of the SAME window -// beats an older one regardless of device_id, while an old snapshot's late -// retry - reusing its original timestamp - cannot. updated_at (first-upload -// wall time) is NOT freshness. -// Appended AFTER the bucket rows of the same sync so the queue ordering -// guarantees a device's rows always land before the watermark covering them. -// Bucket-row readers skip this record via its kind field (it carries no -// hour_start and is not a usage row). -function watermarkWindow(windowStartMs, windowEndMs) { - return { - window_start: new Date(windowStartMs).toISOString(), - window_end: new Date(windowEndMs).toISOString(), - }; -} - -function isSameTraeCnWatermark(lastWatermark, windowStartMs, windowEndMs) { - if (!lastWatermark) return false; - if (!Number.isFinite(windowStartMs) || !Number.isFinite(windowEndMs)) return false; - return ( - Date.parse(lastWatermark.window_start) === windowStartMs && - Date.parse(lastWatermark.window_end) === windowEndMs - ); -} - -async function appendTraeCnSyncWatermark({ - queuePath, - windowStartMs, - windowEndMs, - firstCoveredMs, - verifiedAtMs, -}) { - if ( - !Number.isFinite(windowStartMs) || - !Number.isFinite(windowEndMs) || - windowEndMs <= windowStartMs || - // Coverage must start inside the verified window (the snapshot's first - // data bucket) and carry a logical verification time; without both the - // record asserts nothing and is not appended. - !Number.isFinite(firstCoveredMs) || - firstCoveredMs < windowStartMs || - firstCoveredMs >= windowEndMs || - !Number.isFinite(verifiedAtMs) - ) { - return false; +// Account session state: a queue record (kind: "account_session_state") +// carrying the CANONICAL observation of ONE provider-side TRAE CN session. +// Cloud truth for trae-cn lives at the session level +// (tokentracker_account_session_states; ingest edge upserts via +// tokentracker_upsert_account_session_states): identity is +// (user_id, source, session_id) - device_id is NOT identity, because the +// usage API request carries no device discriminator, so every device of the +// account observes the same server-side session namespace (PROVEN +// 2026-08-17: 137/137 sessions persisted across two independent fetches, 0 +// disappearances, one session revised upward KEPT its id, no duplicate ids, +// cross-window queries return exact subsets). +// +// The three correction classes collapse into ONE whole-row replace: +// downward S tokens 100 -> 60 +// model S model A -> B +// bucket S bucket 10:00 -> 10:30 +// A fresh device with no cursor history uploads every session it observes; +// the cloud LWW guard reconciles versions. ABSENCE is NOT PROVEN to mean +// deletion, so nothing is ever emitted for sessions missing from a +// non-empty snapshot, and an empty payload emits nothing at all. +// +// snapshot_verified_at is the CLIENT logical fetch stamp (the API exposes no +// provider-side ordering signal - headers carry only CDN trace ids, rows +// carry no revision). It is stamped once per real fetch and replayed +// verbatim by this append-only queue; the cloud upsert applies strictly +// newer (>) stamps only, so replays are idempotent and a transport retry of +// an older observation cannot displace a newer one. Cross-device ordering +// under clock skew is a documented residual risk, NOT strict correctness. +// +// Appended AFTER the bucket rows of the same sync (queue order guarantees a +// device's rows land before the states describing them). Bucket-row readers +// skip this record via its kind field (it carries no hour_start and is not +// a usage row). +async function appendTraeCnSessionStates({ queuePath, observations, verifiedAtMs }) { + if (!Array.isArray(observations) || observations.length === 0) return 0; + if (!Number.isFinite(verifiedAtMs)) { + throw new Error("Trae CN session states require a finite verification stamp."); + } + const verifiedAt = new Date(verifiedAtMs).toISOString(); + const lines = []; + for (const obs of observations) { + const t = obs.totals; + lines.push( + JSON.stringify({ + kind: "account_session_state", + source: TRAE_CN_SOURCE, + session_id: obs.sessionId, + model: obs.model, + bucket_start: obs.bucketStart, + input_tokens: t.input_tokens, + output_tokens: t.output_tokens, + cached_input_tokens: t.cached_input_tokens, + cache_creation_input_tokens: t.cache_creation_input_tokens, + reasoning_output_tokens: t.reasoning_output_tokens, + total_tokens: t.total_tokens, + snapshot_verified_at: verifiedAt, + }), + ); } - const record = JSON.stringify({ - kind: "account_sync_watermark", - source: TRAE_CN_SOURCE, - window_start: new Date(windowStartMs).toISOString(), - window_end: new Date(windowEndMs).toISOString(), - first_covered_hour: new Date(firstCoveredMs).toISOString(), - snapshot_verified_at: new Date(verifiedAtMs).toISOString(), - }); - await fs.appendFile(queuePath, record + "\n", "utf8"); - return true; + await fs.appendFile(queuePath, lines.join("\n") + "\n", "utf8"); + return lines.length; } async function parseTraeCnApiIncremental({ @@ -16721,27 +16687,27 @@ async function parseTraeCnApiIncremental({ const traeCnState = structuredClone(normalizeTraeCnState(cursors?.traeCn)); if (sessions.length === 0) { - // Empty payload is a successful no-op: no usage mutation, NO watermark. + // Empty payload is a successful no-op: no usage mutation, no session + // state records. // // Evidence check (2026-08-16, two real fetches 17min apart over the same // account: 137 -> 141 sessions, 0 disappeared, 0 rows changed) shows the // session set is stable, but NOTHING proves 'absent from the response' // means 'authoritatively deleted / zero' — so the absence contract is // NOT PROVEN and must stay symmetric everywhere: a session missing from - // a non-empty snapshot is never retracted locally, and an empty response - // must not assert an authoritative-zero window that would displace other - // devices' tuples cloud-side. Fresh-device displacement rides on - // non-empty snapshots only. + // a non-empty snapshot never emits a retraction, and an empty response + // asserts nothing at all. Canonical corrections ride on explicit + // observations only. return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 }; } // Full prevalidation + in-payload dedupe BEFORE any cursor/bucket mutation. // - // ANY malformed row fails the whole snapshot closed. The sync watermark - // asserts this device fully verified the account window, so a partially - // understood snapshot (99 valid + 1 uninterpretable row) must never be - // enqueued or watermarked: it would become the canonical cloud state for - // the window. The provider-level try/catch in cmdSync keeps the failure + // ANY malformed row fails the whole snapshot closed. Canonical session + // states assert this device actually understood the snapshot, so a + // partially understood snapshot (99 valid + 1 uninterpretable row) must + // never be enqueued: it would become the canonical cloud state. The + // provider-level try/catch in cmdSync keeps the failure // isolated (other providers sync on) and the error message carries only // the count + reason - no session ids, tokens, or credentials. Confirmed // LEGAL variations must not land here: absent cache fields are already @@ -16776,7 +16742,7 @@ async function parseTraeCnApiIncremental({ if (skippedRows > 0) { // Partial snapshots are not authoritative: one uninterpretable row means // the window was NOT fully verified, so fail closed - no bucket rows, no - // watermark, no cursor commit. The next sync replays from the same + // session states, no cursor commit. The next sync replays from the same // cursor state (idempotent). throw new Error( `Trae CN snapshot is not authoritative: ${skippedRows} malformed row${skippedRows !== 1 ? "s" : ""} (first: ${firstSkipReason}).`, @@ -16815,8 +16781,11 @@ async function parseTraeCnApiIncremental({ traeCnState.prunedBeforeMs = windowStartMs; } - // Deterministic: process unique session ids in sorted order. + // Deterministic: process unique session ids in sorted order. Sessions + // whose canonical observation changed (or that were never seen) are also + // collected for the account_session_state queue records. const sessionIds = [...bySession.keys()].sort(); + const changedObservations = []; for (const sessionId of sessionIds) { const current = bySession.get(sessionId); const previousEntry = traeCnState.sessions[sessionId]; @@ -16854,39 +16823,31 @@ async function parseTraeCnApiIncremental({ totals: { ...current.totals }, updatedAt: new Date().toISOString(), }; + changedObservations.push({ + sessionId, + model: current.model, + bucketStart: current.bucketStart, + totals: current.totals, + }); eventsAggregated += 1; index += 1; if (cb) cb({ index, total, eventsAggregated, bucketsQueued: touchedBuckets.size }); } const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }); - // Assert the verified window only after the reconciled bucket rows are - // durably queued; a failure here aborts before cursor commit, so the next - // sync replays the (idempotent) reconciliation and re-appends both. As in - // the empty-payload branch, re-asserting the SAME window as the last sync - // is redundant - skip it so a fixed-now no-change sync stays byte-identical. - if (!isSameTraeCnWatermark(traeCnState.lastWatermark, windowStartMs, windowEndMs)) { - // Coverage starts at the snapshot's FIRST data bucket (min bucketStart): - // enumeration is only proven from the first observed session onward. - let firstCoveredMs = Infinity; - for (const id of sessionIds) { - const bucketMs = Date.parse(bySession.get(id).bucketStart); - if (Number.isFinite(bucketMs) && bucketMs < firstCoveredMs) firstCoveredMs = bucketMs; - } - // snapshot_verified_at is stamped once per real fetch; the append-only - // queue replays it verbatim, so transport retries never fake freshness. - const verifiedAtMs = Number.isFinite(snapshotVerifiedAtMs) ? snapshotVerifiedAtMs : Date.now(); - const watermarkAppended = await appendTraeCnSyncWatermark({ - queuePath, - windowStartMs, - windowEndMs, - firstCoveredMs, - verifiedAtMs, - }); - if (watermarkAppended) { - traeCnState.lastWatermark = watermarkWindow(windowStartMs, windowEndMs); - } - } + // Emit canonical session observations only after the reconciled bucket + // rows are durably queued; a failure here aborts before cursor commit, so + // the next sync replays the (idempotent) reconciliation and re-appends + // both. snapshot_verified_at is stamped once per real fetch; the + // append-only queue replays it verbatim, so transport retries never fake + // freshness. Unchanged sessions emit nothing - a fixed-now no-change sync + // stays byte-identical. + const verifiedAtMs = Number.isFinite(snapshotVerifiedAtMs) ? snapshotVerifiedAtMs : Date.now(); + await appendTraeCnSessionStates({ + queuePath, + observations: changedObservations, + verifiedAtMs, + }); const updatedAt = new Date().toISOString(); hourlyState.updatedAt = updatedAt; traeCnState.updatedAt = updatedAt; diff --git a/test/account-source-parity.test.js b/test/account-source-parity.test.js index 53ad40611..f33578f77 100644 --- a/test/account-source-parity.test.js +++ b/test/account-source-parity.test.js @@ -12,10 +12,10 @@ // that v0.44 fixed. This test fails loudly on any mismatch. // // NOT covered here: scripts/ops/leaderboard-usage-grouped-rpc.sql (SUPERSEDED -// rollback reference — do not edit or verify). Since the account-sync -// watermark work, leaderboard_hourly_dedup_v2's account_sources array IS -// versioned in migrations/20260817120000_account-sync-watermarks.sql, so it -// is verified below; applying that migration to production stays a +// rollback reference — do not edit or verify). Since the account +// session-states work, leaderboard_hourly_dedup_v2's account_sources array +// IS versioned in migrations/20260817120000_account-session-states.sql, so +// it is verified below; applying that migration to production stays a // maintainer-side deployment step. const fs = require("node:fs"); @@ -66,8 +66,8 @@ test("account-level source list is identical across source-metadata, the account "account-usage-grouped-rpc.sql": extractSqlAccountSources( readFile("scripts/ops/account-usage-grouped-rpc.sql"), ), - "20260817120000_account-sync-watermarks.sql (leaderboard_hourly_dedup_v2)": extractSqlAccountSources( - readFile("migrations/20260817120000_account-sync-watermarks.sql"), + "20260817120000_account-session-states.sql (leaderboard_hourly_dedup_v2)": extractSqlAccountSources( + readFile("migrations/20260817120000_account-session-states.sql"), ), "tokentracker-leaderboard-profile.ts": extractJsSet( readFile("dashboard/edge-patches/tokentracker-leaderboard-profile.ts"), diff --git a/test/account-usage-dedup.test.js b/test/account-usage-dedup.test.js index 2cef79928..15b595873 100644 --- a/test/account-usage-dedup.test.js +++ b/test/account-usage-dedup.test.js @@ -1,33 +1,39 @@ /** - * Account-level cross-device dedup regression tests (PR #474 review). + * Account-level cross-device truth regression tests (PR #474 review). * * TRAE CN's usage API returns CORRECTABLE snapshots, not append-only events: * a session's totals can be revised downward, moved to another model, or - * shifted to another half-hour. Two devices of one account therefore hold - * different snapshot VERSIONS of the same hour, and the legacy cloud - * aggregation - per (hour, source, model) pick MAX(total_tokens) across - * devices - stitches incompatible versions together. These tests pin the - * FIXED semantics implemented by src/lib/account-usage-dedup.js (executable + * shifted to another half-hour. Hour-level dedup cannot express those + * corrections (a fresh device's first data bucket cannot safely displace + * earlier hours, and a 10:00 -> 10:30 migration strands the old hour), so + * canonical truth for trae-cn lives at the SESSION level. These tests pin + * the semantics implemented by src/lib/account-usage-dedup.js (executable * spec) and mirrored by: + * - migrations/20260817120000_account-session-states.sql + * (tokentracker_account_session_states + LWW upsert + + * leaderboard_hourly_dedup_v2) * - scripts/ops/account-usage-grouped-rpc.sql (account_usage_grouped) - * - migrations/20260817120000_account-sync-watermarks.sql - * (leaderboard_hourly_dedup_v2 + watermark table) + * - dashboard/edge-patches/tokentracker-ingest.ts (batch upload) * - * Scenarios A-E are the exact merge-blocker cases from the PR review; they - * exercise the real aggregation logic, not the ACCOUNT_LEVEL_SOURCES config - * drift checks in account-source-parity.test.js. + * The merge-blocker scenarios run the REAL pipeline: parser -> queue -> + * ingest batch -> LWW persistence -> aggregation. */ "use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); const { + SESSION_STATE_KIND, + upsertAccountSessionStates, + aggregateSessionStates, dedupeAccountLevelRows, sumTotalTokens, } = require("../src/lib/account-usage-dedup"); +const { parseTraeCnApiIncremental } = require("../src/lib/rollout"); const ROOT = path.join(__dirname, ".."); @@ -36,278 +42,369 @@ const OLD = "11111111-1111-1111-1111-111111111111"; const FRESH = "22222222-2222-2222-2222-222222222222"; const H10 = "2027-01-10T10:00:00.000Z"; const H1030 = "2027-01-10T10:30:00.000Z"; -const H11 = "2027-01-10T11:00:00.000Z"; -function row(deviceId, hourStart, model, totalTokens) { - return { - device_id: deviceId, - hour_start: hourStart, - source: "trae-cn", - model, - total_tokens: totalTokens, - input_tokens: totalTokens, - output_tokens: 0, - cached_input_tokens: 0, - cache_creation_input_tokens: 0, - reasoning_output_tokens: 0, - conversations: 1, - updated_at: "2027-01-15T00:00:00.000Z", - }; +// --------------------------------------------------------------------------- +// Real-pipeline harness: parser -> queue -> ingest batch -> LWW table. +// +// Each "device" run drives the REAL parseTraeCnApiIncremental against its +// own append-only queue, then drains the queue exactly like the production +// upload path (src/commands/sync.js readQueueBatch collects session-state +// records last-wins per (source, session_id); the ingest edge dedupes the +// same way before the SQL batch upsert), then applies them to the shared +// cloud table through the JS mirror of tokentracker_upsert_account_ +// session_states. Aggregation mirrors the RPC's trae-cn branch. +// --------------------------------------------------------------------------- +function tempQueue(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return { dir, queuePath: path.join(dir, "queue.jsonl") }; +} + +const WINDOW_START_MS = 1_799_000_000_000; // well before both devices' data +const WINDOW_END_MS = 1_801_000_000_000; + +async function deviceSync(cloudStates, device, { sessions, verifiedAtMs, cursors = {} }) { + const { dir, queuePath } = tempQueue("tokentracker-session-pipeline-"); + try { + await parseTraeCnApiIncremental({ + sessions, + cursors, + queuePath, + windowStartMs: WINDOW_START_MS, + windowEndMs: WINDOW_END_MS, + snapshotVerifiedAtMs: verifiedAtMs, + }); + // Drain the queue like the production upload path (last-wins per + // session, mirroring sync.js readQueueBatch + the edge's stateMap). + // An empty payload writes NO queue file at all (the parser asserts + // nothing), so the ingest batch is simply empty. + const lines = fs.existsSync(queuePath) + ? fs + .readFileSync(queuePath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + : []; + const stateMap = new Map(); + for (const row of lines) { + if (row.kind === SESSION_STATE_KIND) { + stateMap.set(row.source + "|" + row.session_id, row); + } + } + upsertAccountSessionStates(cloudStates, [...stateMap.values()]); + return lines; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } -function watermark(deviceId, start, end, updatedAt, opts = {}) { +function traeSession(sessionId, usageTimeSec, overrides = {}) { return { - device_id: deviceId, - source: "trae-cn", - window_start: start, - window_end: end, - // Coverage starts at the snapshot's FIRST data bucket. Defaults to - // window_start (data spanning the whole window); tests for the narrowed - // contract pass a later first_covered_hour via opts. - first_covered_hour: opts.firstCovered ?? start, - // Logical fetch time (stamped once per real fetch, replayed on - // retry). Deliberately independent of updated_at: updated_at is the - // TRANSPORT first-upload time and must never masquerade as freshness. - snapshot_verified_at: opts.verifiedAt ?? "2027-01-15T00:00:00.000Z", - updated_at: updatedAt || "2027-01-15T00:00:00.000Z", + session_id: sessionId, + model_name: "model-a", + usage_time: usageTimeSec, + input_token: 100, + output_token: 10, + cache_read_token: 0, + cache_write_token: 0, + ...overrides, }; } -// A watermark window covering the whole test day (mirrors the CLI's rolling -// 30-day sync window, which always covers every corrected hour). -const DAY_WINDOW = { start: "2027-01-09T00:00:00.000Z", end: "2027-01-12T00:00:00.000Z" }; +const T10 = Date.parse("2027-01-10T10:00:00.000Z") / 1000; // -> the 10:00 bucket +const T1030 = Date.parse("2027-01-10T10:30:00.000Z") / 1000; // -> the 10:30 bucket +const VERIFIED_OLD = Date.parse("2027-01-15T00:00:00.000Z"); +const VERIFIED_FRESH = Date.parse("2027-01-16T00:00:00.000Z"); -function totalByModel(rows) { - const byModel = new Map(); - for (const r of rows) { - byModel.set(r.model, (byModel.get(r.model) || 0) + r.total_tokens); - } - return byModel; +function totalsByHour(rows) { + return new Map(rows.map((r) => [r.hour_start, r.total_tokens])); } // --------------------------------------------------------------------------- -// A. Cross-device downward correction +// 1. Fresh-device bucket migration (THE P0): old device uploaded S@10:00, +// the API later corrected S to 10:30, and the fresh device's snapshot only +// contains S@10:30 (its FIRST data bucket) - no hour-coverage inference +// can reclaim 10:00, but the session identity can. // --------------------------------------------------------------------------- -test("A. downward correction: old device 100, fresh device 60 -> account total is 60", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 60), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), // older sync - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), // freshest verified window - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "must pick the corrected 60, not MAX 100"); +test("R1. fresh-device bucket migration: S@10:00=100 then S@10:30=100 -> only 10:30, total 100", async () => { + const cloudStates = new Map(); + // Device OLD synced first: session s1 lived at 10:00. + await deviceSync(cloudStates, OLD, { + sessions: [traeSession("s1", T10)], + verifiedAtMs: VERIFIED_OLD, + }); + // Fresh device, no cursor history: the API now reports s1 at 10:30. + await deviceSync(cloudStates, FRESH, { + sessions: [traeSession("s1", T1030)], + verifiedAtMs: VERIFIED_FRESH, + cursors: {}, // fresh device: no local TRAE history + }); + const rows = aggregateSessionStates(cloudStates); + const byHour = totalsByHour(rows); + assert.equal(byHour.get(H10), undefined, "the 10:00 tuple is GONE (whole-row replace)"); + assert.equal(byHour.get(H1030), 100 + 10, "only the corrected 10:30 bucket remains"); + assert.equal(sumTotalTokens(rows), 110, "total is 110 tokens (session counted once), never 220"); }); // --------------------------------------------------------------------------- -// B. Cross-device model migration +// 2. Fresh-device model migration. // --------------------------------------------------------------------------- -test("B. model migration: old device A=100, fresh device B=100 -> A displaced, total 100", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-b", 100), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - const byModel = totalByModel(deduped); - assert.equal(byModel.get("model-a") || 0, 0, "stale model-a tuple must be displaced, not kept"); - assert.equal(byModel.get("model-b"), 100, "fresh model-b row counts"); - assert.equal(sumTotalTokens(deduped), 100, "must be 100, not the stitched 200"); +test("R2. fresh-device model migration: S/A=100 then S/B=100 -> only B, total 100", async () => { + const cloudStates = new Map(); + await deviceSync(cloudStates, OLD, { + sessions: [traeSession("s1", T10)], + verifiedAtMs: VERIFIED_OLD, + }); + await deviceSync(cloudStates, FRESH, { + sessions: [traeSession("s1", T10, { model_name: "model-b" })], + verifiedAtMs: VERIFIED_FRESH, + cursors: {}, + }); + const rows = aggregateSessionStates(cloudStates); + const byModel = new Map(rows.map((r) => [r.model, r.total_tokens])); + assert.equal(byModel.get("model-a"), undefined, "stale model-a state is replaced, not kept"); + assert.equal(byModel.get("model-b"), 110, "only model-b remains"); + assert.equal(sumTotalTokens(rows), 110, "total 110, never the stitched 220"); }); // --------------------------------------------------------------------------- -// C. Cross-device bucket (hour) migration +// 3. Fresh-device downward correction. // --------------------------------------------------------------------------- -test("C. bucket migration: old device 10:00=100, fresh device 10:30=100 -> only new bucket", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H1030, "model-a", 100), - ]; - // The fresh device verified a window covering BOTH hours (the rolling - // window does), so its lack of a 10:00 row means the account truly has 0 - // at 10:00 now. - const watermarks = [watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end)]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - const byHour = new Map(deduped.map((r) => [r.hour_start, r.total_tokens])); - assert.equal(byHour.get(H10), undefined, "old 10:00 tuple must be displaced"); - assert.equal(byHour.get(H1030), 100, "new 10:30 tuple survives"); - assert.equal(sumTotalTokens(deduped), 100, "total must be 100, not 200"); +test("R3. fresh-device downward correction: S=100 then S=60 -> 60, never MAX", async () => { + const cloudStates = new Map(); + await deviceSync(cloudStates, OLD, { + sessions: [traeSession("s1", T10)], + verifiedAtMs: VERIFIED_OLD, + }); + await deviceSync(cloudStates, FRESH, { + sessions: [traeSession("s1", T10, { input_token: 50 })], + verifiedAtMs: VERIFIED_FRESH, + cursors: {}, + }); + const rows = aggregateSessionStates(cloudStates); + assert.equal(sumTotalTokens(rows), 60, "the corrected 60 wins, never the MAX 110"); + assert.equal(rows.length, 1, "one canonical session row"); }); // --------------------------------------------------------------------------- -// D. Fresh device first sync (no prior cursor state) +// 4. Missing session: absence is NOT deletion (contract NOT PROVEN). // --------------------------------------------------------------------------- -test("D. fresh device with no old cursors displaces stale tuples it has never seen", () => { - // Old device synced long ago; its watermark covers only up to its own - // (stale) sync moment. The brand-new device has no cursor state at all - - // it only knows what the API says NOW: the session is at 10:30, 60 tokens. - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H1030, "model-a", 60), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "fresh snapshot wins wholesale"); - const byHour = new Map(deduped.map((r) => [r.hour_start, r.total_tokens])); - assert.equal(byHour.get(H10), undefined); - assert.equal(byHour.get(H1030), 60); -}); - -test("D2. an owner with no rows at covered hours displaces every stale tuple", () => { - // Aggregation-level displacement: the owning device's snapshot holds - // nothing at a covered hour (e.g. every session there migrated away), so - // the stale tuples must not survive. NOTE: an EMPTY API response produces - // NO watermark at all (the TRAE absence contract is NOT PROVEN), so this - // displacement only ever rides on NON-EMPTY verified snapshots. - const rows = [row(OLD, H10, "model-a", 100), row(OLD, H11, "model-b", 50)]; - const watermarks = [watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end)]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 0, "stale tuples must not survive a verified-empty window"); - assert.equal(deduped.length, 0); +test("R4. a session missing from the next non-empty snapshot is RETAINED", async () => { + const cloudStates = new Map(); + await deviceSync(cloudStates, OLD, { + sessions: [traeSession("s1", T10)], + verifiedAtMs: VERIFIED_OLD, + }); + // A later NON-EMPTY snapshot that simply lacks s1 (it only reports s2). + await deviceSync(cloudStates, FRESH, { + sessions: [traeSession("s2", T1030, { input_token: 20, output_token: 2 })], + verifiedAtMs: VERIFIED_FRESH, + cursors: {}, + }); + const rows = aggregateSessionStates(cloudStates); + assert.equal(cloudStates.get("trae-cn|s1").total_tokens, 110, "s1 is untouched by absence"); + assert.equal(sumTotalTokens(rows), 110 + 22, "both sessions count once"); }); // --------------------------------------------------------------------------- -// E. Two devices hold the identical snapshot +// 5. Empty response: asserts NOTHING (contract NOT PROVEN). // --------------------------------------------------------------------------- -test("E. identical snapshots on two devices count exactly once", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 100), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T01:00:00.000Z"), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T02:00:00.000Z"), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 100, "same account snapshot must not double"); - assert.equal(deduped.length, 1, "exactly one canonical row"); +test("R5. an empty snapshot asserts nothing: existing sessions survive", async () => { + const cloudStates = new Map(); + await deviceSync(cloudStates, OLD, { + sessions: [traeSession("s1", T10)], + verifiedAtMs: VERIFIED_OLD, + }); + // The parser short-circuits an empty payload before ANY mutation, so the + // ingest batch is empty and the LWW table is untouched. + await deviceSync(cloudStates, FRESH, { + sessions: [], + verifiedAtMs: VERIFIED_FRESH, + cursors: {}, + }); + const rows = aggregateSessionStates(cloudStates); + assert.equal(sumTotalTokens(rows), 110, "empty response never deletes anything"); }); // --------------------------------------------------------------------------- -// Additional ownership semantics +// 6. Transport retry idempotence: the same observation replayed N times +// yields ONE canonical state with unchanged numbers. // --------------------------------------------------------------------------- -test("upward correction: fresh larger total wins (owner is per-hour, not per-tuple MAX)", () => { - const rows = [ - row(OLD, H10, "model-a", 60), - row(FRESH, H10, "model-a", 100), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, "2027-01-10T09:59:00.000Z"), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 100); -}); - -test("an old device that never comes back online cannot keep owning newer hours", () => { - // The stale device's watermark window ENDS before the fresher sync; hours - // inside the fresher window belong to the fresh device even though the old - // device's numbers are larger (revoked / inactive / retired device). - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 40), - ]; - const watermarks = [ - watermark(OLD, "2027-01-09T00:00:00.000Z", "2027-01-10T09:59:00.000Z"), - watermark(FRESH, "2027-01-09T00:00:00.000Z", "2027-01-12T00:00:00.000Z"), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 40, "freshest covering window owns the hour"); +test("R6. replaying the same observation is idempotent", async () => { + const cloudStates = new Map(); + const lines = await deviceSync(cloudStates, OLD, { + sessions: [traeSession("s1", T10)], + verifiedAtMs: VERIFIED_OLD, + }); + const observations = lines.filter((r) => r.kind === SESSION_STATE_KIND); + assert.equal(observations.length, 1); + + // Transport retries re-deliver the EXACT same batch (same bytes, same + // snapshot_verified_at - the append-only queue replays it verbatim). + const second = upsertAccountSessionStates(cloudStates, observations); + const third = upsertAccountSessionStates(cloudStates, observations); + assert.equal(second, 0, "first replay applies nothing"); + assert.equal(third, 0, "further replays apply nothing"); + assert.equal(aggregateSessionStates(cloudStates).length, 1, "still one canonical session"); + assert.equal(sumTotalTokens(aggregateSessionStates(cloudStates)), 110, "numbers unchanged"); + + // A genuinely NEWER fetch of the same session still lands (LWW >). + const applied = upsertAccountSessionStates(cloudStates, [ + { + ...observations[0], + input_token: 60, + total_tokens: 70, + snapshot_verified_at: new Date(VERIFIED_FRESH).toISOString(), + }, + ]); + assert.equal(applied, 1); + assert.equal(sumTotalTokens(aggregateSessionStates(cloudStates)), 70, "a newer stamp replaces"); }); -test("hours covered by no watermark keep the legacy whole-row MAX dedup (cursor / watermark-less history)", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 120), - row(OLD, H10, "model-b", 30), - row(FRESH, H10, "model-b", 30), - ]; - const deduped = dedupeAccountLevelRows(rows, []); - assert.equal(sumTotalTokens(deduped), 150, "per-model MAX pick, identical rows count once"); - assert.equal(deduped.length, 2); +// --------------------------------------------------------------------------- +// LWW unit semantics (mirroring the SQL upsert guard exactly). +// --------------------------------------------------------------------------- +test("LWW: strictly newer stamps replace, equal stamps keep the first-applied row", () => { + const states = new Map(); + const base = { + source: "trae-cn", + session_id: "s1", + model: "model-a", + bucket_start: H10, + input_tokens: 100, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 100, + snapshot_verified_at: "2027-01-15T00:00:00.000Z", + }; + assert.equal(upsertAccountSessionStates(states, [base]), 1); + // EQUAL stamp: first-applied wins (stable under retries). + assert.equal( + upsertAccountSessionStates(states, [{ ...base, model: "model-b" }]), + 0, + "equal stamp never replaces", + ); + assert.equal(states.get("trae-cn|s1").model, "model-a"); + // OLDER stamp: never displaces. + assert.equal( + upsertAccountSessionStates(states, [ + { ...base, model: "model-c", snapshot_verified_at: "2027-01-14T00:00:00.000Z" }, + ]), + 0, + "older observation is a no-op", + ); + assert.equal(states.get("trae-cn|s1").model, "model-a"); + // STRICTLY newer stamp: whole-row replace. + assert.equal( + upsertAccountSessionStates(states, [ + { + ...base, + model: "model-b", + bucket_start: H1030, + input_tokens: 60, + total_tokens: 60, + snapshot_verified_at: "2027-01-16T00:00:00.000Z", + }, + ]), + 1, + ); + const replaced = states.get("trae-cn|s1"); + assert.equal(replaced.model, "model-b"); + assert.equal(replaced.bucket_start, H1030); + assert.equal(replaced.total_tokens, 60, "downward replacement applied, not MAXed"); }); -test("partial coverage: covered hours use ownership, uncovered hours stay legacy", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 60), - row(OLD, H1030, "model-a", 70), - row(FRESH, H1030, "model-a", 90), - ]; - // The watermark window covers ONLY 10:00; 10:30 falls back to legacy MAX. - const watermarks = [watermark(FRESH, "2027-01-10T10:00:00.000Z", "2027-01-10T10:30:00.000Z")]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - const byHour = new Map(deduped.map((r) => [r.hour_start, r.total_tokens])); - assert.equal(byHour.get(H10), 60, "covered hour: owner row wins even though smaller"); - assert.equal(byHour.get(H1030), 90, "uncovered hour: legacy MAX"); +test("LWW: malformed observations (bad dates / missing identity) assert nothing", () => { + const states = new Map(); + assert.equal(upsertAccountSessionStates(states, [null, {}, { source: "trae-cn" }]), 0); + assert.equal(states.size, 0); + assert.equal( + upsertAccountSessionStates(states, [ + { + source: "trae-cn", + session_id: "s1", + model: "model-a", + bucket_start: "not-a-date", + total_tokens: 10, + snapshot_verified_at: "2027-01-15T00:00:00.000Z", + }, + ]), + 0, + ); + assert.equal(states.size, 0, "an unparseable bucket_start never lands"); }); -test("owner selection tiebreak is deterministic: window_end, then window_start, then device_id", () => { - const rows = [ - row("aaaa0000-0000-0000-0000-000000000000", H10, "model-a", 10), - row("bbbb0000-0000-0000-0000-000000000000", H10, "model-a", 20), - ]; - // Equal window identity -> smaller device_id wins (deterministic, mirrors - // the SQL ORDER BY ... w.device_id LIMIT 1). - const watermarks = [ - watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end), - watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 10, "device_id aaa... owns the tie"); - - // updated_at is IRRELEVANT to ownership: watermark rows are immutable - // (first-upload timestamp only), so a wildly later updated_at on the SAME - // window identity must NOT flip the pick — this is what makes transport - // retries harmless (regression E.23). - const watermarks2 = [ - watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-15T00:00:00.000Z"), - watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end, "2027-02-16T00:00:00.000Z"), - ]; - const deduped2 = dedupeAccountLevelRows(rows, watermarks2); - assert.equal(sumTotalTokens(deduped2), 10, "later updated_at on the same window must not steal ownership"); - - // A strictly later window_end wins outright (aaaa verified through - // 01-13, bbbb only through 01-12 -> aaaa owns despite the device_id order). - const watermarks3 = [ - watermark("bbbb0000-0000-0000-0000-000000000000", DAY_WINDOW.start, DAY_WINDOW.end), - watermark("aaaa0000-0000-0000-0000-000000000000", DAY_WINDOW.start, "2027-01-13T00:00:00.000Z"), - ]; - const deduped3 = dedupeAccountLevelRows(rows, watermarks3); - assert.equal(sumTotalTokens(deduped3), 10, "larger window_end owns"); -}); -test("watermarks are scoped per source: a cursor watermark never owns trae-cn hours", () => { - const rows = [row(OLD, H10, "model-a", 100)]; - const watermarks = [ +test("aggregation groups by (bucket, model) and counts sessions, with an optional [from, to) window", () => { + const states = new Map(); + upsertAccountSessionStates(states, [ { - device_id: FRESH, - source: "cursor", // different account source - window_start: DAY_WINDOW.start, - window_end: DAY_WINDOW.end, + source: "trae-cn", + session_id: "s1", + model: "model-a", + bucket_start: H10, + input_tokens: 100, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 100, + snapshot_verified_at: "2027-01-15T00:00:00.000Z", }, - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - // No trae-cn watermark -> uncovered -> legacy MAX keeps the single row. - assert.equal(sumTotalTokens(deduped), 100); + { + source: "trae-cn", + session_id: "s2", + model: "model-a", + bucket_start: H10, + input_tokens: 5, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 5, + snapshot_verified_at: "2027-01-15T00:00:00.000Z", + }, + { + source: "trae-cn", + session_id: "s3", + model: "model-b", + bucket_start: H1030, + input_tokens: 7, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 7, + snapshot_verified_at: "2027-01-15T00:00:00.000Z", + }, + ]); + const rows = aggregateSessionStates(states); + assert.deepEqual( + rows.map((r) => [r.hour_start, r.model, r.total_tokens, r.conversations]), + [ + [H10, "model-a", 105, 2], + [H1030, "model-b", 7, 1], + ], + ); + const windowed = aggregateSessionStates(states, { from: H1030 }); + assert.equal(windowed.length, 1, "window filter excludes earlier buckets"); + assert.equal(windowed[0].model, "model-b"); }); -test("degenerate watermarks (empty window / unparseable dates) are ignored", () => { - const rows = [row(OLD, H10, "model-a", 100)]; - const watermarks = [ - watermark(FRESH, DAY_WINDOW.end, DAY_WINDOW.start), // inverted - watermark(FRESH, "not-a-date", DAY_WINDOW.end), - watermark(FRESH, DAY_WINDOW.start, "not-a-date"), +// --------------------------------------------------------------------------- +// Legacy dedup: account-level sources WITHOUT a session identity ('cursor'). +// --------------------------------------------------------------------------- +test("cursor-style rows (no session identity) keep the legacy whole-row MAX dedup", () => { + const rows = [ + { device_id: OLD, hour_start: H10, source: "cursor", model: "model-a", total_tokens: 100, updated_at: "2027-01-15T00:00:00.000Z" }, + { device_id: FRESH, hour_start: H10, source: "cursor", model: "model-a", total_tokens: 120, updated_at: "2027-01-15T00:00:00.000Z" }, + { device_id: OLD, hour_start: H10, source: "cursor", model: "model-b", total_tokens: 30, updated_at: "2027-01-15T00:00:00.000Z" }, + { device_id: FRESH, hour_start: H10, source: "cursor", model: "model-b", total_tokens: 30, updated_at: "2027-01-15T00:00:00.000Z" }, ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 100, "falls back to legacy dedup"); + const deduped = dedupeAccountLevelRows(rows); + assert.equal(sumTotalTokens(deduped), 150, "per-model MAX pick, identical rows count once"); + assert.equal(deduped.length, 2); }); test("sumTotalTokens tolerates junk input", () => { @@ -316,385 +413,83 @@ test("sumTotalTokens tolerates junk input", () => { }); // --------------------------------------------------------------------------- -// SQL / deployment parity: the deployed aggregation must implement the SAME -// owner-selection semantics as the JS spec above. +// SQL / deployment parity: the deployed pipeline must implement the SAME +// session-state semantics as the JS spec above. // --------------------------------------------------------------------------- function readRepoFile(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } -test("account_usage_grouped RPC implements watermark ownership with the same tiebreak as the JS spec", () => { - const sql = readRepoFile("scripts/ops/account-usage-grouped-rpc.sql"); - assert.match(sql, /tokentracker_account_sync_watermarks/, "RPC reads the watermark table"); - assert.match( - sql, - /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.snapshot_verified_at DESC, w\.device_id/, - "owner tiebreak must be window_end DESC, window_start DESC, snapshot_verified_at DESC, device_id - same as pickOwnerWatermark; updated_at never participates", - ); - assert.match( - sql, - /ah\.hour_start >= w\.first_covered_hour/, - "coverage starts at the snapshot's first data bucket, not window_start", - ); +test("migration: session-state table identity is (user_id, source, session_id) and the upsert guard is strictly newer", () => { + const sql = readRepoFile("migrations/20260817120000_account-session-states.sql"); + assert.match(sql, /CREATE TABLE IF NOT EXISTS public\.tokentracker_account_session_states/); assert.match( sql, - /ah\.hour_start \+ interval '30 minutes' <= w\.window_end/, - "a bucket is owned only when FULLY contained (bucket end <= window end)", - ); - assert.match( - sql, - /o\.owner_device_id = h\.device_id/, - "covered hours must count ONLY the owner device's rows", + /PRIMARY KEY \(user_id, source, session_id\)/, + "device_id is NOT part of the canonical identity", ); + assert.match(sql, /CHECK \(\s*total_tokens =\s*input_tokens \+ cached_input_tokens \+ cache_creation_input_tokens \+ output_tokens\s*\)/); + assert.match(sql, /ENABLE ROW LEVEL SECURITY/); + // The LWW guard. assert.match( sql, - /o\.owner_device_id IS NULL/, - "uncovered hours must keep the legacy whole-row MAX branch", - ); - // The covered branch must NOT be active-device filtered (ownership follows - // verified information, not device liveness). - const coveredBranch = sql.split("WATERMARK-COVERED hours: the owner device's rows count")[1] - .split("UNION ALL")[0]; - assert.doesNotMatch( - coveredBranch, - /revoked_at IS NULL/, - "covered branch must not active-filter (a since-revoked owner still owns its verified hours)", + /WHERE EXCLUDED\.snapshot_verified_at > t\.snapshot_verified_at/, + "only STRICTLY newer observations replace (retries are no-ops)", ); + // Nothing may ever delete a session row. + assert.doesNotMatch(sql, /\bDELETE\b/i, "absence is not proven - no deletion path"); + // Leaderboard aggregates trae-cn from the session states. + assert.match(sql, /leaderboard_hourly_dedup_v2/); + const traeBranch = sql.split("trae-cn: canonical account truth aggregated from session states")[1]; + assert.ok(traeBranch, "leaderboard has a trae-cn session-state branch"); + assert.match(traeBranch.split("$func$")[0], /FROM tokentracker_account_session_states/); }); -test("leaderboard_hourly_dedup_v2 migration mirrors the RPC ownership semantics and includes trae-cn", () => { - const sql = readRepoFile("migrations/20260817120000_account-sync-watermarks.sql"); - assert.match(sql, /CREATE TABLE IF NOT EXISTS public\.tokentracker_account_sync_watermarks/); - assert.match( - sql, - /PRIMARY KEY \(user_id, device_id, source, window_start, window_end\)/, - "watermark identity is the full window - rows are immutable history, never overwritten", - ); - assert.match(sql, /CHECK \(window_end > window_start\)/); - assert.match(sql, /ENABLE ROW LEVEL SECURITY/); - // The leaderboard function must agree with account_usage_grouped. - assert.match( - sql, - /ORDER BY w\.window_end DESC, w\.window_start DESC, w\.snapshot_verified_at DESC, w\.device_id/, - "leaderboard owner tiebreak must equal the account RPC tiebreak", - ); +test("account_usage_grouped RPC aggregates trae-cn from session states (no watermark path)", () => { + const sql = readRepoFile("scripts/ops/account-usage-grouped-rpc.sql"); + assert.match(sql, /tokentracker_account_session_states/, "RPC reads the session-state table"); assert.match( sql, - /ah\.hour_start >= w\.first_covered_hour/, - "leaderboard coverage also starts at first_covered_hour", + /GROUP BY s\.bucket_start, s\.source, s\.model/, + "aggregation groups by (bucket, source, model) - same as the JS spec", ); - assert.match( + assert.doesNotMatch( sql, - /first_covered_hour timestamptz NOT NULL/, - "coverage start is a required column (absence cannot be faked)", + /account_sync_watermark|first_covered_hour/, + "watermarks must be fully out of the correctness path", ); - assert.match(sql, /ARRAY\['cursor', 'trae-cn'\]::text\[\] AS account_sources/); - assert.match(sql, /o\.owner_device_id = h\.device_id/); - assert.match(sql, /o\.owner_device_id IS NULL/); + // 'cursor' keeps the legacy whole-row MAX pick (DISTINCT ON + ORDER BY + // total_tokens DESC), NOT the session-state path. + const cursorBranch = sql.split("'cursor' (account-level, no session identity)")[1] || ""; + assert.match(cursorBranch.split("UNION ALL")[0], /source = 'cursor'/); + assert.match(cursorBranch.split("UNION ALL")[0], /total_tokens DESC/); }); -test("ingest edge accepts account_watermarks and upserts them after the bucket rows", () => { +test("ingest edge validates account_session_states and upserts them via the LWW RPC after the hourly rows", () => { const ts = readRepoFile("dashboard/edge-patches/tokentracker-ingest.ts"); - assert.match(ts, /account_watermarks/, "edge reads the account_watermarks payload"); - assert.match( - ts, - /tokentracker_account_sync_watermarks/, - "edge upserts into the watermark table", - ); - assert.match( - ts, - /onConflict: "user_id,device_id,source,window_start,window_end"/, - "upsert conflict target matches the immutable window identity PK", - ); - assert.match( - ts, - /ignoreDuplicates: true/, - "a re-delivered watermark is a no-op (retry never refreshes updated_at / freshness)", - ); - // Watermark upsert must come AFTER the hourly upsert in file order. + assert.match(ts, /body\.account_session_states/, "edge reads the account_session_states payload"); + assert.match(ts, /Invalid account session state/, "malformed states fail closed"); + assert.match(ts, /r\.source \+ "\|" \+ r\.session_id/, "batch dedup is last-wins per session"); + assert.match(ts, /tokentracker_upsert_account_session_states/, "edge calls the LWW upsert RPC"); + // Session states land AFTER the hourly upsert in file order. assert.ok( - ts.indexOf("from(\"tokentracker_hourly\")") < ts.indexOf("tokentracker_account_sync_watermarks"), - "bucket rows land before the watermark that covers them", + ts.indexOf('from("tokentracker_hourly")') < ts.indexOf("tokentracker_upsert_account_session_states"), + "bucket rows land before the canonical states describing them", ); - // Malformed watermarks fail closed. - assert.match(ts, /Invalid account watermark/); }); -test("local queue readers never surface watermark control records as usage rows", () => { +test("local queue readers never surface session-state control records as usage rows", () => { const localApi = readRepoFile("src/lib/local-api.js"); assert.match( localApi, - /kind === "account_sync_watermark"/, - "readQueueData must skip account_sync_watermark records", + /kind === "account_session_state"/, + "readQueueData must skip account_session_state records", ); const sync = readRepoFile("src/commands/sync.js"); assert.match( sync, - /account_watermarks/, - "drainQueueToCloud must forward watermarks to the ingest edge", + /kind === "account_session_state"/, + "the upload path collects account_session_state records separately from bucket rows", ); -}); -// --------------------------------------------------------------------------- -// F. Historical ownership survives the 30-day window sliding past (>30 days -// regression - merge-blocker): watermark rows are IMMUTABLE history, so the -// covering watermark that displaced a stale tuple stays in the table even -// after BOTH devices' current rolling windows no longer cover the hour. -// --------------------------------------------------------------------------- -test("F. corrected history stays corrected after the 30-day window slides past it", () => { - // Day 0: OLD synced H10/model-a=100 (its watermark covered H10 then). - // Day 1: TRAE corrects the session to model-b=60; FRESH syncs the - // corrected snapshot - FRESH's day-1 watermark (greater window_end) owns H10. - // Day 31+: both devices' CURRENT windows have slid past H10; only the - // immutable historical rows still cover it. FRESH's day-1 watermark row - // must STILL own H10 - the correction must never resurrect via the legacy - // MAX fallback, and the stale model-a tuple must stay displaced. - const rows = [ - row(OLD, H10, "model-a", 100), // OLD's stale row (uploaded day 0) - row(FRESH, H10, "model-b", 60), // FRESH's corrected row (uploaded day 1) - ]; - const watermarks = [ - // OLD: day-0 window (covered H10) + current day-31 window (does not). - watermark(OLD, "2026-12-11T00:00:00.000Z", "2027-01-10T11:00:00.000Z"), - watermark(OLD, "2027-02-10T00:00:00.000Z", "2027-03-12T00:00:00.000Z"), - // FRESH: day-1 corrected window (still covers H10 - immutable history) - // + current day-32 window (does not). - watermark(FRESH, "2026-12-11T00:00:00.000Z", "2027-01-12T00:00:00.000Z"), - watermark(FRESH, "2027-02-11T00:00:00.000Z", "2027-03-13T00:00:00.000Z"), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - const byModel = totalByModel(deduped); - assert.equal(byModel.get("model-a") || 0, 0, "stale model-a must stay displaced"); - assert.equal(byModel.get("model-b"), 60, "corrected value survives the window slide"); - assert.equal(sumTotalTokens(deduped), 60, "must stay 60 - never 100, never 160"); -}); - -test("F2. downward correction also survives the 30-day window slide", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 60), - ]; - const watermarks = [ - watermark(OLD, "2026-12-11T00:00:00.000Z", "2027-01-10T11:00:00.000Z"), - watermark(OLD, "2027-02-10T00:00:00.000Z", "2027-03-12T00:00:00.000Z"), - watermark(FRESH, "2026-12-11T00:00:00.000Z", "2027-01-12T00:00:00.000Z"), - watermark(FRESH, "2027-02-11T00:00:00.000Z", "2027-03-13T00:00:00.000Z"), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "must stay the corrected 60, never MAX 100"); -}); - -test("F3. no fallback to stale MAX: a once-covered hour never reverts to legacy dedup", () => { - // Even when the ONLY covering watermark is months old (the correcting - // device long retired), the hour is covered - legacy MAX must not run. - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 60), - ]; - const watermarks = [ - watermark(FRESH, "2026-12-11T00:00:00.000Z", "2027-01-12T00:00:00.000Z", "2027-06-01T00:00:00.000Z"), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60); -}); - -// --------------------------------------------------------------------------- -// G. Bucket-coverage boundary contract: a watermark claims a half-hour -// bucket only when the bucket is FULLY inside [window_start, window_end]. -// The rolling fetch start is bucket-aligned down and the end is floor(now), -// so honest gaps stay uncovered (legacy fallback) instead of being claimed. -// --------------------------------------------------------------------------- -test("G1. left boundary: a window starting mid-bucket does not claim that bucket", () => { - // Unaligned start 08:37: the 08:30 bucket is only PARTIALLY verified. - const rows = [ - row(OLD, "2027-01-10T08:30:00.000Z", "model-a", 100), - row(FRESH, "2027-01-10T08:30:00.000Z", "model-a", 60), - ]; - const watermarks = [watermark(FRESH, "2027-01-10T08:37:00.000Z", DAY_WINDOW.end)]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal( - sumTotalTokens(deduped), - 100, - "partial bucket falls back to legacy MAX - never claimed by a partial window", - ); -}); - -test("G2. right boundary: window_end exactly at a bucket start does not claim it", () => { - // end = 10:30:00: the 10:30 bucket spans [10:30, 11:00) - only its first - // instant is verified. Not claimed. - const rows = [ - row(OLD, H1030, "model-a", 100), - row(FRESH, H1030, "model-a", 60), - ]; - const watermarks = [watermark(FRESH, DAY_WINDOW.start, "2027-01-10T10:30:00.000Z")]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 100, "the 10:30 bucket is NOT claimed by a window ending 10:30"); -}); - -test("G3. right boundary: window_end mid-bucket does not claim the in-progress bucket", () => { - // end = 10:47: the 10:30 bucket [10:30, 11:00) is only verified through - // 10:47. Not claimed. - const rows = [ - row(OLD, H1030, "model-a", 100), - row(FRESH, H1030, "model-a", 60), - ]; - const watermarks = [watermark(FRESH, DAY_WINDOW.start, "2027-01-10T10:47:00.000Z")]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 100, "in-progress bucket stays uncovered (legacy fallback)"); -}); - -test("G4. right boundary: window_end at the bucket END fully claims the bucket", () => { - // end = 11:00: the 10:30 bucket [10:30, 11:00) is fully contained. - const rows = [ - row(OLD, H1030, "model-a", 100), - row(FRESH, H1030, "model-a", 60), - ]; - const watermarks = [watermark(FRESH, DAY_WINDOW.start, "2027-01-10T11:00:00.000Z")]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "a fully-contained bucket is owned by its verifier"); -}); - -// --------------------------------------------------------------------------- -// H. Transport retry idempotence (E.23): a re-delivered watermark of an -// older snapshot must never outrank a genuinely newer verified window, -// even if its updated_at looks fresher. -// --------------------------------------------------------------------------- -test("H. an old watermark re-delivered late never steals ownership from a newer snapshot", () => { - const A = "aaaa0000-0000-0000-0000-000000000000"; - const B = "bbbb0000-0000-0000-0000-000000000000"; - const rows = [ - row(A, H10, "model-a", 100), // A's stale row - row(B, H10, "model-a", 60), // B's corrected row - ]; - const watermarks = [ - // A's snapshot verified at T1; its queue record re-delivered at T3 - // (updated_at refreshed by the retry - the legacy failure mode). - watermark(A, DAY_WINDOW.start, "2027-01-11T00:00:00.000Z", "2027-03-01T00:00:00.000Z"), - // B's genuinely newer window (verified T2): later window_end. - watermark(B, DAY_WINDOW.start, "2027-01-12T00:00:00.000Z", "2027-01-20T00:00:00.000Z"), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "window identity, not updated_at, decides ownership"); -}); - -// --------------------------------------------------------------------------- -// I. P0 absence narrowing: enumeration is only PROVEN from the snapshot's -// FIRST data bucket (first_covered_hour) onward. A non-empty snapshot must -// NOT assert authoritative zero for hours BEFORE its first observed session -// (contract probe 2026-08-17: "no rows before the first data point" cannot -// be distinguished from an API index boundary). -// --------------------------------------------------------------------------- -test("I1. hours before the snapshot's first data bucket are NOT owned (no absence inference)", () => { - // Old device B holds 10:00/model-a=100; new device A's non-empty snapshot - // only contains 11:00/model-b=20 (first data at 11:00). The P0 review case: - // A must NOT zero out 10:00 merely because its response lacked the session. - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H11, "model-b", 20), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { - firstCovered: H11, // A's snapshot starts at 11:00 - verifiedAt: "2027-01-16T00:00:00.000Z", // A fetched after B - }), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - const byModel = totalByModel(deduped); - assert.equal(byModel.get("model-a"), 100, "10:00 stays legacy: B's row is kept, not zeroed"); - assert.equal(byModel.get("model-b"), 20, "11:00 is owned by FRESH"); -}); - -test("I2. the first covered bucket itself IS owned (data anchors enumeration from there)", () => { - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 60), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { - firstCovered: H10, // FRESH's first data bucket is exactly H10 - verifiedAt: "2027-01-16T00:00:00.000Z", // FRESH fetched after OLD - }), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "downward correction still lands on the first covered bucket"); -}); - -test("I3. a watermark missing first_covered_hour / snapshot_verified_at owns nothing", () => { - const rows = [row(OLD, H10, "model-a", 100)]; - const wm = watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end); - const broken1 = { ...wm, first_covered_hour: undefined }; - const broken2 = { ...wm, snapshot_verified_at: undefined }; - for (const broken of [broken1, broken2]) { - const deduped = dedupeAccountLevelRows(rows, [broken]); - assert.equal(sumTotalTokens(deduped), 100, "incomplete watermark rows fall back to legacy dedup"); - } -}); - -test("I4. first_covered_hour outside the window is rejected (owns nothing)", () => { - const rows = [row(OLD, H10, "model-a", 100)]; - const watermarks = [ - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { - firstCovered: "2027-01-20T00:00:00.000Z", // after window_end: invalid - }), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 100, "invalid coverage rows assert nothing"); -}); - -// --------------------------------------------------------------------------- -// J. P1 same-window logical freshness: two devices can produce the SAME -// window identity (same-second floor(now)) with different real fetch times. -// snapshot_verified_at - the logical fetch stamp replayed verbatim by the -// append-only queue - decides; device_id is only the final tiebreak. -// --------------------------------------------------------------------------- -test("J1. same window: the genuinely newer fetch wins regardless of device_id", () => { - // OLD fetches at T1 (100); FRESH fetches at T2 > T1 after a correction (60). - // OLD has the smaller device_id, which previously decided the tie - wrongly. - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 60), - ]; - const watermarks = [ - watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { - verifiedAt: "2027-01-10T10:00:00.000Z", - }), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { - verifiedAt: "2027-01-10T10:05:00.000Z", - }), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "newer logical fetch owns the hour, not the smaller device_id"); -}); - -test("J2. a late transport retry of the older snapshot cannot steal ownership", () => { - // OLD verified at T1; FRESH verified at T2 > T1; OLD's queue record is only - // uploaded at T3 > T2 (network retry). The retry replays the ORIGINAL T1 - // stamp - and updated_at (upload time) never participates - so FRESH keeps - // the hour. - const rows = [ - row(OLD, H10, "model-a", 100), - row(FRESH, H10, "model-a", 60), - ]; - const watermarks = [ - // OLD re-delivered at T3: original verifiedAt T1, late updated_at T3. - watermark(OLD, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-11T00:00:00.000Z", { - verifiedAt: "2027-01-10T10:00:00.000Z", - }), - watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, "2027-01-10T10:30:00.000Z", { - verifiedAt: "2027-01-10T10:05:00.000Z", - }), - ]; - const deduped = dedupeAccountLevelRows(rows, watermarks); - assert.equal(sumTotalTokens(deduped), 60, "FRESH (verified T2) still owns despite OLD's T3 re-delivery"); -}); - -test("J3. replaying the same watermark record is idempotent (no new logical version)", () => { - const rows = [row(OLD, H10, "model-a", 100), row(FRESH, H10, "model-a", 60)]; - const wm = watermark(FRESH, DAY_WINDOW.start, DAY_WINDOW.end, undefined, { - verifiedAt: "2027-01-10T10:05:00.000Z", - }); - const once = dedupeAccountLevelRows(rows, [wm]); - const thrice = dedupeAccountLevelRows(rows, [wm, { ...wm }, { ...wm }]); - assert.deepEqual(once, thrice, "duplicate records of one snapshot do not change ownership"); + assert.match(sync, /account_session_states/, "the upload body carries account_session_states"); }); diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js index f90ac2c38..07444765c 100644 --- a/test/sync-trae-cn.test.js +++ b/test/sync-trae-cn.test.js @@ -101,8 +101,8 @@ function writeTraeCnStorage(traeCnHome, auth = { token: "fake-jwt-token", refres ); } -function readWatermarkRows(home) { - return readQueueRows(home).filter((row) => row.kind === "account_sync_watermark"); +function readSessionStateRows(home) { + return readQueueRows(home).filter((row) => row.kind === "account_session_state"); } function readQueueRows(home) { @@ -175,7 +175,7 @@ test("non-background auto trae-cn sync fetches the exact rolling range and queue assert.equal(body.page_size, 20); const traeRows = readQueueRows(home).filter( - (row) => row.source === "trae-cn" && row.kind !== "account_sync_watermark", + (row) => row.source === "trae-cn" && !row.kind, ); assert.equal(traeRows.length, 1); assert.equal(traeRows[0].model, "doubao-pro"); @@ -185,19 +185,26 @@ test("non-background auto trae-cn sync fetches the exact rolling range and queue assert.equal(traeRows[0].conversation_count, 1); assert.equal(traeRows[0].hour_start, halfHourBucket(usageTime)); - // The sync appends exactly one account-sync watermark asserting the full - // verified window (bucket row first, watermark after - queue order). + // The sync appends exactly one canonical session-state observation + // after the bucket row (queue order: a device's rows land before the + // states describing them). const queueLines = fs .readFileSync(path.join(home, ".tokentracker", "tracker", "queue.jsonl"), "utf8") .trim() .split("\n"); - assert.equal(queueLines.length, 2, "bucket row then watermark"); - const watermarks = readWatermarkRows(home); - assert.equal(watermarks.length, 1); - assert.equal(watermarks[0].source, "trae-cn"); - assert.equal(Date.parse(watermarks[0].window_start), startTime * 1000); - assert.equal(Date.parse(watermarks[0].window_end), endTime * 1000); - assert.equal(readCursors(home).traeCn.lastWatermark.window_end, watermarks[0].window_end); + assert.equal(queueLines.length, 2, "bucket row then session state"); + const states = readSessionStateRows(home); + assert.equal(states.length, 1); + assert.equal(states[0].source, "trae-cn"); + assert.equal(states[0].session_id, "s1"); + assert.equal(states[0].model, "doubao-pro"); + assert.equal(states[0].bucket_start, halfHourBucket(usageTime)); + assert.equal(states[0].total_tokens, 110); + assert.equal( + Date.parse(states[0].snapshot_verified_at), + NOW_MS, + "logical fetch stamp is the fixed sync clock, not the enqueue moment", + ); const cursors = readCursors(home); assert.equal(cursors.traeCn.version, 1); @@ -223,13 +230,13 @@ test("repeated fixed-now sync is idempotent (no token growth / no new queue row) assert.equal(fs.readFileSync(queuePath, "utf8"), before, "queue unchanged on repeat"); const traeRows = readQueueRows(home).filter( - (row) => row.source === "trae-cn" && row.kind !== "account_sync_watermark", + (row) => row.source === "trae-cn" && !row.kind, ); assert.equal(traeRows.length, 1); assert.equal(traeRows[0].total_tokens, 110); - // The repeat neither re-appends the watermark nor grows the queue: the - // cursor lastWatermark echo suppresses the redundant re-assertion. - assert.equal(readWatermarkRows(home).length, 1); + // The repeat re-appends nothing: unchanged sessions emit no session + // state observation, so the queue stays byte-identical. + assert.equal(readSessionStateRows(home).length, 1); }); }); @@ -383,17 +390,21 @@ test("over-capacity TRAE CN window is split and the staggered halves import atom [[startTime, endTime], [startTime, mid], [mid + 1, endTime]], ); const traeRows = readQueueRows(home).filter( - (row) => row.source === "trae-cn" && row.kind !== "account_sync_watermark", + (row) => row.source === "trae-cn" && !row.kind, ); assert.equal(traeRows.length, 2, "both halves queue their row"); const cursorSessions = readCursors(home).traeCn.sessions; assert.deepEqual(Object.keys(cursorSessions).sort(), ["split-left", "split-right"]); - // Split sub-windows union back to ONE watermark over the full window - - // ownership cloud-side must cover the whole requested range. - const watermarks = readWatermarkRows(home); - assert.equal(watermarks.length, 1); - assert.equal(Date.parse(watermarks[0].window_start), startTime * 1000); - assert.equal(Date.parse(watermarks[0].window_end), endTime * 1000); + // Both halves emit their canonical session observations (one per + // session), all stamped with the SAME logical fetch clock. + const states = readSessionStateRows(home); + assert.deepEqual( + states.map((r) => r.session_id).sort(), + ["split-left", "split-right"], + ); + for (const state of states) { + assert.equal(Date.parse(state.snapshot_verified_at), NOW_MS); + } }); }); diff --git a/test/trae-cn-parser.test.js b/test/trae-cn-parser.test.js index 6866f63a0..4d424cb85 100644 --- a/test/trae-cn-parser.test.js +++ b/test/trae-cn-parser.test.js @@ -38,6 +38,9 @@ function lastTraeRow(queuePath, model, hourStart) { return queueRows(queuePath) .filter( (row) => + // Usage rows only: the queue also carries account_session_state + // control records (same source/model, no hour_start). + !row.kind && row.source === "trae-cn" && row.model === model && (hourStart === undefined || row.hour_start === hourStart), @@ -328,7 +331,7 @@ test("conflicting duplicate ids and malformed rows fail before any mutation", as // A malformed row FAILS THE WHOLE SNAPSHOT closed (P0: a partially // understood snapshot must never become the authoritative window state - - // no bucket rows, no watermark, no cursor commit). Confirmed-legal + // no bucket rows, no session states, no cursor commit). Confirmed-legal // variations (absent cache fields) are handled above and stay legal. const mixedQueue = tempQueue(); const cursorsMixed = {}; @@ -409,12 +412,12 @@ test("empty payload is a no-op and preserves existing contributions", async (t) assert.equal(lastTraeRow(queuePath, "doubao-pro").total_tokens, 110); }); -test("empty payload asserts nothing: no watermark, no cursor commit (absence contract NOT PROVEN)", async (t) => { +test("empty payload asserts nothing: no usage mutation, no session states (absence contract NOT PROVEN)", async (t) => { // Evidence: two real fetches 17min apart (137 -> 141 sessions, 0 // disappeared, 0 changed) show a stable session set, but nothing proves // that an absent session means deleted/zero. An empty response therefore - // asserts NOTHING - no usage mutation AND no account_sync_watermark that - // would displace other devices' tuples cloud-side. + // asserts NOTHING - no usage mutation AND no account_session_state + // observation that could displace another device's canonical rows. const { dir, queuePath } = tempQueue(); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); const cursors = {}; @@ -430,16 +433,11 @@ test("empty payload asserts nothing: no watermark, no cursor commit (absence con }); assert.equal(empty.recordsProcessed, 0); assert.equal(empty.bucketsQueued, 0); - assert.equal(fs.readFileSync(queuePath, "utf8"), before, "no watermark appended for an empty response"); - assert.equal( - cursors.traeCn.lastWatermark, - null, - "the absence contract is not proven - empty asserts no verified window", - ); + assert.equal(fs.readFileSync(queuePath, "utf8"), before, "no session state appended for an empty response"); assert.equal(cursors.traeCn.sessions.s1.totals.input_tokens, 100, "contribution preserved"); }); -test("a partial snapshot (99 valid + 1 malformed) publishes no watermark even with a window", async (t) => { +test("a partial snapshot (99 valid + 1 malformed) publishes no session states even with a window", async (t) => { const { dir, queuePath } = tempQueue(); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); const cursors = {}; @@ -455,7 +453,7 @@ test("a partial snapshot (99 valid + 1 malformed) publishes no watermark even wi }), /not authoritative/, ); - assert.equal(fs.existsSync(queuePath), false, "neither bucket rows nor a watermark may land"); + assert.equal(fs.existsSync(queuePath), false, "neither bucket rows nor session states may land"); assert.equal(cursors.hourly, undefined, "previous canonical state untouched locally"); }); test("cursors survive a serialize/parse restart and a later correction", async (t) => { @@ -646,23 +644,24 @@ test("window prune drops pre-window sessions monotonically and never rewinds", a assert.ok(cursors.traeCn.sessions["new-s"], "in-window entry kept"); assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs); - // An earlier window start must not rewind the watermark or re-prune; a - // re-reported in-window session still reconciles against its stored entry. + // An earlier window start must not rewind the prune watermark + // (prunedBeforeMs) or re-prune; a re-reported in-window session still + // reconciles against its stored entry. await parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "new-s", usage_time: T2 })], cursors, queuePath, windowStartMs: (T1 + 60) * 1000, }); - assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs, "watermark never rewinds"); + assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs, "prune watermark never rewinds"); assert.ok(cursors.traeCn.sessions["new-s"]); }); -test("watermark record carries first_covered_hour + snapshot_verified_at (coverage + logical freshness)", async (t) => { +test("session state records carry the canonical observation + snapshot_verified_at (logical freshness)", async (t) => { const { dir, queuePath } = tempQueue(); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); const cursors = {}; - const T_LATE = 1_700_003_500; // 23:05:00Z -> 23:00 bucket (the FIRST data bucket here is 22:00 from s1) + const T_LATE = 1_700_003_500; // 23:05:00Z -> 23:00 bucket const verifiedAt = 1_700_010_000_000; await parseTraeCnApiIncremental({ sessions: [sessionRow(), sessionRow({ session_id: "s2", usage_time: T_LATE })], @@ -672,18 +671,25 @@ test("watermark record carries first_covered_hour + snapshot_verified_at (covera windowEndMs: 1_700_010_000_000, snapshotVerifiedAtMs: verifiedAt, }); - const wm = queueRows(queuePath).find((r) => r.kind === "account_sync_watermark"); - assert.ok(wm, "watermark appended for a non-empty snapshot"); - assert.equal(wm.first_covered_hour, B1, "coverage starts at the snapshot's FIRST data bucket (22:00)"); + const states = queueRows(queuePath).filter((r) => r.kind === "account_session_state"); + assert.equal(states.length, 2, "one canonical observation per session in the snapshot"); + const byId = new Map(states.map((r) => [r.session_id, r])); + const s1 = byId.get("s1"); + assert.ok(s1, "s1 observation queued"); + assert.equal(s1.source, "trae-cn"); + assert.equal(s1.model, "doubao-pro"); + assert.equal(s1.bucket_start, B1, "bucket_start is the session's half-hour floor (22:00)"); + assert.equal(s1.input_tokens, 100, "token columns mirror the session totals"); + assert.equal(s1.total_tokens, 110); assert.equal( - wm.snapshot_verified_at, + s1.snapshot_verified_at, new Date(verifiedAt).toISOString(), "logical fetch stamp is the injected real-fetch time", ); - assert.ok(Date.parse(wm.first_covered_hour) >= 1_699_000_000_000, "coverage start within the window"); + assert.equal(byId.get("s2").bucket_start, "2023-11-14T23:00:00.000Z"); }); -test("transport retry replays the ORIGINAL watermark record verbatim (append-only queue)", async (t) => { +test("transport retry replays the ORIGINAL session state record verbatim (append-only queue)", async (t) => { const { dir, queuePath } = tempQueue(); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); const cursors = {}; From ab280cb284d998a8a9e08323b104ded013622416 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 11:57:37 +0800 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20session-state=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=AE=A1=E5=85=A5=20batch=20=E4=B8=8A=E9=99=90=20+=20=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=20identity=20=E8=AF=81=E6=8D=AE=E8=A1=A8=E8=BF=B0=20+?= =?UTF-8?q?=20=E9=92=89=E6=AD=BB=E5=8E=86=E5=8F=B2=20rollup=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) readQueueBatch 中 account_session_state 原不计入 per-batch 记录上限, states-heavy 队列(fresh device 首次 30 天同步)一次读出全部(可 >500)→ ingest 400 → offset 不前进 → 永久失败。现在 session state 与 bucket 行计入同一上限(batchSize=200/batch,≤ edge 500 上限), production-path 测试(真实 readQueueBatch→drainQueueToCloud→mock fetch)证明:1200 states 拆多批全部送达、失败 offset 冻结、retry 精确续传、批内无重复 session。 2) session identity 证据拆分:repeated-fetch VERIFIED / cross-window VERIFIED / cross-device NOT DIRECTLY VERIFIED(请求体无 device 参数 是必要非充分条件;未做第二设备实验;如被反证需重评 PK)。 freshness 表述去掉未实现的 skew bound。 3) >7 天 correction vs 历史 rollup:现有 advance_v2 循环 repair(每次 6h 推 7 天、从最老历史日循环)已能更新闭环日,无需改实现;测试钉 死 A/B/C 三场景 + SQL 契约(total=rollup∪live tail、account/profile 实时)。migration 注释补充 lag 随总历史长度增长的特性。 --- .../20260817120000_account-session-states.sql | 60 ++- src/commands/sync.js | 21 +- src/lib/account-usage-dedup.js | 8 +- src/lib/rollout.js | 15 +- test/leaderboard-rollup-correction.test.js | 361 ++++++++++++++++++ test/sync-upload-batching.test.js | 268 +++++++++++++ 6 files changed, 703 insertions(+), 30 deletions(-) create mode 100644 test/leaderboard-rollup-correction.test.js create mode 100644 test/sync-upload-batching.test.js diff --git a/migrations/20260817120000_account-session-states.sql b/migrations/20260817120000_account-session-states.sql index 7b8633b72..39490a845 100644 --- a/migrations/20260817120000_account-session-states.sql +++ b/migrations/20260817120000_account-session-states.sql @@ -8,16 +8,32 @@ -- bucket migration (10:00 -> 10:30) leaves the old hour stranded. -- -- Fix: canonical truth at the SESSION level. TRAE's usage API carries a --- stable session_id (PROVEN 2026-08-17: 137/137 sessions persisted across two --- independent fetches with 0 disappearances; one session was revised upward --- mid-window and KEPT its session_id; account responses have no duplicate --- ids; cross-window cross-checks return exact subsets). The CLI parser --- (src/lib/rollout.js) emits one queue record per CHANGED session --- (kind: "account_session_state"); the ingest edge upserts them here via --- tokentracker_upsert_account_session_states(). Identity is --- (user_id, source, session_id) - device_id is NOT part of the identity: the --- API request carries no device discriminator, so every device of the --- account observes the SAME server-side session namespace. +-- session_id whose observed stability splits into (evidence 2026-08-17, +-- one account, three real fetches 137 -> 141 -> 164 sessions): +-- repeated-fetch stability VERIFIED (137/137 persisted across two +-- independent fetches, 0 disappearances; +-- one session revised upward mid-window +-- KEPT its session_id) +-- cross-window stability VERIFIED (window-subset queries return +-- exact id subsets) +-- no duplicate ids OBSERVED (all fetched responses) +-- cross-device same-account NOT DIRECTLY VERIFIED: the request body +-- carries no device discriminator, but that +-- is necessary, not sufficient - a device +-- or login context could ride inside the +-- JWT / server auth context. No second +-- independent device/auth experiment was +-- run. If it were ever DISPROVEN (same +-- logical session, different ids per +-- device), this PK would split one logical +-- session into competing rows and the +-- identity must be re-evaluated. +-- The CLI parser (src/lib/rollout.js) emits one queue record per CHANGED +-- session (kind: "account_session_state"); the ingest edge upserts them +-- here via tokentracker_upsert_account_session_states(). Identity is +-- (user_id, source, session_id) - device_id is NOT part of the identity +-- (the API request carries no device discriminator; see the stability +-- split above for what that does and does not prove). -- -- Three corrections become ONE whole-row replace: -- downward S tokens 100 -> 60 row replaced, total 60 @@ -37,10 +53,10 @@ -- observation only when its stamp is STRICTLY NEWER (>) than the stored -- one, so a transport retry of an old observation is a no-op, and the same -- observation replayed is idempotent. This is best-effort cross-device --- ordering, NOT strict correctness: two devices with skewed clocks can --- mis-order two conflicting observations of one session (residual risk, --- bounded by the skew; observations of the same session converge because --- they reflect one server-side state). +-- ordering, NOT strict correctness: client clock skew can mis-order two +-- conflicting observations of one session, and a sufficiently +-- future-skewed client timestamp can delay later corrections (residual +-- risk; there is no mechanism bounding the skew). -- -- Semantics mirrored by src/lib/account-usage-dedup.js and pinned by -- test/account-usage-dedup.test.js. Apply BEFORE re-deploying the updated @@ -53,8 +69,10 @@ CREATE TABLE IF NOT EXISTS public.tokentracker_account_session_states ( user_id uuid NOT NULL, source text NOT NULL, - -- Stable provider-side session identity (TRAE session_id). Not a device - -- attribute: all devices of one account observe the same namespace. + -- Provider-side session identity (TRAE session_id): stable across + -- repeated fetches and window changes (VERIFIED), account-scoped rather + -- than device-scoped in the request (cross-device id stability itself is + -- NOT DIRECTLY VERIFIED - see the header's evidence split). session_id text NOT NULL, model text NOT NULL, -- Canonical UTC half-hour bucket of this session's CURRENT placement. @@ -152,9 +170,13 @@ GRANT EXECUTE ON FUNCTION public.tokentracker_upsert_account_session_states(uuid -- watermark-owner branch and the legacy MAX fallback for trae-cn. 'cursor' -- (identical rows across devices, no session identity) keeps the legacy -- whole-row MAX dedup. Historical rollup days self-heal through the existing --- 7-day repair loop in leaderboard_rollup_daily_advance_v2; an immediate --- full rebuild is optional (call leaderboard_rollup_daily_replace_v2 for the --- affected range). +-- cyclic repair in leaderboard_rollup_daily_advance_v2: 7 days per scheduled +-- run (total refresh, every ~6h) from the OLDEST history day, wrapping. The +-- lag therefore scales with TOTAL history length (e.g. ~28 days of history +-- repaired per day of wall clock), not with the correction's age; live paths +-- (account_usage_grouped, bounded leaderboard windows) reflect corrections +-- immediately. An immediate full rebuild is optional (call +-- leaderboard_rollup_daily_replace_v2 for the affected range). CREATE OR REPLACE FUNCTION public.leaderboard_hourly_dedup_v2( p_from timestamptz, p_to timestamptz diff --git a/src/commands/sync.js b/src/commands/sync.js index 3ae5e2e44..f3e25b0b4 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -3045,6 +3045,8 @@ function recordCodexColdScanAudit( module.exports = { cmdSync, acquireSyncLock, + readQueueBatch, + drainQueueToCloud, migrateCursorUnknownBuckets, migrateRolloutCumulativeDeltaBuckets, repairCodebuddyLogJsonlOverlap, @@ -3611,9 +3613,17 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { // one, and only the latest per session may reach the ingest batch (a // duplicate would make the cloud ON CONFLICT affect one row twice). // snapshot_verified_at rides along verbatim from the ORIGINAL fetch, so a - // transport retry never fakes logical freshness. (The ingest edge caps a - // batch at 500 states; a fresh device's first sync appends one record per - // observed session, well under the cap for a 30-day TRAE window.) + // transport retry never fakes logical freshness. + // + // Batching: session-state records count toward the SAME per-batch record + // cap (maxBuckets) as bucket rows. The ingest edge rejects a request with + // more than 500 states (HTTP 400); before they counted, a states-heavy + // queue (a fresh device's first 30-day TRAE sync appends one record per + // observed session) was read in ONE uncapped batch - the 400 left the + // queue offset untouched and every retry re-read the identical oversized + // request, a permanent upload failure. With the default batchSize of 200 + // (and the MAX_INGEST_BUCKETS=500 clamp above), every request stays under + // the edge cap while batching follows the existing design. const sessionStateMap = new Map(); let offset = startOffset; let linesRead = 0; @@ -3645,6 +3655,11 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) { ? { snapshot_verified_at: bucket.snapshot_verified_at } : {}), }); + // Counted like a bucket row: the record rides THIS batch and its + // bytes are covered by nextOffset, so the next batch resumes right + // after it. + linesRead += 1; + if (linesRead >= maxBuckets) break; } continue; } diff --git a/src/lib/account-usage-dedup.js b/src/lib/account-usage-dedup.js index 6a434d843..35e4d6c2d 100644 --- a/src/lib/account-usage-dedup.js +++ b/src/lib/account-usage-dedup.js @@ -18,8 +18,12 @@ * old hour). Canonical truth for trae-cn therefore lives at the SESSION * level (tokentracker_account_session_states; migrations/ * 20260817120000_account-session-states.sql): identity is - * (user_id, source, session_id) - device_id is NOT identity, because the - * usage API carries no device discriminator. Every observation of one + * (user_id, source, session_id) - device_id is NOT identity (the usage + * API request carries no device discriminator). Session-id evidence + * (2026-08-17): repeated-fetch stability VERIFIED, cross-window + * stability VERIFIED; cross-device same-account stability NOT DIRECTLY + * VERIFIED (no second independent device/auth experiment). Every + * observation of one * session whole-row-replaces the previous state under a strict LWW * guard, so the three correction classes collapse into ONE operation: * diff --git a/src/lib/rollout.js b/src/lib/rollout.js index 69fbcc055..54f487c3f 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -16604,12 +16604,15 @@ function assertTraeCnBucketCovers(bucketTotals, previousTotals) { // Cloud truth for trae-cn lives at the session level // (tokentracker_account_session_states; ingest edge upserts via // tokentracker_upsert_account_session_states): identity is -// (user_id, source, session_id) - device_id is NOT identity, because the -// usage API request carries no device discriminator, so every device of the -// account observes the same server-side session namespace (PROVEN -// 2026-08-17: 137/137 sessions persisted across two independent fetches, 0 -// disappearances, one session revised upward KEPT its id, no duplicate ids, -// cross-window queries return exact subsets). +// (user_id, source, session_id) - device_id is NOT identity (the usage API +// request carries no device discriminator). Evidence split (2026-08-17, one +// account, three real fetches 137 -> 141 -> 164): repeated-fetch id +// stability VERIFIED (137/137 persisted, corrections KEPT ids), cross-window +// stability VERIFIED (exact subsets), no duplicate ids OBSERVED. Cross-device +// same-account id stability is NOT DIRECTLY VERIFIED - no device +// discriminator in the request body is necessary but not sufficient (a +// device/login context could ride inside the JWT / server auth context), and +// no second independent device/auth experiment was run. // // The three correction classes collapse into ONE whole-row replace: // downward S tokens 100 -> 60 diff --git a/test/leaderboard-rollup-correction.test.js b/test/leaderboard-rollup-correction.test.js new file mode 100644 index 000000000..f0ff5758b --- /dev/null +++ b/test/leaderboard-rollup-correction.test.js @@ -0,0 +1,361 @@ +/** + * >7-day TRAE CN corrections vs the HISTORICAL rollup paths (PR #474). + * + * Product read paths under test (mirrored from the deployed SQL): + * + * account usage / profile / dashboard history + * -> account_usage_grouped(_cached) LIVE session states + * leaderboard week / month + * -> leaderboard_usage_grouped(bounded) LIVE leaderboard_hourly_dedup_v2 + * leaderboard TOTAL + * -> leaderboard_rollup_daily_v2 (closed days, materialized) + * UNION leaderboard_hourly_dedup_v2(v_through, now) (live tail) + * + * leaderboard_hourly_dedup_v2 aggregates trae-cn from + * tokentracker_account_session_states (migrations/ + * 20260817120000_account-session-states.sql), so LIVE reads reflect a + * correction immediately. Materialized rollup days only change when the + * cyclic repair in leaderboard_rollup_daily_advance_v2 (7 days per run, + * from the OLDEST history day, wrapping) reaches the corrected day - the + * claim "historical days self-heal" is true, but the lag scales with TOTAL + * history length (7 days repaired per scheduled 6h run), not with the + * correction's age. These tests pin BOTH facts: + * + * A. >7d downward correction rollup 100 -> 60 once repair covers the day + * B. >7d model migration rollup loses model-A, keeps model-B=100 + * C. cross-day bucket migration Day A 23:30 -> Day B 00:00: Day A drops + * the 100, Day B gains it, total stays 100 (transient dip possible + * while only one of the two days has been repaired) + * + * The session-state inputs come from the REAL parser + * (parseTraeCnApiIncremental -> queue -> LWW upsert mirror); the rollup/repair + * cycle below is the executable mirror of leaderboard_rollup_daily_advance_v2 + * / _replace_v2 / leaderboard_usage_grouped, and the trailing tests pin the + * deployed SQL itself so the mirror cannot drift silently. + */ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + upsertAccountSessionStates, + aggregateSessionStates, +} = require("../src/lib/account-usage-dedup"); +const { parseTraeCnApiIncremental } = require("../src/lib/rollout"); + +const ROOT = path.join(__dirname, ".."); +const DAY_MS = 24 * 60 * 60 * 1000; +const HALF_HOUR_MS = 30 * 60 * 1000; + +// Everything UTC-midnight aligned, like the SQL (date_trunc('day' ... UTC)). +const midnight = (ms) => Math.floor(ms / DAY_MS) * DAY_MS; + +function isoDay(ms) { + return new Date(midnight(ms)).toISOString().slice(0, 10); +} + +// --------------------------------------------------------------------------- +// Real parser -> queue -> LWW table (same harness shape as +// test/account-usage-dedup.test.js). +// --------------------------------------------------------------------------- +function tempQueue(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return { dir, queuePath: path.join(dir, "queue.jsonl") }; +} + +async function syncSessions(cloudStates, { sessions, verifiedAtMs }) { + const { dir, queuePath } = tempQueue("tokentracker-rollup-"); + try { + await parseTraeCnApiIncremental({ + sessions, + cursors: {}, + queuePath, + windowStartMs: verifiedAtMs - 40 * DAY_MS, + windowEndMs: verifiedAtMs + DAY_MS, + snapshotVerifiedAtMs: verifiedAtMs, + }); + const lines = fs + .readFileSync(queuePath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)); + const batch = lines.filter((r) => r.kind === "account_session_state"); + upsertAccountSessionStates(cloudStates, batch); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function traeSession(sessionId, usageTimeMs, overrides = {}) { + return { + session_id: sessionId, + model_name: "model-a", + usage_time: Math.floor(usageTimeMs / 1000), + input_token: 100, + output_token: 10, + cache_read_token: 0, + cache_write_token: 0, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Executable mirror of the rollup machinery. +// --------------------------------------------------------------------------- +const REPAIR_CHUNK_DAYS = 7; // advance_v2: interval '7 days' per run + +function createRollupSim(getStates, { minDayMs, nowMs }) { + // meta mirrors tokentracker_leaderboard_rollup_meta_v2; assume catch-up + // done (through = today), repair_from cycling from the oldest history day. + const target = () => midnight(nowMs); + const meta = { through: target(), repairFrom: midnight(minDayMs) }; + // rollup mirrors tokentracker_leaderboard_rollup_daily_v2: + // Map "day|source|model" -> {day, source, model, total_tokens} + const rollup = new Map(); + + function replaceDay(dayMs) { + const dayKey = isoDay(dayMs); + for (const key of [...rollup.keys()]) { + if (key.startsWith(dayKey + "|")) rollup.delete(key); + } + for (const row of aggregateSessionStates(getStates(), { + from: new Date(dayMs).toISOString(), + to: new Date(dayMs + DAY_MS).toISOString(), + })) { + rollup.set(`${dayKey}|${row.source}|${row.model}`, { + day: dayKey, + source: row.source, + model: row.model, + total_tokens: row.total_tokens, + }); + } + } + + return { + /** One scheduled advance run: repairs the next 7-day chunk cyclically. */ + advanceRun() { + const t = target(); + let from = meta.repairFrom >= t ? minDayMs : meta.repairFrom; + const until = Math.min(t, from + REPAIR_CHUNK_DAYS * DAY_MS); + for (let d = from; d < until; d += DAY_MS) replaceDay(d); + meta.repairFrom = until >= t ? minDayMs : until; + }, + /** Materialize [minDay, today) in one go (initial full build). */ + buildAll() { + const t = target(); + for (let d = minDayMs; d < t; d += DAY_MS) replaceDay(d); + meta.repairFrom = minDayMs; + }, + /** leaderboard TOTAL: materialized rollup UNION live tail [through, now). */ + leaderboardTotal() { + const perModel = new Map(); + for (const row of rollup.values()) { + perModel.set(row.model, (perModel.get(row.model) || 0) + row.total_tokens); + } + for (const row of aggregateSessionStates(getStates(), { + from: new Date(meta.through).toISOString(), + })) { + perModel.set(row.model, (perModel.get(row.model) || 0) + row.total_tokens); + } + return perModel; + }, + rollupDay(dayMs, model) { + return rollup.get(`${isoDay(dayMs)}|trae-cn|${model}`) || null; + }, + }; +} + +// Fixed "now" so days are deterministic: TODAY = 2027-02-01T00:00:00Z. +const NOW_MS = midnight(Date.parse("2027-02-01T12:00:00.000Z")); +const DAY = (offsetDays) => NOW_MS - offsetDays * DAY_MS; + +// A 120-day history: the repair cycle needs ceil/worst ~17 runs to cover a +// day-20 correction - the honest lag characterization for the total board. +const HISTORY_MIN_DAY = DAY(120); + +function scenarioSetup() { + const cloudStates = new Map(); + const sim = createRollupSim(() => cloudStates, { minDayMs: HISTORY_MIN_DAY, nowMs: NOW_MS }); + return { cloudStates, sim }; +} + +const V1 = Date.parse("2027-01-12T00:00:00.000Z"); // original fetch stamp +const V2 = Date.parse("2027-02-01T00:00:00.000Z"); // correction fetch stamp + +// --------------------------------------------------------------------------- +// A. >7-day downward correction. +// --------------------------------------------------------------------------- +test("A. >7d downward: live paths show 60 immediately; total rollup heals when the repair cycle reaches the day", async () => { + const { cloudStates, sim } = scenarioSetup(); + const correctedDay = DAY(20); + await syncSessions(cloudStates, { + sessions: [traeSession("s1", correctedDay + 10 * HALF_HOUR_MS)], + verifiedAtMs: V1, + }); + sim.buildAll(); + assert.equal(sim.leaderboardTotal().get("model-a"), 110, "rollup materialized 100+10"); + assert.ok(sim.rollupDay(correctedDay, "model-a"), "day-20 rollup row exists"); + + // TRAE revises S down to input 50 (=total 60) 20 days later. + await syncSessions(cloudStates, { + sessions: [traeSession("s1", correctedDay + 10 * HALF_HOUR_MS, { input_token: 50 })], + verifiedAtMs: V2, + }); + + // LIVE paths (account_usage_grouped / bounded leaderboard windows): + // corrected immediately, zero rollup involvement. + const liveRows = aggregateSessionStates(cloudStates); + assert.equal(liveRows.length, 1); + assert.equal(liveRows[0].total_tokens, 60, "live path shows 60 immediately"); + + // TOTAL path: stale until the cyclic repair reaches day-20. + let runs = 0; + const maxRuns = 200; + while (sim.leaderboardTotal().get("model-a") !== 60 && runs < maxRuns) { + sim.advanceRun(); + runs += 1; + } + assert.ok(runs > 0, "the total board did NOT flip without repair work"); + assert.equal(sim.leaderboardTotal().get("model-a"), 60, `total healed to 60 after ${runs} repair runs`); + // Honest lag pin: with 120 days of history and 7 days per run the + // corrected day is reached near the END of the cycle (>= 10 runs). + assert.ok(runs >= 10, `repair-cycle lag is real (took ${runs} scheduled runs, not immediate)`); + assert.equal(sim.rollupDay(correctedDay, "model-a").total_tokens, 60, "materialized day-20 row itself is 60"); +}); + +// --------------------------------------------------------------------------- +// B. >7-day model migration. +// --------------------------------------------------------------------------- +test("B. >7d model migration: repaired rollup keeps ONLY model-B (no permanent model-A residue)", async () => { + const { cloudStates, sim } = scenarioSetup(); + const correctedDay = DAY(20); + await syncSessions(cloudStates, { + sessions: [traeSession("s1", correctedDay + 10 * HALF_HOUR_MS)], + verifiedAtMs: V1, + }); + sim.buildAll(); + assert.ok(sim.rollupDay(correctedDay, "model-a"), "model-A row materialized"); + + await syncSessions(cloudStates, { + sessions: [traeSession("s1", correctedDay + 10 * HALF_HOUR_MS, { model_name: "model-b" })], + verifiedAtMs: V2, + }); + + // Live bounded path already migrated. + const liveByModel = new Map(aggregateSessionStates(cloudStates).map((r) => [r.model, r.total_tokens])); + assert.deepEqual([...liveByModel.keys()].sort(), ["model-b"]); + + let runs = 0; + while ((!sim.leaderboardTotal().has("model-b") || sim.leaderboardTotal().has("model-a")) && runs < 200) { + sim.advanceRun(); + runs += 1; + } + const total = sim.leaderboardTotal(); + assert.equal(total.get("model-a"), undefined, "no permanent model-A residue after repair"); + assert.equal(total.get("model-b"), 110, "model-B carries the session exactly once"); + assert.equal(sim.rollupDay(correctedDay, "model-a"), null, "the materialized model-A row is deleted by the day rebuild"); + assert.equal(sim.rollupDay(correctedDay, "model-b").total_tokens, 110); +}); + +// --------------------------------------------------------------------------- +// C. Cross-day bucket migration (Day A 23:30 -> Day B 00:00). +// --------------------------------------------------------------------------- +test("C. cross-day bucket migration: Day A drops the 100, Day B gains it, total stays 100", async () => { + const { cloudStates, sim } = scenarioSetup(); + const dayA = DAY(21); // 23:30 slot lives on day A + const dayB = DAY(20); // 00:00 slot lives on day B (the next UTC day) + const slotA = dayA + 23 * HALF_HOUR_MS + HALF_HOUR_MS; // 23:30 on day A + const slotB = dayB; // 00:00 on day B + await syncSessions(cloudStates, { + sessions: [traeSession("s1", slotA)], + verifiedAtMs: V1, + }); + sim.buildAll(); + assert.equal(sim.rollupDay(dayA, "model-a").total_tokens, 110, "100+10 sits on day A"); + assert.equal(sim.rollupDay(dayB, "model-a"), null, "day B empty"); + + // TRAE moves the session across the UTC-day boundary. + await syncSessions(cloudStates, { + sessions: [traeSession("s1", slotB)], + verifiedAtMs: V2, + }); + + // Live path: only day B's bucket carries the session now. + const liveRows = aggregateSessionStates(cloudStates); + assert.equal(liveRows.length, 1); + assert.equal(Date.parse(liveRows[0].hour_start), Math.floor(slotB / HALF_HOUR_MS) * HALF_HOUR_MS); + assert.equal(liveRows[0].total_tokens, 110); + + // Total path: day A and day B sit in the same 7-day repair chunk here, so + // one run covering both heals them together; a chunk BOUNDARY between the + // two days would transiently dip (documented, not a correctness bug - the + // canonical state itself is single-copy). + let runs = 0; + const healed = () => sim.rollupDay(dayA, "model-a") === null + && sim.rollupDay(dayB, "model-a") + && sim.rollupDay(dayB, "model-a").total_tokens === 110; + while (!healed() && runs < 200) { + sim.advanceRun(); + runs += 1; + } + assert.ok(healed(), `cross-day migration healed after ${runs} repair runs`); + assert.equal(sim.leaderboardTotal().get("model-a"), 110, "total still counts the session exactly once"); +}); + +// --------------------------------------------------------------------------- +// SQL contract pins: the deployed functions must actually implement the +// semantics mirrored above (the JS mirror cannot be allowed to drift). +// --------------------------------------------------------------------------- +function readRepoFile(rel) { + return fs.readFileSync(path.join(ROOT, rel), "utf8"); +} + +test("SQL pin: replace_v2 rebuilds closed days from leaderboard_hourly_dedup_v2 (which reads trae-cn session states)", () => { + const sql = readRepoFile("migrations/20260804043427_align-leaderboard-machine-clusters.sql"); + const replaceFn = sql.split("CREATE OR REPLACE FUNCTION public.leaderboard_rollup_daily_replace_v2")[1].split("$func$;")[0]; + assert.match(replaceFn, /DELETE FROM public\.tokentracker_leaderboard_rollup_daily_v2/, "a rebuild DELETEs the materialized day (downward/model corrections clean up)"); + assert.match(replaceFn, /FROM public\.leaderboard_hourly_dedup_v2\(v_day, v_day \+ interval '1 day'\)/, "days are re-aggregated from the dedup function"); + + const sessionStates = readRepoFile("migrations/20260817120000_account-session-states.sql"); + const dedupFn = sessionStates.split("CREATE OR REPLACE FUNCTION public.leaderboard_hourly_dedup_v2")[1].split("$func$;")[0]; + assert.match(dedupFn, /FROM tokentracker_account_session_states s/, "the dedup function's trae-cn branch reads canonical session states"); + assert.match(dedupFn, /s\.source = 'trae-cn'/); +}); + +test("SQL pin: advance_v2 repairs 7 days per run cyclically from the OLDEST history day (lag scales with history length)", () => { + const sql = readRepoFile("migrations/20260804043427_align-leaderboard-machine-clusters.sql"); + const advanceFn = sql.split("CREATE OR REPLACE FUNCTION public.leaderboard_rollup_daily_advance_v2")[1].split("$func$;")[0]; + assert.match(advanceFn, /interval '7 days'/, "7 days per scheduled run"); + assert.match(advanceFn, /v_min_day/, "repair starts at the oldest history day"); + assert.match(advanceFn, /v_repair_from := v_min_day/, "repair wraps to the oldest day after completing a cycle"); +}); + +test("SQL pin: leaderboard TOTAL reads rollup UNION live tail; bounded windows read live", () => { + const sql = readRepoFile("migrations/20260804043427_align-leaderboard-machine-clusters.sql"); + const groupedFn = sql.split("CREATE OR REPLACE FUNCTION public.leaderboard_usage_grouped")[1].split("$func$;")[0]; + assert.match(groupedFn, /FROM public\.tokentracker_leaderboard_rollup_daily_v2 r/, "total = materialized rollup..."); + assert.match(groupedFn, /FROM public\.leaderboard_hourly_dedup_v2\(v_through, p_to\) t/, "...UNION the live tail"); + // The rollup+tail branch requires an all-time range; bounded windows take + // the live-only ELSE branch. + assert.match(groupedFn, /p_from < '1980-01-01'::timestamptz AND p_to >= v_through/); + + const refresh = readRepoFile("dashboard/edge-patches/tokentracker-leaderboard-refresh.ts"); + assert.match(refresh, /if \(period === "total"\)/, "only the total period advances the rollup"); + assert.match(refresh, /leaderboard_rollup_daily_advance_v2/); + assert.match(refresh, /from_day: "1970-01-01"/, "total uses the all-time sentinel that selects the rollup branch"); +}); + +test("SQL pin: profile / account views read the LIVE account RPC (corrections visible immediately)", () => { + const profile = readRepoFile("dashboard/edge-patches/tokentracker-leaderboard-profile.ts"); + assert.match(profile, /account_usage_grouped/, "profile uses the live account RPC"); + const summary = readRepoFile("dashboard/edge-patches/tokentracker-account-summary.ts"); + assert.match(summary, /account_usage_grouped/, "account summary uses the live account RPC"); + const rpc = readRepoFile("scripts/ops/account-usage-grouped-rpc.sql"); + assert.match(rpc, /FROM tokentracker_account_session_states s/, "the account RPC's trae-cn branch reads session states"); + assert.doesNotMatch(rpc, /leaderboard_rollup_daily_v2/, "the account RPC never reads the materialized rollup"); +}); diff --git a/test/sync-upload-batching.test.js b/test/sync-upload-batching.test.js new file mode 100644 index 000000000..364a867d9 --- /dev/null +++ b/test/sync-upload-batching.test.js @@ -0,0 +1,268 @@ +/** + * Production-path batching regression for account_session_state uploads. + * + * Real chain under test (only the network boundary is mocked): + * + * queue.jsonl -> readQueueBatch() -> drainQueueToCloud() -> fetch(ingest) + * + * The ingest edge (dashboard/edge-patches/tokentracker-ingest.ts) rejects a + * request with > 500 account_session_states (HTTP 400) and > 500 hourly + * buckets. Before this regression, session-state records did NOT count + * toward readQueueBatch's per-batch record cap, so a states-heavy queue + * (e.g. a fresh device's first 30-day TRAE sync) produced ONE oversized + * request; the 400 left the queue offset untouched, and every retry re-read + * the identical oversized batch - a PERMANENT upload failure. + * + * These tests enforce the fixed contract instead: + * - every request carries <= 500 states (with the default batchSize of + * 200, batching follows the existing design: <= 200 records per batch), + * - batching makes bounded forward progress and loses nothing, + * - nextOffset only crosses records actually included in the request, + * - a states-only batch uploads normally, + * - an HTTP failure freezes the offset at the failed batch; a retry + * resumes from exactly there (no skip, no permanent 400), + * - observations stay canonical per request (no duplicate session ids in + * one request), keeping cloud LWW replay idempotent. + */ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { readQueueBatch, drainQueueToCloud } = require("../src/commands/sync"); + +const INGEST_STATE_LIMIT = 500; // edge contract: account_session_states.length <= 500 +const BATCH_SIZE = 200; // production default (cmdSync autoUploadDecision.batchSize) + +function tempQueue(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return { dir, queuePath: path.join(dir, "queue.jsonl"), queueStatePath: path.join(dir, "queue-state.json") }; +} + +function stateRecord(sessionId, { model = "model-a", bucketStart = "2027-01-10T10:00:00.000Z", input = 100, verifiedAt = "2027-01-15T00:00:00.000Z" } = {}) { + return { + kind: "account_session_state", + source: "trae-cn", + session_id: sessionId, + model, + bucket_start: bucketStart, + input_tokens: input, + output_tokens: 10, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: input + 10, + snapshot_verified_at: verifiedAt, + }; +} + +function bucketRow(hourStart, total = 50) { + return { source: "trae-cn", model: "model-a", hour_start: hourStart, total_tokens: total, input_tokens: total, output_tokens: 0 }; +} + +/** + * Mock ingest that mirrors the REAL edge contract (payload caps + response + * shape). Records every request's buckets/states for assertions. + */ +function installIngestMock({ failFirstNBatches = 0 } = {}) { + const calls = []; + const realFetch = globalThis.fetch; + let failuresLeft = failFirstNBatches; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(init.body); + const buckets = Array.isArray(body.hourly) ? body.hourly : []; + const states = Array.isArray(body.account_session_states) ? body.account_session_states : []; + if (buckets.length > 500 || states.length > INGEST_STATE_LIMIT) { + calls.push({ buckets, states, rejected: true }); + return { + ok: false, + status: 400, + headers: { get: () => null }, + text: () => Promise.resolve(JSON.stringify({ error: "Too many buckets or account session states" })), + }; + } + if (failuresLeft > 0) { + failuresLeft -= 1; + calls.push({ buckets, states, failed: true }); + return { + ok: false, + status: 503, + headers: { get: () => null }, + text: () => Promise.resolve(JSON.stringify({ error: "transient" })), + }; + } + calls.push({ buckets, states }); + return { + ok: true, + status: 200, + headers: { get: () => null }, + text: () => Promise.resolve(JSON.stringify({ inserted: buckets.length + states.length, skipped: 0 })), + }; + }; + return { + calls, + restore() { + globalThis.fetch = realFetch; + }, + }; +} + +function writeQueue(queuePath, records) { + fs.writeFileSync(queuePath, records.map((r) => JSON.stringify(r)).join("\n") + "\n"); +} + +test("1200 states-only queue: bounded batches, every request <= 500, nothing lost, no permanent 400", async (t) => { + const { dir, queuePath, queueStatePath } = tempQueue("tokentracker-batching-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const total = 1200; + writeQueue(queuePath, Array.from({ length: total }, (_, i) => stateRecord(`s-${i}`))); + + const ingest = installIngestMock(); + t.after(() => ingest.restore()); + + // Production drain defaults: maxBatches=5 per invocation, batchSize=200. + let drained = 0; + let guard = 0; + while (drained < total && guard < 20) { + const result = await drainQueueToCloud({ baseUrl: "https://t.example", deviceToken: "tok", queuePath, queueStatePath, maxBatches: 5, batchSize: BATCH_SIZE }); + const state = JSON.parse(fs.readFileSync(queueStatePath, "utf8")); + drained = state.offset >= fs.statSync(queuePath).size ? total : drained + result.inserted + result.skipped; + guard += 1; + if (result.batches === 0) break; + } + + // No request ever exceeded the ingest cap... + assert.ok(ingest.calls.length > 0, "requests were made"); + for (const call of ingest.calls) { + assert.ok(!call.rejected, `request rejected by ingest cap (states=${call.states.length})`); + assert.ok(call.states.length <= INGEST_STATE_LIMIT, `states per request <= ${INGEST_STATE_LIMIT}`); + assert.ok(call.buckets.length <= 500, "buckets per request <= 500"); + } + // ...all 1200 distinct sessions were delivered exactly once... + const delivered = ingest.calls.flatMap((c) => c.states.map((s) => s.session_id)); + assert.equal(delivered.length, total, "no loss, no duplicate delivery"); + assert.equal(new Set(delivered).size, total, "every session delivered exactly once"); + // ...no request duplicated a session id (canonical per request)... + for (const call of ingest.calls) { + assert.equal(new Set(call.states.map((s) => s.session_id)).size, call.states.length, "one observation per session per request"); + } + // ...and the drain made bounded forward progress to the end of the queue. + const finalState = JSON.parse(fs.readFileSync(queueStatePath, "utf8")); + assert.ok(finalState.offset >= fs.statSync(queuePath).size, "offset reached the end of the queue"); +}); + +test("states-only batch uploads normally (no bucket rows required)", async (t) => { + const { dir, queuePath, queueStatePath } = tempQueue("tokentracker-states-only-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + writeQueue(queuePath, Array.from({ length: 10 }, (_, i) => stateRecord(`s-${i}`))); + + const ingest = installIngestMock(); + t.after(() => ingest.restore()); + + const result = await drainQueueToCloud({ baseUrl: "https://t.example", deviceToken: "tok", queuePath, queueStatePath, maxBatches: 5, batchSize: BATCH_SIZE }); + assert.ok(result.batches >= 1, "states-only queue still uploads"); + assert.equal(ingest.calls.filter((c) => !c.failed && !c.rejected).length, 1); + assert.equal(ingest.calls[0].buckets.length, 0, "no bucket rows in a states-only queue"); + assert.equal(ingest.calls[0].states.length, 10); +}); + +test("mid-drain HTTP failure: offset freezes at the failed batch, retry resumes exactly there", async (t) => { + const { dir, queuePath, queueStatePath } = tempQueue("tokentracker-recover-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const total = 450; // 3 batches of 200/200/50 at batchSize=200 + const records = Array.from({ length: total }, (_, i) => stateRecord(`s-${i}`)); + writeQueue(queuePath, records); + // Cumulative byte offset after the first N records (file bytes, incl. \n). + const offsetAfter = (n) => records.slice(0, n).reduce((acc, r) => acc + JSON.stringify(r).length + 1, 0); + + // First drain: batch 1 succeeds, batch 2 fails (503), drain aborts. + // Custom mock: only the SECOND request fails. + const realFetch = globalThis.fetch; + const calls = []; + let requestIndex = 0; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(init.body); + const states = Array.isArray(body.account_session_states) ? body.account_session_states : []; + const buckets = Array.isArray(body.hourly) ? body.hourly : []; + requestIndex += 1; + const fail = requestIndex === 2; // first batch OK, second batch fails + calls.push({ buckets, states, failed: fail }); + if (fail) { + return { ok: false, status: 503, headers: { get: () => null }, text: () => Promise.resolve(JSON.stringify({ error: "transient" })) }; + } + return { ok: true, status: 200, headers: { get: () => null }, text: () => Promise.resolve(JSON.stringify({ inserted: states.length, skipped: 0 })) }; + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + await assert.rejects( + () => drainQueueToCloud({ baseUrl: "https://t.example", deviceToken: "tok", queuePath, queueStatePath, maxBatches: 5, batchSize: BATCH_SIZE }), + (err) => err.status === 503, + ); + // Exactly one batch landed; the offset crossed ONLY that batch's records. + assert.equal(calls.filter((c) => !c.failed).length, 1); + const stateAfterFailure = JSON.parse(fs.readFileSync(queueStatePath, "utf8")); + assert.equal(stateAfterFailure.offset, offsetAfter(200), "offset crossed exactly the first batch's 200 records"); + + // Retry: everything from the failed batch onward now succeeds. + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(init.body); + const states = Array.isArray(body.account_session_states) ? body.account_session_states : []; + calls.push({ buckets: [], states }); + return { ok: true, status: 200, headers: { get: () => null }, text: () => Promise.resolve(JSON.stringify({ inserted: states.length, skipped: 0 })) }; + }; + await drainQueueToCloud({ baseUrl: "https://t.example", deviceToken: "tok", queuePath, queueStatePath, maxBatches: 5, batchSize: BATCH_SIZE }); + + const successful = calls.filter((c) => !c.failed); + const delivered = successful.flatMap((c) => c.states.map((s) => s.session_id)); + assert.equal(new Set(delivered).size, total, "every session eventually delivered exactly once across the failure boundary"); + assert.equal(delivered.length, total, "no loss and no duplicate delivery"); + const finalState = JSON.parse(fs.readFileSync(queueStatePath, "utf8")); + assert.ok(finalState.offset >= fs.statSync(queuePath).size, "retry drains to the end"); +}); + +test("mixed queue: buckets and states both count toward the batch cap; nextOffset crosses only included records", async (t) => { + const { dir, queuePath, queueStatePath } = tempQueue("tokentracker-mixed-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + // 150 buckets then 100 states: with the cap counting BOTH record kinds, + // the first batch must stop at 200 records total (150 buckets + 50 + // states), and the second batch carries the remaining 50 states. + const records = [ + // Distinct half-hours: bucket rows are keyed by (source, model, hour) + // and would otherwise legitimately dedup in place. + ...Array.from({ length: 150 }, (_, i) => + bucketRow(new Date(Date.parse("2027-01-10T00:00:00.000Z") + i * 30 * 60 * 1000).toISOString()), + ), + ...Array.from({ length: 100 }, (_, i) => stateRecord(`s-${i}`)), + ]; + writeQueue(queuePath, records); + + const first = await readQueueBatch(queuePath, 0, BATCH_SIZE); + assert.equal(first.buckets.length, 150); + assert.equal(first.sessionStates.length, 50, "states fill the remainder of the capped batch"); + assert.equal(first.sessionStates[0].session_id, "s-0"); + + const second = await readQueueBatch(queuePath, first.nextOffset, BATCH_SIZE); + assert.equal(second.buckets.length, 0); + assert.equal(second.sessionStates.length, 50, "the tail states come back in the next batch"); + assert.equal(second.sessionStates[0].session_id, "s-50"); +}); + +test("duplicate observations of one session: last-wins within a batch, never duplicated inside one request", async (t) => { + const { dir, queuePath, queueStatePath } = tempQueue("tokentracker-dup-"); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const older = stateRecord("dup-1", { input: 100, verifiedAt: "2027-01-15T00:00:00.000Z" }); + const newer = stateRecord("dup-1", { input: 60, verifiedAt: "2027-01-16T00:00:00.000Z" }); + writeQueue(queuePath, [older, newer, stateRecord("other-1")]); + + const batch = await readQueueBatch(queuePath, 0, BATCH_SIZE); + const ids = batch.sessionStates.map((s) => s.session_id); + assert.equal(ids.length, 2); + assert.equal(new Set(ids).size, 2, "no duplicate session id inside one request"); + const dup = batch.sessionStates.find((s) => s.session_id === "dup-1"); + assert.equal(dup.input_tokens, 60, "last-wins: the newer observation is the one delivered"); +}); From 67ed4ea174a3a28e78624ae983e2e5be67a8b2ad Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 12:16:12 +0800 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20leaderboard=20TOTAL=20=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=20TRAE=2030=20=E5=A4=A9=20seed=20=E6=AC=A0=E8=AE=A1?= =?UTF-8?q?=20=E2=86=92=20advance=5Fv2=20seed-gap=20=E4=BC=98=E5=85=88?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次 sync 一次 seed ~30 个闭环日 session states:account/profile/bounded board 走实时路径立即可见,但 leaderboard TOTAL 读物化 rollup ∪ live tail (tail 只覆盖今天),闭环 seed 日两边都不覆盖 → TOTAL 欠计整个 seed, 旧 cyclic repair 需等最多一个完整周期(history_days/7 次 run)才覆盖。 最小修复:advance_v2(在 migration 中 supersede 重定义)检测"有 trae-cn session states 但无 trae-cn rollup 行"的最早闭环日,把 7 天 repair 窗口 跳到该处——seed 范围确定性 ceil(span/7) 次调度内重建(30 天 = 5 次 ≈30h),无需等整周期。已覆盖日(仅 stale 值)不构成 gap,修正仍走 原 cyclic 调度;单次 run 工作量仍为 7 天块,不新建 invalidation 架构。 Scenario D regression:修复前 TOTAL=0 vs account=3300(欠计证明); run 1 精确修 seed 最老 7 天(770);5 次后 TOTAL=account=3300。 SQL pin 改钉最新 advance_v2 定义(cyclic + gap 优先)。 --- .../20260817120000_account-session-states.sql | 134 +++++++++++++++++- test/leaderboard-rollup-correction.test.js | 101 ++++++++++++- 2 files changed, 223 insertions(+), 12 deletions(-) diff --git a/migrations/20260817120000_account-session-states.sql b/migrations/20260817120000_account-session-states.sql index 39490a845..d90925f06 100644 --- a/migrations/20260817120000_account-session-states.sql +++ b/migrations/20260817120000_account-session-states.sql @@ -169,13 +169,18 @@ GRANT EXECUTE ON FUNCTION public.tokentracker_upsert_account_session_states(uuid -- the leaderboard - the session-state branch replaces both the previous -- watermark-owner branch and the legacy MAX fallback for trae-cn. 'cursor' -- (identical rows across devices, no session identity) keeps the legacy --- whole-row MAX dedup. Historical rollup days self-heal through the existing --- cyclic repair in leaderboard_rollup_daily_advance_v2: 7 days per scheduled --- run (total refresh, every ~6h) from the OLDEST history day, wrapping. The --- lag therefore scales with TOTAL history length (e.g. ~28 days of history --- repaired per day of wall clock), not with the correction's age; live paths --- (account_usage_grouped, bounded leaderboard windows) reflect corrections --- immediately. An immediate full rebuild is optional (call +-- whole-row MAX dedup. Historical rollup days self-heal through the cyclic +-- repair in leaderboard_rollup_daily_advance_v2 (superseded below): 7 days +-- per scheduled run (total refresh, every ~6h) from the OLDEST history day, +-- wrapping. FIRST SEEDS are prioritized: when a closed day has trae-cn +-- session states but no trae-cn rollup row yet, the repair window jumps +-- there, so a new account's first ~30-day seed heals in +-- ceil(seed_span / 7) scheduled runs (~30h at the 6h cadence) instead of a +-- full cycle. Corrections to already-covered days (stale values, row exists) +-- keep the ordinary cyclic schedule - their lag scales with TOTAL history +-- length, not the correction's age. Live paths (account_usage_grouped, +-- bounded leaderboard windows) reflect everything immediately. An immediate +-- full rebuild is optional for instant parity (call -- leaderboard_rollup_daily_replace_v2 for the affected range). CREATE OR REPLACE FUNCTION public.leaderboard_hourly_dedup_v2( p_from timestamptz, @@ -284,3 +289,118 @@ AS $func$ AND s.source = 'trae-cn' GROUP BY s.user_id, s.source, s.model, s.bucket_start $func$; + +-- --------------------------------------------------------------------------- +-- leaderboard_rollup_daily_advance_v2: supersede the 20260804043427 definition +-- with FIRST-SEED PRIORITIZATION for trae-cn. Everything else (7-day catch-up +-- bootstrap, cyclic 7-day repair from the oldest history day) is unchanged. +-- +-- Why: a new account's first TRAE sync seeds ~30 CLOSED days of session +-- states in one upload. Live paths (account_usage_grouped, bounded +-- leaderboard windows) read session states directly and see the seed +-- immediately, but the leaderboard TOTAL reads the MATERIALIZED rollup for +-- closed days - which has never covered them. The plain cyclic repair would +-- reach the seed only after up to a FULL cycle (history_days / 7 scheduled +-- runs), leaving the TOTAL undercounted versus account/profile for days. +-- The fix is a repositioning only: when the earliest closed day that HAS +-- trae-cn session states but NO trae-cn rollup row exists, the repair window +-- JUMPS there. The seed range then rebuilds deterministically at 7 days per +-- scheduled run (ceil(seed_span / 7) runs; a 30-day seed = 5 runs ~= 30h at +-- the ~6h total-refresh cadence). Corrections to already-covered days are +-- NOT gaps (their rollup row exists, merely stale) and keep the ordinary +-- cyclic schedule; per-run work stays bounded by the same 7-day chunk, so +-- the memory/statement-time budget that motivated the cyclic design is +-- unchanged. Mirrored by the createRollupSim harness and pinned by scenario +-- D in test/leaderboard-rollup-correction.test.js. +CREATE OR REPLACE FUNCTION public.leaderboard_rollup_daily_advance_v2() +RETURNS void +LANGUAGE plpgsql +SET work_mem TO '16MB' +SET hash_mem_multiplier TO '2' +SET statement_timeout TO '25s' +AS $func$ +DECLARE + v_from timestamptz; + v_target timestamptz := date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + v_until timestamptz; + v_min_day date; + v_repair_from date; + v_seed_gap_day date; +BEGIN + SELECT + (date_trunc('day', MIN(h.hour_start) AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')::date + INTO v_min_day + FROM public.tokentracker_hourly h; + v_min_day := COALESCE(v_min_day, (v_target AT TIME ZONE 'UTC')::date); + + SELECT m.through, m.repair_from INTO v_from, v_repair_from + FROM public.tokentracker_leaderboard_rollup_meta_v2 m + WHERE m.id = 1 + FOR UPDATE; + + IF v_from IS NULL THEN + v_from := v_min_day::timestamp AT TIME ZONE 'UTC'; + v_repair_from := v_min_day; + INSERT INTO public.tokentracker_leaderboard_rollup_meta_v2 ( + id, through, repair_from, rebuilt_at + ) VALUES (1, v_from, v_repair_from, now()); + END IF; + + IF v_from < v_target THEN + -- Bootstrap/catch-up: advance at most seven closed days per request. + v_until := LEAST(v_target, v_from + interval '7 days'); + PERFORM public.leaderboard_rollup_daily_replace_v2(v_from, v_until); + UPDATE public.tokentracker_leaderboard_rollup_meta_v2 + SET through = v_until, + rebuilt_at = now() + WHERE id = 1; + RETURN; + END IF; + + -- Once caught up, continuously repair seven historical days per scheduled + -- total refresh. Late history uploads, device revocations, and cluster-map + -- changes therefore self-heal without another whole-history memory spike. + IF v_repair_from >= (v_target AT TIME ZONE 'UTC')::date THEN + v_repair_from := v_min_day; + END IF; + + -- First-seed / uncovered-day prioritization (trae-cn): jump the repair + -- window to the earliest CLOSED day that has trae-cn session states but + -- no trae-cn rollup row yet (see the function header comment). A day + -- whose rollup row exists with stale values is NOT a gap - corrections + -- keep the ordinary cyclic schedule. + SELECT MIN((s.bucket_start AT TIME ZONE 'UTC')::date) INTO v_seed_gap_day + FROM public.tokentracker_account_session_states s + WHERE s.source = 'trae-cn' + AND (s.bucket_start AT TIME ZONE 'UTC')::date < (v_target AT TIME ZONE 'UTC')::date + AND NOT EXISTS ( + SELECT 1 FROM public.tokentracker_leaderboard_rollup_daily_v2 r + WHERE r.source = 'trae-cn' + AND r.day = (s.bucket_start AT TIME ZONE 'UTC')::date + ); + IF v_seed_gap_day IS NOT NULL THEN + v_repair_from := v_seed_gap_day; + END IF; + + v_until := LEAST( + v_target, + (v_repair_from::timestamp AT TIME ZONE 'UTC') + interval '7 days' + ); + PERFORM public.leaderboard_rollup_daily_replace_v2( + v_repair_from::timestamp AT TIME ZONE 'UTC', + v_until + ); + UPDATE public.tokentracker_leaderboard_rollup_meta_v2 + SET repair_from = CASE + WHEN v_until >= v_target THEN v_min_day + ELSE (v_until AT TIME ZONE 'UTC')::date + END, + rebuilt_at = now() + WHERE id = 1; +END +$func$; + +REVOKE ALL ON FUNCTION public.leaderboard_rollup_daily_advance_v2() + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.leaderboard_rollup_daily_advance_v2() + TO project_admin; diff --git a/test/leaderboard-rollup-correction.test.js b/test/leaderboard-rollup-correction.test.js index f0ff5758b..189493e8d 100644 --- a/test/leaderboard-rollup-correction.test.js +++ b/test/leaderboard-rollup-correction.test.js @@ -26,6 +26,13 @@ * C. cross-day bucket migration Day A 23:30 -> Day B 00:00: Day A drops * the 100, Day B gains it, total stays 100 (transient dip possible * while only one of the two days has been repaired) + * D. FIRST 30-day seed on a caught-up rollup: live paths (account / + * profile / bounded boards) include the seed immediately, but the + * leaderboard TOTAL (rollup UNION live tail) misses every CLOSED seed + * day until a repair rebuilds them. The seed-gap PRIORITIZATION in + * advance_v2 (repair jumps to the earliest uncovered trae-cn day) + * heals the seed deterministically in ceil(seed_days / 7) scheduled + * runs instead of waiting a full cycle. * * The session-state inputs come from the REAL parser * (parseTraeCnApiIncremental -> queue -> LWW upsert mirror); the rollup/repair @@ -137,10 +144,28 @@ function createRollupSim(getStates, { minDayMs, nowMs }) { } return { - /** One scheduled advance run: repairs the next 7-day chunk cyclically. */ + // Seed-gap prioritization (mirrors the superseding advance_v2): the + // earliest CLOSED day with trae-cn session states but no trae-cn rollup + // row yet jumps the repair window there; stale-value corrections (row + // exists) keep the cyclic schedule. + minSeedGapDay() { + const t = target(); + let gap = null; + for (const state of getStates().values()) { + const dayMs = midnight(Date.parse(state.bucket_start)); + if (dayMs >= t) continue; + const covered = [...rollup.keys()].some( + (key) => key.startsWith(isoDay(dayMs) + "|trae-cn|"), + ); + if (!covered && (gap === null || dayMs < gap)) gap = dayMs; + } + return gap; + }, + /** One scheduled advance run: gap-prioritized, else the cyclic chunk. */ advanceRun() { const t = target(); - let from = meta.repairFrom >= t ? minDayMs : meta.repairFrom; + const gap = this.minSeedGapDay(); + let from = gap !== null ? gap : (meta.repairFrom >= t ? minDayMs : meta.repairFrom); const until = Math.min(t, from + REPAIR_CHUNK_DAYS * DAY_MS); for (let d = from; d < until; d += DAY_MS) replaceDay(d); meta.repairFrom = until >= t ? minDayMs : until; @@ -307,6 +332,59 @@ test("C. cross-day bucket migration: Day A drops the 100, Day B gains it, total assert.equal(sim.leaderboardTotal().get("model-a"), 110, "total still counts the session exactly once"); }); +// --------------------------------------------------------------------------- +// D. FIRST 30-day seed vs the leaderboard TOTAL (rollup UNION live tail). +// --------------------------------------------------------------------------- +test("D. first 30-day seed: account/profile/bounded immediate; TOTAL undercounts closed days until the seed-gap-prioritized repair completes", async () => { + const { cloudStates, sim } = scenarioSetup(); + // Preconditions (review scenario): rollup caught up (through = today), + // no trae-cn session states, no trae-cn rollup rows. The 120-day history + // below is OTHER sources; the cyclic repair position sits at the OLDEST + // day, so without prioritization the seed would wait a full cycle. + sim.buildAll(); + + // First TRAE sync: one real parser call seeds ~30 CLOSED days at once + // (one session per day, 110 tokens each). + const seedDays = 30; + const sessions = []; + for (let k = seedDays; k >= 1; k -= 1) { + sessions.push(traeSession(`seed-${k}`, DAY(k) + 10 * 60 * 60 * 1000)); + } + await syncSessions(cloudStates, { sessions, verifiedAtMs: V2 }); + assert.equal(cloudStates.size, seedDays, "the first sync seeded 30 canonical sessions"); + + // LIVE paths: everything except the leaderboard TOTAL is immediate. + const accountTotal = aggregateSessionStates(cloudStates).reduce((acc, r) => acc + r.total_tokens, 0); + assert.equal(accountTotal, seedDays * 110, "account usage total = 3300 immediately (profile reads the same RPC)"); + const weekRows = aggregateSessionStates(cloudStates, { + from: new Date(DAY(7)).toISOString(), + to: new Date(NOW_MS).toISOString(), + }); + assert.equal(weekRows.reduce((acc, r) => acc + r.total_tokens, 0), 7 * 110, "bounded leaderboard week includes its seed days immediately"); + + // TOTAL before any repair: rollup has no trae-cn rows and the live tail + // only covers [today 00:00, now) - every seed day is closed, so the TOTAL + // UNDERCOUNTS by the full seed. + const before = sim.leaderboardTotal().get("model-a") || 0; + assert.equal(before, 0, "leaderboard TOTAL undercounts the first seed entirely (all 30 days are closed days)"); + + // The seed-gap prioritization must repair from the SEED's oldest day (not + // the cyclic position 120 days back): after one run exactly the first + // 7 seed days are materialized. + sim.advanceRun(); + const afterRun1 = sim.leaderboardTotal().get("model-a") || 0; + assert.equal(afterRun1, 7 * 110, "run 1 repairs the seed's oldest 7 days (gap-first, 7-day chunk)"); + + // Deterministic completion: ceil(30/7) = 5 scheduled runs total. + let runs = 1; + while ((sim.leaderboardTotal().get("model-a") || 0) !== seedDays * 110 && runs < 50) { + sim.advanceRun(); + runs += 1; + } + assert.equal(sim.leaderboardTotal().get("model-a"), seedDays * 110, "TOTAL now equals the account/profile total"); + assert.equal(runs, 5, "a 30-day seed heals in ceil(30/7)=5 scheduled runs (not a full 120-day cycle)"); +}); + // --------------------------------------------------------------------------- // SQL contract pins: the deployed functions must actually implement the // semantics mirrored above (the JS mirror cannot be allowed to drift). @@ -314,6 +392,9 @@ test("C. cross-day bucket migration: Day A drops the 100, Day B gains it, total function readRepoFile(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } +function readRepoFile(rel) { + return fs.readFileSync(path.join(ROOT, rel), "utf8"); +} test("SQL pin: replace_v2 rebuilds closed days from leaderboard_hourly_dedup_v2 (which reads trae-cn session states)", () => { const sql = readRepoFile("migrations/20260804043427_align-leaderboard-machine-clusters.sql"); @@ -327,12 +408,22 @@ test("SQL pin: replace_v2 rebuilds closed days from leaderboard_hourly_dedup_v2 assert.match(dedupFn, /s\.source = 'trae-cn'/); }); -test("SQL pin: advance_v2 repairs 7 days per run cyclically from the OLDEST history day (lag scales with history length)", () => { - const sql = readRepoFile("migrations/20260804043427_align-leaderboard-machine-clusters.sql"); +test("SQL pin: advance_v2 (superseding definition) repairs 7 days per run, cyclically, with first-seed gap prioritization", () => { + // The 20260817120000 migration supersedes the 0804 advance_v2: pin the + // LATEST definition the deployment will actually leave in the database. + const sql = readRepoFile("migrations/20260817120000_account-session-states.sql"); const advanceFn = sql.split("CREATE OR REPLACE FUNCTION public.leaderboard_rollup_daily_advance_v2")[1].split("$func$;")[0]; - assert.match(advanceFn, /interval '7 days'/, "7 days per scheduled run"); + assert.match(advanceFn, /interval '7 days'/, "7 days per scheduled run (catch-up AND repair)"); assert.match(advanceFn, /v_min_day/, "repair starts at the oldest history day"); assert.match(advanceFn, /v_repair_from := v_min_day/, "repair wraps to the oldest day after completing a cycle"); + // First-seed prioritization: jump to the earliest closed trae-cn day that + // has session states but no rollup row yet. + assert.match(advanceFn, /v_seed_gap_day/, "seed-gap day is computed"); + assert.match(advanceFn, /FROM public\.tokentracker_account_session_states s/, "gaps come from the canonical session states"); + assert.match(advanceFn, /NOT EXISTS[\s\S]*?r\.source = 'trae-cn'/, "a day is a gap only when NO trae-cn rollup row exists (stale values are NOT gaps)"); + assert.match(advanceFn, /v_repair_from := v_seed_gap_day/, "the repair window jumps to the seed gap"); + // Only the window POSITION changes: the per-run chunk stays 7 days. + assert.match(advanceFn, /v_seed_gap_day[\s\S]*?v_until := LEAST\([\s\S]*?interval '7 days'/, "gap-jump still repairs a bounded 7-day chunk"); }); test("SQL pin: leaderboard TOTAL reads rollup UNION live tail; bounded windows read live", () => { From 672f02a67c4b3681abb52afded26e19003234c84 Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 12:25:52 +0800 Subject: [PATCH 18/19] chore: remove duplicated readRepoFile declaration in rollup correction test --- test/leaderboard-rollup-correction.test.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/leaderboard-rollup-correction.test.js b/test/leaderboard-rollup-correction.test.js index 189493e8d..064ebf0de 100644 --- a/test/leaderboard-rollup-correction.test.js +++ b/test/leaderboard-rollup-correction.test.js @@ -392,9 +392,6 @@ test("D. first 30-day seed: account/profile/bounded immediate; TOTAL undercounts function readRepoFile(rel) { return fs.readFileSync(path.join(ROOT, rel), "utf8"); } -function readRepoFile(rel) { - return fs.readFileSync(path.join(ROOT, rel), "utf8"); -} test("SQL pin: replace_v2 rebuilds closed days from leaderboard_hourly_dedup_v2 (which reads trae-cn session states)", () => { const sql = readRepoFile("migrations/20260804043427_align-leaderboard-machine-clusters.sql"); From cb26a6bde290b439f511aee499f74c61d43081ee Mon Sep 17 00:00:00 2001 From: KunWei <2088220700@qq.com> Date: Mon, 17 Aug 2026 12:39:20 +0800 Subject: [PATCH 19/19] =?UTF-8?q?fix:=20seed-gap=20=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E6=8C=89=20user=5Fid=20=E9=9A=94=E7=A6=BB=EF=BC=88=E5=8F=A6?= =?UTF-8?q?=E4=B8=80=E7=94=A8=E6=88=B7=E5=90=8C=E6=97=A5=20rollup=20?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E5=86=8D=E9=81=AE=E8=94=BD=E6=96=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E9=A6=96=E6=AC=A1=20seed=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 672f02a6 的 advance_v2 seed-gap NOT EXISTS 只关联 (source, day):User A 已有某日 trae-cn rollup 行时,User B 同日首次 seed 被误判"已覆盖", 拿不到 first-seed prioritized repair,退回普通 cyclic(多等最多一整 个周期)。 最小修复:NOT EXISTS 增加 r.user_id = s.user_id(rollup PK 本就以 user_id 开头,覆盖判定天然 per-user)。cyclic correction、batching、 freshness、absence、session PK 均不变。 Scenario E(可执行 regression,非仅 SQL pin):sim 改为镜像真实双表 (rollup key 加 user 维度,per-account 聚合),User A Day X 已有行 + User B Day X 首次 seed → 修复前 minSeedGapDay()=null(bug 复现)、 TOTAL 欠计 110;修复后 Day X 仍为 gap,run 1 精确 materialize user-b 行(220=A+B),user-a 行不变。SQL pin 钉 r.user_id = s.user_id。 --- .../20260817120000_account-session-states.sql | 11 +- test/leaderboard-rollup-correction.test.js | 144 ++++++++++++++---- 2 files changed, 120 insertions(+), 35 deletions(-) diff --git a/migrations/20260817120000_account-session-states.sql b/migrations/20260817120000_account-session-states.sql index d90925f06..fed4aed69 100644 --- a/migrations/20260817120000_account-session-states.sql +++ b/migrations/20260817120000_account-session-states.sql @@ -366,16 +366,19 @@ BEGIN -- First-seed / uncovered-day prioritization (trae-cn): jump the repair -- window to the earliest CLOSED day that has trae-cn session states but - -- no trae-cn rollup row yet (see the function header comment). A day - -- whose rollup row exists with stale values is NOT a gap - corrections - -- keep the ordinary cyclic schedule. + -- no trae-cn rollup row yet (see the function header comment). Coverage + -- is PER USER (the rollup PK starts at user_id): another user's same-day + -- row does NOT cover this user's seed. A day whose rollup row exists for + -- that user (even with stale values) is NOT a gap - corrections keep the + -- ordinary cyclic schedule. SELECT MIN((s.bucket_start AT TIME ZONE 'UTC')::date) INTO v_seed_gap_day FROM public.tokentracker_account_session_states s WHERE s.source = 'trae-cn' AND (s.bucket_start AT TIME ZONE 'UTC')::date < (v_target AT TIME ZONE 'UTC')::date AND NOT EXISTS ( SELECT 1 FROM public.tokentracker_leaderboard_rollup_daily_v2 r - WHERE r.source = 'trae-cn' + WHERE r.user_id = s.user_id + AND r.source = 'trae-cn' AND r.day = (s.bucket_start AT TIME ZONE 'UTC')::date ); IF v_seed_gap_day IS NOT NULL THEN diff --git a/test/leaderboard-rollup-correction.test.js b/test/leaderboard-rollup-correction.test.js index 064ebf0de..ee0fafc55 100644 --- a/test/leaderboard-rollup-correction.test.js +++ b/test/leaderboard-rollup-correction.test.js @@ -33,6 +33,9 @@ * advance_v2 (repair jumps to the earliest uncovered trae-cn day) * heals the seed deterministically in ceil(seed_days / 7) scheduled * runs instead of waiting a full cycle. + * E. MULTI-USER isolation of that prioritization: gap coverage is PER + * USER (r.user_id = s.user_id) - another user's same-day trae-cn + * rollup row does not cover a new user's first seed day. * * The session-state inputs come from the REAL parser * (parseTraeCnApiIncremental -> queue -> LWW upsert mirror); the rollup/repair @@ -116,13 +119,16 @@ function traeSession(sessionId, usageTimeMs, overrides = {}) { // --------------------------------------------------------------------------- const REPAIR_CHUNK_DAYS = 7; // advance_v2: interval '7 days' per run -function createRollupSim(getStates, { minDayMs, nowMs }) { +// getAccounts() -> [{ userId, states }] - one entry per user, mirroring +// the real shared tables (rollup PK (user_id, source, model, day); session +// states PK (user_id, source, session_id)). +function createRollupSim(getAccounts, { minDayMs, nowMs }) { // meta mirrors tokentracker_leaderboard_rollup_meta_v2; assume catch-up // done (through = today), repair_from cycling from the oldest history day. const target = () => midnight(nowMs); const meta = { through: target(), repairFrom: midnight(minDayMs) }; // rollup mirrors tokentracker_leaderboard_rollup_daily_v2: - // Map "day|source|model" -> {day, source, model, total_tokens} + // Map "day|source|model|user" -> {user_id, day, source, model, total_tokens} const rollup = new Map(); function replaceDay(dayMs) { @@ -130,34 +136,43 @@ function createRollupSim(getStates, { minDayMs, nowMs }) { for (const key of [...rollup.keys()]) { if (key.startsWith(dayKey + "|")) rollup.delete(key); } - for (const row of aggregateSessionStates(getStates(), { - from: new Date(dayMs).toISOString(), - to: new Date(dayMs + DAY_MS).toISOString(), - })) { - rollup.set(`${dayKey}|${row.source}|${row.model}`, { - day: dayKey, - source: row.source, - model: row.model, - total_tokens: row.total_tokens, - }); + for (const { userId, states } of getAccounts()) { + for (const row of aggregateSessionStates(states, { + from: new Date(dayMs).toISOString(), + to: new Date(dayMs + DAY_MS).toISOString(), + })) { + rollup.set(`${dayKey}|${row.source}|${row.model}|${userId}`, { + user_id: userId, + day: dayKey, + source: row.source, + model: row.model, + total_tokens: row.total_tokens, + }); + } } } return { // Seed-gap prioritization (mirrors the superseding advance_v2): the - // earliest CLOSED day with trae-cn session states but no trae-cn rollup - // row yet jumps the repair window there; stale-value corrections (row - // exists) keep the cyclic schedule. + // earliest CLOSED day where SOME user has trae-cn session states but NO + // trae-cn rollup row OF THEIR OWN jumps the repair window there. + // Coverage is PER USER (r.user_id = s.user_id in the SQL): another + // user's same-day row does not cover this user's seed. Stale-value + // corrections (the user's own row exists) keep the cyclic schedule. minSeedGapDay() { const t = target(); let gap = null; - for (const state of getStates().values()) { - const dayMs = midnight(Date.parse(state.bucket_start)); - if (dayMs >= t) continue; - const covered = [...rollup.keys()].some( - (key) => key.startsWith(isoDay(dayMs) + "|trae-cn|"), - ); - if (!covered && (gap === null || dayMs < gap)) gap = dayMs; + for (const { userId, states } of getAccounts()) { + for (const state of states.values()) { + const dayMs = midnight(Date.parse(state.bucket_start)); + if (dayMs >= t) continue; + const covered = [...rollup.keys()].some( + (key) => + key.startsWith(isoDay(dayMs) + "|trae-cn|") && + key.endsWith("|" + userId), + ); + if (!covered && (gap === null || dayMs < gap)) gap = dayMs; + } } return gap; }, @@ -182,15 +197,17 @@ function createRollupSim(getStates, { minDayMs, nowMs }) { for (const row of rollup.values()) { perModel.set(row.model, (perModel.get(row.model) || 0) + row.total_tokens); } - for (const row of aggregateSessionStates(getStates(), { - from: new Date(meta.through).toISOString(), - })) { - perModel.set(row.model, (perModel.get(row.model) || 0) + row.total_tokens); + for (const { states } of getAccounts()) { + for (const row of aggregateSessionStates(states, { + from: new Date(meta.through).toISOString(), + })) { + perModel.set(row.model, (perModel.get(row.model) || 0) + row.total_tokens); + } } return perModel; }, - rollupDay(dayMs, model) { - return rollup.get(`${isoDay(dayMs)}|trae-cn|${model}`) || null; + rollupDay(dayMs, model, userId = "user-a") { + return rollup.get(`${isoDay(dayMs)}|trae-cn|${model}|${userId}`) || null; }, }; } @@ -204,9 +221,14 @@ const DAY = (offsetDays) => NOW_MS - offsetDays * DAY_MS; const HISTORY_MIN_DAY = DAY(120); function scenarioSetup() { - const cloudStates = new Map(); - const sim = createRollupSim(() => cloudStates, { minDayMs: HISTORY_MIN_DAY, nowMs: NOW_MS }); - return { cloudStates, sim }; + const cloudStates = new Map(); // user a (scenarios A-D are single-user) + const cloudStatesB = new Map(); // user b (multi-user scenario E) + const accounts = () => [ + { userId: "user-a", states: cloudStates }, + { userId: "user-b", states: cloudStatesB }, + ]; + const sim = createRollupSim(accounts, { minDayMs: HISTORY_MIN_DAY, nowMs: NOW_MS }); + return { cloudStates, cloudStatesB, sim }; } const V1 = Date.parse("2027-01-12T00:00:00.000Z"); // original fetch stamp @@ -385,6 +407,65 @@ test("D. first 30-day seed: account/profile/bounded immediate; TOTAL undercounts assert.equal(runs, 5, "a 30-day seed heals in ceil(30/7)=5 scheduled runs (not a full 120-day cycle)"); }); +// --------------------------------------------------------------------------- +// E. MULTI-USER first-seed isolation: the seed-gap check must be PER USER. +// User A already has a trae-cn rollup row for day X; User B's first sync +// seeds day X with NO row of their own. Day X must STILL be a seed gap +// for User B (gap identity is (user_id, day), not just day) so the +// prioritized repair window covers User B's seed instead of falling back +// to the ordinary cyclic position. +// --------------------------------------------------------------------------- +test("E. multi-user first-seed: User B's day X stays a seed gap despite User A's same-day trae-cn rollup row", async () => { + const { cloudStates: userA, cloudStatesB: userB, sim } = scenarioSetup(); + // Both users' rows live in the SAME deployed tables; the gap check must + // correlate coverage by user_id, not by day alone. + + const seedDay = DAY(10); + // User A: day X already covered by the materialized rollup. + await syncSessions(userA, { + sessions: [traeSession("usera-day10", seedDay + 5 * 60 * 60 * 1000)], + verifiedAtMs: V1, + }); + sim.buildAll(); + assert.ok(sim.rollupDay(seedDay, "model-a"), "User A already has a trae-cn rollup row for day X"); + assert.equal(sim.leaderboardTotal().get("model-a"), 110, "TOTAL covers User A only"); + + // User B: FIRST trae-cn sync seeds the SAME day, no user-b rollup row. + await syncSessions(userB, { + sessions: [traeSession("userb-day10", seedDay + 12 * 60 * 60 * 1000)], + verifiedAtMs: V2, + }); + assert.equal( + aggregateSessionStates(userB)[0].total_tokens, + 110, + "User B's live account total is immediate (session states)", + ); + assert.equal( + sim.leaderboardTotal().get("model-a"), + 110, + "TOTAL still undercounts User B before repair (closed day, no user-b rollup row)", + ); + + // The regression: day X is a gap FOR USER B even though User A's row + // covers the same (source, day). A day-only existence check (the 672f02a6 + // SQL) would see "covered" and silently demote User B to cyclic repair. + assert.equal(sim.minSeedGapDay(), seedDay, "day X is still a seed gap for User B (per-user coverage)"); + + // The prioritized repair must jump to User B's seed day, not the cyclic + // position 120 days back: one run materializes user-b's day-X row while + // User A's own row is rebuilt unchanged. + sim.advanceRun(); + const userBRow = sim.rollupDay(seedDay, "model-a", "user-b"); + assert.ok(userBRow, "user-b's own day-X rollup row is materialized"); + assert.equal(userBRow.total_tokens, 110, "user-b's row carries their seed usage"); + assert.equal(sim.rollupDay(seedDay, "model-a", "user-a").total_tokens, 110, "user-a's row is untouched"); + assert.equal( + sim.leaderboardTotal().get("model-a"), + 220, + "run 1 prioritized User B's seed day (110 A + 110 B)", + ); +}); + // --------------------------------------------------------------------------- // SQL contract pins: the deployed functions must actually implement the // semantics mirrored above (the JS mirror cannot be allowed to drift). @@ -418,6 +499,7 @@ test("SQL pin: advance_v2 (superseding definition) repairs 7 days per run, cycli assert.match(advanceFn, /v_seed_gap_day/, "seed-gap day is computed"); assert.match(advanceFn, /FROM public\.tokentracker_account_session_states s/, "gaps come from the canonical session states"); assert.match(advanceFn, /NOT EXISTS[\s\S]*?r\.source = 'trae-cn'/, "a day is a gap only when NO trae-cn rollup row exists (stale values are NOT gaps)"); + assert.match(advanceFn, /r\.user_id = s\.user_id/, "gap coverage is PER USER: another user's same-day row must not hide this user's seed (scenario E)"); assert.match(advanceFn, /v_repair_from := v_seed_gap_day/, "the repair window jumps to the seed gap"); // Only the window POSITION changes: the per-run chunk stays 7 days. assert.match(advanceFn, /v_seed_gap_day[\s\S]*?v_until := LEAST\([\s\S]*?interval '7 days'/, "gap-jump still repairs a bounded 7-day chunk");