From 132dc94332ee909d73e62fe69f573744e8c2aa6d Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Thu, 23 Jul 2026 11:48:24 -0700 Subject: [PATCH 1/5] Harden CLI reliability for 0.2.0 --- .github/workflows/ci.yml | 97 +- .github/workflows/release-cli.yml | 141 ++ README.md | 9 +- apps/web/__tests__/api/usage-devices.test.ts | 224 ++ .../web/__tests__/api/usage-submit-v2.test.ts | 388 +++ apps/web/__tests__/api/usage-submit.test.ts | 2107 ++--------------- .../web/__tests__/flows/cli-push-flow.test.ts | 125 +- .../__tests__/flows/web-import-flow.test.ts | 29 +- apps/web/__tests__/integration/db.ts | 22 +- .../integration/usage-submit.test.ts | 761 +++++- .../__tests__/unit/migration-safety.test.ts | 39 +- apps/web/__tests__/unit/usage-import.test.ts | 30 + .../web/__tests__/unit/usage-protocol.test.ts | 156 ++ apps/web/app/(app)/settings/import/page.tsx | 17 +- apps/web/app/api/usage/devices/auth.ts | 44 + .../app/api/usage/devices/resolve/route.ts | 150 ++ apps/web/app/api/usage/devices/route.ts | 103 + apps/web/app/api/usage/submit/route.ts | 1340 +++++------ apps/web/lib/usage-import.ts | 18 + bun.lock | 4 +- docs/API.md | 107 +- docs/CHANGELOG.md | 13 + docs/CLI.md | 161 +- docs/CLI_OPERATIONS.md | 98 + docs/SECURITY.md | 2 + docs/audit-2026-07-23.md | 433 ++++ packages/cli/README.md | 54 +- packages/cli/__tests__/api.test.ts | 137 +- packages/cli/__tests__/args.test.ts | 64 + packages/cli/__tests__/auth.test.ts | 75 +- .../cli/__tests__/auto-push-logger.test.ts | 19 + .../cli/__tests__/background-command.test.ts | 51 + packages/cli/__tests__/calendar.test.ts | 35 + packages/cli/__tests__/ccusage.test.ts | 205 +- packages/cli/__tests__/commands/auto.test.ts | 30 +- .../cli/__tests__/commands/devices.test.ts | 71 + packages/cli/__tests__/commands/login.test.ts | 119 +- packages/cli/__tests__/commands/push.test.ts | 750 +++--- .../cli/__tests__/flows/cli-sync-flow.test.ts | 165 +- packages/cli/__tests__/hooks.test.ts | 36 +- packages/cli/__tests__/machine-id.test.ts | 53 + packages/cli/__tests__/prompt.test.ts | 29 + .../__tests__/resolve-push-date-range.test.ts | 25 +- packages/cli/__tests__/scheduler.test.ts | 176 +- packages/cli/__tests__/sync-state.test.ts | 144 ++ packages/cli/__tests__/telemetry.test.ts | 26 +- packages/cli/package.json | 26 +- packages/cli/scripts/benchmark-cli.mjs | 85 + packages/cli/scripts/benchmark-collector.mjs | 164 ++ packages/cli/scripts/packaged-cli-e2e.mjs | 154 +- packages/cli/src/commands/auto.ts | 88 +- packages/cli/src/commands/devices.ts | 156 ++ packages/cli/src/commands/login.ts | 118 +- packages/cli/src/commands/push.ts | 1208 ++++++---- packages/cli/src/index.ts | 94 +- packages/cli/src/lib/api.ts | 352 ++- packages/cli/src/lib/args.ts | 226 ++ packages/cli/src/lib/auth.ts | 242 +- packages/cli/src/lib/auto-push-logger.ts | 34 +- packages/cli/src/lib/background-command.ts | 38 + packages/cli/src/lib/calendar.ts | 93 + packages/cli/src/lib/ccusage.ts | 445 +++- packages/cli/src/lib/hooks.ts | 118 +- packages/cli/src/lib/machine-id.ts | 96 +- packages/cli/src/lib/posthog.ts | 5 +- packages/cli/src/lib/prompt.ts | 7 + packages/cli/src/lib/scheduler.ts | 277 ++- packages/cli/src/lib/sync-state.ts | 499 ++++ packages/cli/src/lib/telemetry.ts | 45 +- packages/cli/tsup.config.ts | 6 +- packages/shared/package.json | 5 + packages/shared/src/index.ts | 1 + packages/shared/src/usage-protocol.ts | 781 ++++++ papercuts.md | 9 + .../20260723133731_usage_submission_v2.sql | 886 +++++++ .../20260723135641_usage_reconciliation.sql | 1173 +++++++++ 76 files changed, 12110 insertions(+), 4203 deletions(-) create mode 100644 .github/workflows/release-cli.yml create mode 100644 apps/web/__tests__/api/usage-devices.test.ts create mode 100644 apps/web/__tests__/api/usage-submit-v2.test.ts create mode 100644 apps/web/__tests__/unit/usage-import.test.ts create mode 100644 apps/web/__tests__/unit/usage-protocol.test.ts create mode 100644 apps/web/app/api/usage/devices/auth.ts create mode 100644 apps/web/app/api/usage/devices/resolve/route.ts create mode 100644 apps/web/app/api/usage/devices/route.ts create mode 100644 apps/web/lib/usage-import.ts create mode 100644 docs/CLI_OPERATIONS.md create mode 100644 docs/audit-2026-07-23.md create mode 100644 packages/cli/__tests__/args.test.ts create mode 100644 packages/cli/__tests__/background-command.test.ts create mode 100644 packages/cli/__tests__/calendar.test.ts create mode 100644 packages/cli/__tests__/commands/devices.test.ts create mode 100644 packages/cli/__tests__/machine-id.test.ts create mode 100644 packages/cli/__tests__/prompt.test.ts create mode 100644 packages/cli/__tests__/sync-state.test.ts create mode 100644 packages/cli/scripts/benchmark-cli.mjs create mode 100644 packages/cli/scripts/benchmark-collector.mjs create mode 100644 packages/cli/src/commands/devices.ts mode change 100755 => 100644 packages/cli/src/commands/push.ts create mode 100644 packages/cli/src/lib/args.ts create mode 100644 packages/cli/src/lib/background-command.ts create mode 100644 packages/cli/src/lib/calendar.ts create mode 100644 packages/cli/src/lib/sync-state.ts create mode 100644 packages/shared/src/usage-protocol.ts create mode 100644 papercuts.md create mode 100644 supabase/migrations/20260723133731_usage_submission_v2.sql create mode 100644 supabase/migrations/20260723135641_usage_reconciliation.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2006c49b..cbc3f069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -18,7 +21,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - uses: actions/cache@v4 with: @@ -28,7 +31,7 @@ jobs: ${{ runner.os }}-bun- - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile env: BUN_INSTALL_CACHE_DIR: ~/.bun/install/cache @@ -71,10 +74,6 @@ jobs: NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: sb_publishable_placeholder SUPABASE_SECRET_KEY: sb_secret_placeholder - - name: Test (cli) - run: bun run test - working-directory: packages/cli - - name: Setup Supabase CLI uses: supabase/setup-cli@v1 with: @@ -99,3 +98,89 @@ jobs: NEXT_PUBLIC_SUPABASE_URL: http://localhost:54321 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: sb_publishable_placeholder SUPABASE_SECRET_KEY: sb_secret_placeholder + + cli-package: + name: CLI package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.3 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck CLI + run: bun run typecheck + working-directory: packages/cli + + - name: Test CLI + run: bun run test + working-directory: packages/cli + + - name: Benchmark collector fixture + run: bun run benchmark:collector > ../../collector-benchmark.json + working-directory: packages/cli + env: + STRAUDE_COLLECTOR_BENCH_ITERATIONS: "3" + + - name: Upload collector benchmark + uses: actions/upload-artifact@v4 + with: + name: straude-collector-benchmark-${{ github.sha }} + path: collector-benchmark.json + if-no-files-found: error + retention-days: 14 + + - name: Pack release candidate + run: | + mkdir -p ../../artifacts + npm pack --json --pack-destination ../../artifacts + working-directory: packages/cli + + - name: Upload exact package + uses: actions/upload-artifact@v4 + with: + name: straude-cli-package + path: artifacts/*.tgz + if-no-files-found: error + + - name: Upload CI source map + uses: actions/upload-artifact@v4 + with: + name: straude-cli-sourcemap-${{ github.sha }} + path: packages/cli/dist/index.js.map + if-no-files-found: error + retention-days: 14 + + cli-package-matrix: + name: CLI package (${{ matrix.os }}, Node ${{ matrix.node }}) + needs: cli-package + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - uses: actions/download-artifact@v4 + with: + name: straude-cli-package + path: artifacts + + - name: Test installed package + run: node packages/cli/scripts/packaged-cli-e2e.mjs --tarball artifacts diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml new file mode 100644 index 00000000..b979d150 --- /dev/null +++ b/.github/workflows/release-cli.yml @@ -0,0 +1,141 @@ +name: Release CLI + +on: + push: + tags: + - "straude@*" + +permissions: + contents: read + +jobs: + package: + name: Build release candidate + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.3 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Verify tag matches package version + run: | + node -e "const pkg = require('./packages/cli/package.json'); const expected = 'straude@' + pkg.version; if (process.env.GITHUB_REF_NAME !== expected) { throw new Error('Expected tag ' + expected + ', got ' + process.env.GITHUB_REF_NAME); }" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck CLI + run: bun run typecheck + working-directory: packages/cli + + - name: Test CLI + run: bun run test + working-directory: packages/cli + + - name: Pack once + run: | + mkdir -p ../../artifacts + npm pack --json --pack-destination ../../artifacts + cd ../../artifacts + sha256sum ./*.tgz > SHA256SUMS + working-directory: packages/cli + + - name: Upload exact package + uses: actions/upload-artifact@v4 + with: + name: straude-cli-package + path: | + artifacts/*.tgz + artifacts/SHA256SUMS + if-no-files-found: error + + - name: Upload CI source map + uses: actions/upload-artifact@v4 + with: + name: straude-cli-sourcemap-${{ github.sha }} + path: packages/cli/dist/index.js.map + if-no-files-found: error + retention-days: 30 + + package-matrix: + name: Verify (${{ matrix.os }}, Node ${{ matrix.node }}) + needs: package + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: [20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - uses: actions/download-artifact@v4 + with: + name: straude-cli-package + path: artifacts + + - name: Test installed package + run: node packages/cli/scripts/packaged-cli-e2e.mjs --tarball artifacts + + publish: + name: Publish exact package + needs: package-matrix + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - uses: actions/download-artifact@v4 + with: + name: straude-cli-package + path: artifacts + + - name: Publish to npm with provenance + run: | + VERSION=$(node -p "require('./packages/cli/package.json').version") + LOCAL_INTEGRITY=$(node -e "const crypto = require('node:crypto'); const fs = require('node:fs'); const file = fs.readdirSync('artifacts').find((name) => name.endsWith('.tgz')); if (!file) throw new Error('Missing package artifact'); process.stdout.write('sha512-' + crypto.createHash('sha512').update(fs.readFileSync('artifacts/' + file)).digest('base64'));") + PUBLISHED_INTEGRITY=$(npm view "straude@${VERSION}" dist.integrity --json 2>/dev/null | tr -d '"' || true) + if [ -n "$PUBLISHED_INTEGRITY" ]; then + if [ "$PUBLISHED_INTEGRITY" != "$LOCAL_INTEGRITY" ]; then + echo "::error::straude@${VERSION} already exists with different integrity" + exit 1 + fi + echo "straude@${VERSION} is already published with matching integrity" + else + npm publish artifacts/*.tgz --access public --provenance + fi + + - name: Create matching GitHub release + run: | + VERSION=$(node -p "require('./packages/cli/package.json').version") + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" artifacts/*.tgz artifacts/SHA256SUMS --clobber + else + gh release create "$GITHUB_REF_NAME" artifacts/*.tgz artifacts/SHA256SUMS \ + --verify-tag \ + --generate-notes \ + --title "Straude CLI v${VERSION}" + fi + env: + GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index 1017ac4e..0f12c0e8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ npx straude@latest The CLI reads your local [ccusage](https://github.com/ccusage/ccusage) data (cost, tokens, models, sessions), uploads it to Straude, and auto-creates a post on your feed. That includes every source ccusage detects, currently Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, and Gemini CLI. First run opens a browser login; after that, just run `npx straude@latest` daily. It automatically pushes new stats since your last sync. -Options: `--date YYYY-MM-DD` to push a specific date, `--days N` to backfill the last N days (max 7), `--dry-run` to preview without posting. Run `npx straude@latest status` to check your streak and rank. +The first sync reads three days. Normal smart syncs resume after the last committed date and process up to seven contiguous days per run, so repeated runs catch up without skipping dates. `--days N` can explicitly backfill up to 30 days. Use `--date YYYY-MM-DD` for one day or `--dry-run` to preview without posting. Run `npx straude@latest status` to check your streak and rank. ## Features @@ -91,7 +91,7 @@ The CLI runs [ccusage](https://github.com/ccusage/ccusage) locally on your machi ### Can Straude see my code or prompts? -No. The data pipeline is: local JSONL logs → ccusage (local aggregation) → daily totals sent to Straude. At no point does any conversation content, prompt text, code, or file path leave your machine. You can verify this yourself — the CLI is open source, and you can run `npx straude --dry-run` to see exactly what would be sent before it's sent. +No. The data pipeline is: local JSONL logs → ccusage (local aggregation) → daily totals sent to Straude. Conversation content, prompt text, and code stay on your machine. Aggregate operational telemetry is documented separately in the [CLI reference](docs/CLI.md#telemetry), and you can run `npx straude --dry-run` to inspect the usage payload before it is submitted. ### Is my profile public by default? @@ -105,7 +105,7 @@ Straude is an entry in [**Built with Opus 4.6: a Claude Code hackathon**](https: ### Prerequisites -- [Bun](https://bun.sh/) (v1.3+) +- [Bun](https://bun.sh/) 1.3.3 - [Supabase CLI](https://supabase.com/docs/guides/local-development/cli/getting-started) (v2.x) - [Docker](https://docs.docker.com/get-docker/) (required by Supabase local) @@ -113,7 +113,7 @@ Straude is an entry in [**Built with Opus 4.6: a Claude Code hackathon**](https: ```bash # 1. Install dependencies -bun install +bun install --frozen-lockfile # 2. Start local Supabase (Postgres, Auth, Storage via Docker) bun run local:up @@ -138,6 +138,7 @@ The app will be available at `http://localhost:3000`. | Document | Description | |----------|-------------| | [Changelog](docs/CHANGELOG.md) | Release history and what changed | +| [CLI operations](docs/CLI_OPERATIONS.md) | Protocol rollout, alerts, repair, rollback, and audit closure | | [Decisions](docs/DECISIONS.md) | Architecture and design decisions with rationale | | [Roadmap](docs/ROADMAP.md) | Planned features and future work | | [Security](docs/SECURITY.md) | Security audit findings and status | diff --git a/apps/web/__tests__/api/usage-devices.test.ts b/apps/web/__tests__/api/usage-devices.test.ts new file mode 100644 index 00000000..519e2381 --- /dev/null +++ b/apps/web/__tests__/api/usage-devices.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api/cli-auth", () => ({ + verifyCliTokenWithRefresh: vi.fn(), +})); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +const rpc = vi.fn(); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ rpc })), +})); + +import { GET } from "@/app/api/usage/devices/route"; +import { POST } from "@/app/api/usage/devices/resolve/route"; +import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; +import { createClient } from "@/lib/supabase/server"; + +const CANDIDATE_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const CANDIDATE = { + id: CANDIDATE_ID, + device_id_a: "11111111-2222-4333-8444-555555555555", + device_id_b: "66666666-7777-4888-8999-000000000000", + normalized_hostname: "work-macbook", + overlap_dates: ["2026-07-21", "2026-07-22"], + status: "pending", + created_at: "2026-07-23T10:00:00.000Z", +}; + +function cliRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { + authorization: "Bearer fixture", + ...init?.headers, + }, + }); +} + +function webSession(userId: string | null) { + vi.mocked(createClient).mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: userId ? { id: userId } : null }, + }), + }, + } as never); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyCliTokenWithRefresh).mockReturnValue(null); + webSession(null); +}); + +describe("GET /api/usage/devices", () => { + it("lists only the authenticated CLI user's reconciliation candidates", async () => { + vi.mocked(verifyCliTokenWithRefresh).mockReturnValue({ + userId: "cli-user", + username: "cli", + refreshedToken: "refreshed", + }); + rpc.mockResolvedValue({ data: [CANDIDATE], error: null }); + + const response = await GET(cliRequest("http://localhost/api/usage/devices")); + + expect(response.status).toBe(200); + expect(response.headers.get("X-Straude-Refreshed-Token")).toBe("refreshed"); + expect(await response.json()).toEqual({ candidates: [CANDIDATE] }); + expect(rpc).toHaveBeenCalledWith("list_usage_device_candidates", { + p_user_id: "cli-user", + }); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("supports an authenticated web session", async () => { + webSession("web-user"); + rpc.mockResolvedValue({ data: [], error: null }); + + const response = await GET(new Request("http://localhost/api/usage/devices")); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ candidates: [] }); + expect(rpc).toHaveBeenCalledWith("list_usage_device_candidates", { + p_user_id: "web-user", + }); + }); + + it("does not let an invalid CLI bearer token fall through to cookie auth", async () => { + webSession("web-user"); + + const response = await GET(cliRequest("http://localhost/api/usage/devices")); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: { code: "unauthorized", message: "Unauthorized" }, + }); + expect(createClient).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + }); + + it("returns a stable error without leaking database details", async () => { + webSession("web-user"); + rpc.mockResolvedValue({ + data: null, + error: { code: "XX000", message: "sensitive database detail" }, + }); + + const response = await GET(new Request("http://localhost/api/usage/devices")); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: { + code: "candidate_list_failed", + message: "Failed to list usage device candidates", + }, + }); + }); +}); + +describe("POST /api/usage/devices/resolve", () => { + it.each(["merge", "keep_separate"] as const)( + "resolves a candidate with the %s decision", + async (decision) => { + vi.mocked(verifyCliTokenWithRefresh).mockReturnValue({ + userId: "cli-user", + username: "cli", + refreshedToken: null, + }); + rpc.mockResolvedValue({ + data: { + id: CANDIDATE_ID, + status: "resolved", + decision, + canonical_device_id: + decision === "merge" ? CANDIDATE.device_id_a : null, + }, + error: null, + }); + + const response = await POST(cliRequest( + "http://localhost/api/usage/devices/resolve", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ candidate_id: CANDIDATE_ID, decision }), + }, + )); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + candidate: { + id: CANDIDATE_ID, + status: "resolved", + decision, + ...(decision === "merge" + ? { canonical_device_id: CANDIDATE.device_id_a } + : {}), + }, + }); + expect(rpc).toHaveBeenCalledWith("resolve_usage_device_candidate", { + p_user_id: "cli-user", + p_candidate_id: CANDIDATE_ID, + p_decision: decision, + }); + }, + ); + + it("rejects malformed decisions before authentication or database access", async () => { + const response = await POST(new Request( + "http://localhost/api/usage/devices/resolve", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + candidate_id: CANDIDATE_ID, + decision: "delete", + }), + }, + )); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: { + code: "invalid_request", + message: "decision must be merge or keep_separate", + }, + }); + expect(verifyCliTokenWithRefresh).not.toHaveBeenCalled(); + expect(createClient).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + }); + + it("maps an inaccessible candidate to a stable 404 response", async () => { + webSession("web-user"); + rpc.mockResolvedValue({ + data: null, + error: { code: "P0002", message: "candidate not found" }, + }); + + const response = await POST(new Request( + "http://localhost/api/usage/devices/resolve", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + candidate_id: CANDIDATE_ID, + decision: "merge", + }), + }, + )); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: { + code: "candidate_not_found", + message: "Usage device candidate not found", + }, + }); + }); +}); diff --git a/apps/web/__tests__/api/usage-submit-v2.test.ts b/apps/web/__tests__/api/usage-submit-v2.test.ts new file mode 100644 index 00000000..407b43da --- /dev/null +++ b/apps/web/__tests__/api/usage-submit-v2.test.ts @@ -0,0 +1,388 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/lib/api/cli-auth", () => ({ + verifyCliTokenWithRefresh: vi.fn(() => ({ + userId: "user-v2", + username: "v2-user", + refreshedToken: null, + })), +})); + +const rpc = vi.fn(); +const from = vi.fn(); + +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: vi.fn(() => ({ rpc, from })), +})); + +vi.mock("@/lib/analytics/server", () => ({ + captureServerActivationEvent: vi.fn().mockResolvedValue(true), +})); + +import { POST } from "@/app/api/usage/submit/route"; +import { resetRateLimiters } from "@/lib/rate-limit"; + +const DATE = new Date().toISOString().slice(0, 10); +const INSTALLATION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + +function agent() { + return { + agent: "codex", + models: ["gpt-5.6"], + input_tokens: 100, + output_tokens: 20, + reasoning_output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 30, + total_tokens: 160, + cost_usd: 0.25, + model_breakdown: [{ + model: "gpt-5.6", + input_tokens: 100, + output_tokens: 20, + reasoning_output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 30, + total_tokens: 160, + cost_usd: 0.25, + }], + }; +} + +function requestBody() { + return { + protocol_version: 2, + request_id: "request-v2", + source: "cli", + timezone: "America/Vancouver", + installation: { id: INSTALLATION_ID, name: "work-laptop" }, + collector: { name: "ccusage", version: "20.0.16", pricing_mode: "online" }, + entries: [{ + date: DATE, + content_hash: "a".repeat(64), + agents: [agent()], + }], + }; +} + +function request(body: unknown): Request { + return new Request("http://localhost/api/usage/submit", { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: "Bearer token", + "X-Straude-CLI-Version": "0.2.0", + }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetRateLimiters(); + process.env.NEXT_PUBLIC_APP_URL = "https://straude.com"; + rpc.mockImplementation((name: string) => { + if (name === "check_rate_limit") { + return Promise.resolve({ + data: [{ allowed: true, retry_after_seconds: 0 }], + error: null, + }); + } + return Promise.resolve({ + data: { + date: DATE, + status: "committed", + result: { + usage_id: "usage-1", + post_id: "post-1", + post_url: "https://straude.com/post/post-1", + action: "created", + daily_total: 0.25, + device_count: 1, + }, + }, + error: null, + }); + }); +}); + +describe("POST /api/usage/submit protocol v2", () => { + it("validates v2 before touching the database", async () => { + const body = requestBody(); + body.entries[0]!.agents[0]!.cost_usd = 0.24; + + const response = await POST(request(body)); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "invalid_agent_aggregate" }, + }); + expect(rpc).not.toHaveBeenCalledWith("submit_usage_day_v2", expect.anything()); + }); + + it("commits each date through the transactional v2 RPC", async () => { + const log = vi.spyOn(console, "info").mockImplementation(() => {}); + const response = await POST(request(requestBody())); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + request_id: "request-v2", + outcomes: [{ + date: DATE, + status: "committed", + result: { usage_id: "usage-1", post_id: "post-1" }, + }], + }); + expect(rpc).toHaveBeenCalledWith("submit_usage_day_v2", expect.objectContaining({ + p_user_id: "user-v2", + p_request_id: "request-v2", + p_source: "cli", + p_timezone: "America/Vancouver", + p_installation: expect.objectContaining({ id: INSTALLATION_ID }), + p_entry: expect.objectContaining({ date: DATE, agents: [agent()] }), + })); + expect(from).not.toHaveBeenCalledWith("device_usage"); + expect(from).not.toHaveBeenCalledWith("daily_usage"); + expect(from).not.toHaveBeenCalledWith("posts"); + const structuredLogs = log.mock.calls.map((call) => JSON.parse(String(call[0]))); + const structuredLog = structuredLogs.find((entry) => entry.event === "usage_submit_day"); + expect(structuredLog).toMatchObject({ + event: "usage_submit_day", + protocol_version: 2, + request_id: "request-v2", + date: DATE, + collector_version: "20.0.16", + cli_version: "0.2.0", + outcome: "committed", + retry_count: 0, + stage_timings_ms: { + transaction: expect.any(Number), + total: expect.any(Number), + }, + }); + expect(JSON.stringify(structuredLog)).not.toContain(INSTALLATION_ID); + expect(JSON.stringify(structuredLog)).not.toContain("work-laptop"); + expect(structuredLogs).toContainEqual(expect.objectContaining({ + event: "usage_submit_request", + protocol_version: 2, + request_id: "request-v2", + cli_version: "0.2.0", + http_status: 200, + unresolved_partial: false, + submit_duration_ms: expect.any(Number), + })); + log.mockRestore(); + }); + + it("returns unchanged when the RPC replays the same request/date/content", async () => { + rpc.mockImplementation((name: string) => { + if (name === "check_rate_limit") { + return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); + } + return Promise.resolve({ + data: { + date: DATE, + status: "unchanged", + result: { usage_id: "usage-1", post_id: "post-1", action: "updated" }, + }, + error: null, + }); + }); + + const response = await POST(request(requestBody())); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + outcomes: [{ status: "unchanged" }], + }); + }); + + it("maps a conflicting retry of request_id plus date to HTTP 409", async () => { + rpc.mockImplementation((name: string) => { + if (name === "check_rate_limit") { + return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); + } + return Promise.resolve({ + data: { + date: DATE, + status: "identity_conflict", + error: { + code: "idempotency_conflict", + message: "request_id and date already committed with different content", + }, + }, + error: null, + }); + }); + + const response = await POST(request(requestBody())); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + request_id: "request-v2", + outcomes: [{ + status: "identity_conflict", + error: { code: "idempotency_conflict" }, + }], + }); + }); + + it("returns HTTP 207 with every outcome when a v2 batch partially succeeds", async () => { + const prior = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + const body = requestBody(); + body.entries.push({ + ...structuredClone(body.entries[0]!), + date: prior, + content_hash: "b".repeat(64), + }); + rpc.mockImplementation((name: string, params: { p_entry?: { date?: string } }) => { + if (name === "check_rate_limit") { + return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); + } + if (params.p_entry?.date === DATE) { + return Promise.resolve({ + data: { date: DATE, status: "unchanged" }, + error: null, + }); + } + return Promise.resolve({ + data: { + date: prior, + status: "retryable_error", + error: { code: "database_busy", message: "Try again" }, + }, + error: null, + }); + }); + + const response = await POST(request(body)); + + expect(response.status).toBe(207); + expect(await response.json()).toMatchObject({ + outcomes: [ + { date: DATE, status: "unchanged" }, + { date: prior, status: "retryable_error" }, + ], + }); + }); + + it("rejects a source that disagrees with the authenticated channel", async () => { + const body = requestBody(); + body.source = "web"; + + const response = await POST(request(body)); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ + request_id: "request-v2", + outcomes: [{ + status: "permanent_error", + error: { code: "source_mismatch" }, + }], + }); + expect(rpc).not.toHaveBeenCalledWith("submit_usage_day_v2", expect.anything()); + }); + + it("keeps legacy response shape but fails the whole HTTP request when any date fails", async () => { + let submitCalls = 0; + rpc.mockImplementation((name: string) => { + if (name === "check_rate_limit") { + return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); + } + submitCalls += 1; + if (submitCalls === 1) { + return Promise.resolve({ + data: { + date: DATE, + status: "committed", + result: { + usage_id: "usage-1", + post_id: "post-1", + post_url: "https://straude.com/post/post-1", + action: "created", + }, + }, + error: null, + }); + } + return Promise.resolve({ data: null, error: { message: "database unavailable" } }); + }); + const prior = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + const legacyEntry = (date: string) => ({ + date, + data: { + date, + agents: ["codex"], + models: ["gpt-5.6"], + inputTokens: 100, + outputTokens: 20, + reasoningOutputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 30, + totalTokens: 160, + costUSD: 0.25, + modelBreakdown: [{ model: "gpt-5.6", cost_usd: 0.25 }], + }, + }); + + const response = await POST(request({ + entries: [legacyEntry(DATE), legacyEntry(prior)], + hash: "b".repeat(64), + source: "cli", + device_id: INSTALLATION_ID, + device_name: "work-laptop", + })); + + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).not.toBe(207); + expect(await response.json()).toMatchObject({ + results: [{ date: DATE }], + errors: ["Usage transaction is temporarily unavailable"], + }); + expect(rpc).toHaveBeenCalledWith("submit_usage_day_v2", expect.anything()); + expect(from).not.toHaveBeenCalledWith("device_usage"); + expect(from).not.toHaveBeenCalledWith("daily_usage"); + expect(from).not.toHaveBeenCalledWith("posts"); + }); + + it("derives stable legacy idempotency and keeps legacy rows unpartitioned", async () => { + const legacy = { + entries: [{ + date: DATE, + data: { + date: DATE, + agents: ["codex"], + models: ["gpt-5.6"], + inputTokens: 100, + outputTokens: 20, + reasoningOutputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 30, + totalTokens: 160, + costUSD: 0.25, + modelBreakdown: [{ model: "gpt-5.6", cost_usd: 0.25 }], + }, + }], + source: "cli", + device_id: INSTALLATION_ID, + device_name: "work-laptop", + }; + + await POST(request(legacy)); + await POST(request(legacy)); + + const submissions = rpc.mock.calls + .filter(([name]) => name === "submit_usage_day_v2") + .map(([, params]) => params); + expect(submissions).toHaveLength(2); + expect(submissions[0].p_request_id).toMatch(/^[a-f0-9]{64}$/); + expect(submissions[1].p_request_id).toBe(submissions[0].p_request_id); + expect(submissions[0].p_entry.agents).toEqual([ + expect.objectContaining({ agent: "legacy-unpartitioned" }), + ]); + }); +}); diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts index 43144432..c5424043 100644 --- a/apps/web/__tests__/api/usage-submit.test.ts +++ b/apps/web/__tests__/api/usage-submit.test.ts @@ -1,2017 +1,272 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn(), })); vi.mock("@/lib/api/cli-auth", () => ({ - verifyCliToken: vi.fn(), verifyCliTokenWithRefresh: vi.fn(), })); +const rpc = vi.fn(); + vi.mock("@/lib/supabase/service", () => ({ - getServiceClient: vi.fn(), + getServiceClient: vi.fn(() => ({ rpc })), })); vi.mock("@/lib/analytics/server", () => ({ captureServerActivationEvent: vi.fn().mockResolvedValue(true), })); -vi.mock("@supabase/supabase-js", () => ({ - createClient: vi.fn(), -})); - -import { POST, aggregateDeviceRows } from "@/app/api/usage/submit/route"; -import { captureServerActivationEvent } from "@/lib/analytics/server"; +import { POST } from "@/app/api/usage/submit/route"; import { createClient } from "@/lib/supabase/server"; -import { verifyCliToken, verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; -import { getServiceClient } from "@/lib/supabase/service"; +import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; import { resetRateLimiters } from "@/lib/rate-limit"; -const ALL_BUILT_IN_CCUSAGE_AGENTS = [ - "claude", - "codex", - "opencode", - "amp", - "droid", - "codebuff", - "hermes", - "pi", - "goose", - "openclaw", - "kilo", - "kimi", - "qwen", - "copilot", - "gemini", -]; - -function mockAllowedRpc() { - return vi.fn((fn: string) => Promise.resolve( - fn === "check_rate_limit" - ? { data: [{ allowed: true, retry_after_seconds: 0 }], error: null } - : { data: null, error: null }, - )); -} +const DEVICE_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const DATE = new Date().toISOString().slice(0, 10); -function makeEntry(dateStr: string, overrides: Record = {}) { +function legacyEntry(date = DATE) { return { - date: dateStr, + date, data: { - date: dateStr, - models: ["claude-sonnet-4-5-20250929"], - inputTokens: 1000, - outputTokens: 500, + date, + agents: ["codex"], + models: ["gpt-5.6"], + inputTokens: 100, + outputTokens: 20, + reasoningOutputTokens: 10, cacheCreationTokens: 0, - cacheReadTokens: 0, - totalTokens: 1500, - costUSD: 0.05, - ...overrides, + cacheReadTokens: 30, + totalTokens: 160, + costUSD: 0.25, + modelBreakdown: [{ model: "gpt-5.6", cost_usd: 0.25 }], }, }; } -function todayStr() { - return new Date().toISOString().split("T")[0]!; -} - -function daysAgo(n: number) { - const d = new Date(); - d.setDate(d.getDate() - n); - return d.toISOString().split("T")[0]!; -} - -function mockServiceClient(overrides: Record = {}) { - const chain: Record = { - from: vi.fn().mockReturnThis(), - rpc: mockAllowedRpc(), - upsert: vi.fn().mockReturnThis(), - insert: vi.fn().mockReturnThis(), - delete: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: null, - error: null, - }), - single: vi.fn().mockResolvedValue({ - data: { id: "usage-1" }, - error: null, - }), - // These make the chain itself act as a resolved query result, - // needed for calls that end with .eq() (e.g. device_usage fetch) - data: [], - error: null, - count: 0, - ...overrides, - }; - (getServiceClient as any).mockReturnValue(chain); - return chain; -} - -function mockSupabaseAuth(userId: string | null) { - const client: Record = { - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - error: null, - }), - }, - }; - (createClient as any).mockResolvedValue(client); - return client; -} - -const DEFAULT_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; -const DEFAULT_DEVICE_NAME = "test-device"; - -function mockRequest(body: any, headers: Record = {}) { - // All requests must include device_id unless explicitly testing the rejection - const withDevice = { - device_id: DEFAULT_DEVICE_ID, - device_name: DEFAULT_DEVICE_NAME, - ...body, - }; +function request(body: unknown, headers: Record = {}): Request { return new Request("http://localhost/api/usage/submit", { method: "POST", headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify(withDevice), + body: JSON.stringify(body), }); } -function mockRequestRaw(body: any, headers: Record = {}) { - return new Request("http://localhost/api/usage/submit", { - method: "POST", - headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify(body), - }); +function legacyBody(entries = [legacyEntry()]) { + return { + entries, + hash: "a".repeat(64), + source: "cli", + device_id: DEVICE_ID, + device_name: "work-laptop", + }; } beforeEach(() => { vi.clearAllMocks(); resetRateLimiters(); - process.env.NEXT_PUBLIC_SUPABASE_URL = "https://test.supabase.co"; - process.env.SUPABASE_SECRET_KEY = "secret"; process.env.NEXT_PUBLIC_APP_URL = "https://straude.com"; - (verifyCliToken as any).mockReturnValue(null); - // Auto-derive verifyCliTokenWithRefresh from verifyCliToken so existing - // tests can keep setting `verifyCliToken.mockReturnValue("cli-user-id")`. - (verifyCliTokenWithRefresh as any).mockImplementation((header: string | null) => { - const userId = (verifyCliToken as any)(header); - return userId ? { userId, username: null, refreshedToken: null } : null; - }); -}); - -describe("POST /api/usage/submit", () => { - it("rejects unauthenticated requests", async () => { - mockSupabaseAuth(null); - const svc = mockServiceClient(); - - const res = await POST( - mockRequest({ entries: [makeEntry(todayStr())], source: "web" }) - ); - const json = await res.json(); - - expect(res.status).toBe(401); - expect(json.error).toBe("Unauthorized"); - }); - - it("handles CLI JWT auth (Bearer token)", async () => { - (verifyCliToken as any).mockReturnValue("cli-user-id"); - mockSupabaseAuth(null); - const svc = mockServiceClient(); - // Each entry needs three .single() calls: device upsert + daily upsert + post - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest( - { entries: [makeEntry(todayStr())], source: "cli" }, - { authorization: "Bearer some-token" } - ) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(verifyCliToken).toHaveBeenCalledWith("Bearer some-token"); - expect(json.results).toHaveLength(1); - }); - - it("handles Supabase session auth (cookie/web)", async () => { - mockSupabaseAuth("web-user-id"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ entries: [makeEntry(todayStr())], source: "web" }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(1); - }); - - it("submits a single day entry successfully", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ entries: [makeEntry(todayStr())], source: "cli" }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(1); - expect(json.results[0].date).toBe(todayStr()); - expect(json.results[0].usage_id).toBe("usage-1"); - expect(json.results[0].post_id).toBe("post-1"); - expect(json.results[0].post_url).toBe("https://straude.com/post/post-1"); - expect(svc.rpc).toHaveBeenCalledWith("recalculate_user_level", { p_user_id: "user-1" }); - expect(captureServerActivationEvent).toHaveBeenCalledWith(expect.objectContaining({ - event: "usage_submit_succeeded", - distinctId: "user-1", - properties: expect.objectContaining({ - surface: "usage_submit", - activation_state: "first_usage_submitted", - is_authenticated: true, - days_pushed: 1, - result_count: 1, - total_tokens: 1500, - }), - })); - }); - - it("submits multiple days (batch)", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - // 2 entries, each needing 3 .single() calls (device + daily + post) - svc.single - .mockResolvedValueOnce({ data: { id: "d1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "u1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "p1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "d2" }, error: null }) - .mockResolvedValueOnce({ data: { id: "u2" }, error: null }) - .mockResolvedValueOnce({ data: { id: "p2" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr()), makeEntry(daysAgo(1))], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(2); - expect(json.results.map((result: { date: string }) => result.date)).toEqual([ - todayStr(), - daysAgo(1), - ]); - }); - - it("rejects duplicate dates", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - const date = todayStr(); - - const res = await POST( - mockRequest({ - entries: [makeEntry(date), makeEntry(date)], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toBe(`Duplicate date: ${date}`); - }); - - it("rejects more than 32 entries", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - const entries = Array.from({ length: 33 }, (_, index) => makeEntry(daysAgo(index % 31))); - - const res = await POST( - mockRequest({ - entries, - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toBe("Too many entries provided. Maximum is 32."); - }); - - it("rejects dates older than 30 days", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - - const res = await POST( - mockRequest({ - entries: [makeEntry(daysAgo(35))], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toContain("outside the 30-day backfill window"); - }); - - it("accepts dates up to 30 days ago", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(daysAgo(29))], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(1); - }); - - it("rejects negative cost", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { costUSD: -5 })], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toContain("Negative cost"); - }); - - it("rejects negative tokens", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { inputTokens: -100 })], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toContain("Negative input tokens"); - }); - - it("rejects negative reasoning output tokens", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { reasoningOutputTokens: -1 })], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toContain("Negative reasoning output tokens"); - }); - - it("rejects invalid empty ccusage collector agent ids", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr())], - source: "cli", - collector: { - ccusage_version: "20.0.6", - ccusage_agents: ["claude", ""], - pricing_mode: "online", - }, - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toContain("Invalid ccusage_agents"); - }); - - it("rejects unsupported ccusage pricing metadata", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr())], - source: "cli", - collector: { - ccusage_version: "20.0.6", - ccusage_agents: ["claude"], - pricing_mode: "auto", + vi.mocked(verifyCliTokenWithRefresh).mockReturnValue({ + userId: "user-1", + username: "user", + refreshedToken: null, + }); + rpc.mockImplementation((name: string, params: Record) => { + if (name === "check_rate_limit") { + return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); + } + if (name !== "submit_usage_day_v2") { + return Promise.resolve({ data: null, error: null }); + } + const entry = params.p_entry as { date: string }; + return Promise.resolve({ + data: { + date: entry.date, + status: "committed", + result: { + usage_id: `usage-${entry.date}`, + post_id: `post-${entry.date}`, + action: "created", + daily_total: 0.25, + device_count: 1, }, - }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toContain("Unsupported pricing mode"); - }); - - - it("uses upsert on conflict for device_usage and daily_usage", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - await POST( - mockRequest({ entries: [makeEntry(todayStr())], source: "cli" }) - ); - - // Verify upsert was called for both device_usage and daily_usage - expect(svc.upsert).toHaveBeenCalledTimes(2); - expect(svc.upsert.mock.calls[0][1]).toEqual({ onConflict: "user_id,date,device_id" }); - expect(svc.upsert.mock.calls[1][1]).toEqual({ onConflict: "user_id,date" }); - }); - - it("auto-creates post for each usage entry", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - await POST( - mockRequest({ entries: [makeEntry(todayStr())], source: "cli" }) - ); - - // upsert called twice (device_usage + daily_usage), insert once for new post - expect(svc.upsert).toHaveBeenCalledTimes(2); - expect(svc.insert).toHaveBeenCalledTimes(1); - const postInsertCall = svc.insert.mock.calls[0]; - expect(postInsertCall[0]).toMatchObject({ - user_id: "user-1", - daily_usage_id: "usage-1", + }, + error: null, }); }); +}); - it("rejects invalid JSON body", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const req = new Request("http://localhost/api/usage/submit", { - method: "POST", - body: "not json", - }); - - const res = await POST(req); - const json = await res.json(); +describe("POST /api/usage/submit legacy adapter", () => { + it("returns 426 with the exact update command after the configured v1 sunset", async () => { + vi.stubEnv("STRAUDE_USAGE_V1_CUTOFF", "2000-01-01"); - expect(res.status).toBe(400); - expect(json.error).toBe("Invalid JSON"); - }); + const response = await POST(request(legacyBody())); - it("rejects request bodies over 256KB", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const req = new Request("http://localhost/api/usage/submit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - entries: [makeEntry(todayStr(), { models: ["claude-sonnet-4-5-20250929"], large: "x".repeat(260 * 1024) })], - source: "cli", - device_id: DEFAULT_DEVICE_ID, - }), + expect(response.status).toBe(426); + expect(await response.json()).toEqual({ + error: "This Straude CLI version is no longer supported.", + code: "usage_protocol_upgrade_required", + update_command: "npx straude@latest", }); - - const res = await POST(req); - const json = await res.json(); - - expect(res.status).toBe(413); - expect(json.error).toBe("Request body too large"); - }); - - it("rejects empty entries array", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const res = await POST( - mockRequest({ entries: [], source: "cli" }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toBe("No entries provided"); - }); - - it("rejects invalid source", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const res = await POST( - mockRequest({ entries: [makeEntry(todayStr())], source: "invalid" }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toBe("Invalid source"); - }); - - // ------------------------------------------------------------------------- - // Codex / model_breakdown tests - // ------------------------------------------------------------------------- - - it("stores model_breakdown in upsert when provided", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const breakdown = [ - { model: "claude-opus-4-20250505", cost_usd: 10.0 }, - { model: "gpt-5-codex", cost_usd: 3.0 }, - ]; - - const res = await POST( - mockRequest({ - entries: [ - makeEntry(todayStr(), { - models: ["claude-opus-4-20250505", "gpt-5-codex"], - costUSD: 13.0, - modelBreakdown: breakdown, - }), - ], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - const upsertCall = svc.upsert.mock.calls[0]; - expect(upsertCall[0].model_breakdown).toEqual(breakdown); - }); - - it("stores null model_breakdown when not provided (backward compat)", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr())], - source: "cli", - }) - ); - - expect(res.status).toBe(200); - const upsertCall = svc.upsert.mock.calls[0]; - expect(upsertCall[0].model_breakdown).toBeNull(); + expect(rpc).not.toHaveBeenCalledWith("submit_usage_day_v2", expect.anything()); + vi.unstubAllEnvs(); }); - it("stores and aggregates reasoning output tokens", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const deviceRow = { - cost_usd: 3.0, - input_tokens: 2000, - output_tokens: 800, - reasoning_output_tokens: 250, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 2800, - models: ["gpt-5-codex"], - model_breakdown: [{ model: "gpt-5-codex", cost_usd: 3.0 }], - }; - const svc = mockServiceClient({ data: [deviceRow] }); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [ - makeEntry(todayStr(), { - models: ["gpt-5-codex"], - costUSD: 3.0, - inputTokens: 2000, - outputTokens: 800, - reasoningOutputTokens: 250, - totalTokens: 2800, - modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 3.0 }], - }), - ], - source: "cli", - }) - ); - - expect(res.status).toBe(200); - expect(svc.upsert.mock.calls[0][0].reasoning_output_tokens).toBe(250); - expect(svc.upsert.mock.calls[1][0].reasoning_output_tokens).toBe(250); - }); - - it("accepts Codex-only usage (no Claude models)", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [ - makeEntry(todayStr(), { - models: ["gpt-5-codex"], - costUSD: 3.0, - inputTokens: 2000, - outputTokens: 800, - totalTokens: 2800, - modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 3.0 }], - }), - ], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(1); - const upsertCall = svc.upsert.mock.calls[0]; - expect(upsertCall[0].models).toEqual(["gpt-5-codex"]); - expect(upsertCall[0].cost_usd).toBe(3.0); - expect(upsertCall[0].model_breakdown).toEqual([ - { model: "gpt-5-codex", cost_usd: 3.0 }, - ]); - }); - - it("auto-title keeps full GPT Codex model version", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const deviceRow = { - cost_usd: 3.2, - input_tokens: 2100, - output_tokens: 900, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 3000, - models: ["gpt-5.3-codex"], - model_breakdown: [{ model: "gpt-5.3-codex", cost_usd: 3.2 }], - }; - const svc = mockServiceClient({ data: [deviceRow] }); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [ - makeEntry(todayStr(), { - models: ["gpt-5.3-codex"], - costUSD: 3.2, - inputTokens: 2100, - outputTokens: 900, - totalTokens: 3000, - modelBreakdown: [{ model: "gpt-5.3-codex", cost_usd: 3.2 }], - }), - ], - source: "cli", - }) - ); - - expect(res.status).toBe(200); - const postInsertCall = svc.insert.mock.calls[0]; - expect(postInsertCall[0].title).toContain("GPT-5.3-Codex"); - }); - - it("auto-title treats Claude Fable as the highest Claude tier", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const deviceRow = { - cost_usd: 15, - input_tokens: 2100, - output_tokens: 900, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 3000, - models: ["claude-opus-4-20250505", "claude-fable-5"], - model_breakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 12 }, - { model: "claude-fable-5", cost_usd: 3 }, - ], - }; - const svc = mockServiceClient({ data: [deviceRow] }); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [ - makeEntry(todayStr(), { - models: ["claude-opus-4-20250505", "claude-fable-5"], - costUSD: 15, - inputTokens: 2100, - outputTokens: 900, - totalTokens: 3000, - modelBreakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 12 }, - { model: "claude-fable-5", cost_usd: 3 }, - ], - }), - ], - source: "cli", - }) - ); - - expect(res.status).toBe(200); - const postInsertCall = svc.insert.mock.calls[0]; - expect(postInsertCall[0].title).toContain("Claude Fable"); - expect(postInsertCall[0].title).not.toContain("Claude Opus"); - }); - - // ------------------------------------------------------------------------- - // Multi-device tests - // ------------------------------------------------------------------------- - - it("multi-device: device_id triggers device_usage upsert path", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - - const deviceRows = [ - { - cost_usd: 0.05, - input_tokens: 1000, - output_tokens: 500, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 1500, - models: ["claude-sonnet-4-5-20250929"], - model_breakdown: null, + it("keeps authenticated web imports available after the CLI v1 sunset", async () => { + vi.stubEnv("STRAUDE_USAGE_V1_CUTOFF", "2000-01-01"); + vi.mocked(verifyCliTokenWithRefresh).mockReturnValue(null); + vi.mocked(createClient).mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: { id: "web-user" } } }), }, - ]; + } as never); - // Build a per-table mock that distinguishes device_usage from daily_usage - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: deviceRows, error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - insert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; - - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceUpsertChain; - return deviceFetchChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; - }); - - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); + const response = await POST(request({ + ...legacyBody(), + source: "web", + })); - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr())], - source: "cli", - device_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - device_name: "work-laptop", - }) + expect(response.status).toBe(200); + expect(rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", + expect.objectContaining({ p_source: "web", p_is_verified: false }), ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(1); - // Verify device_usage table was targeted - expect(fromFn).toHaveBeenCalledWith("device_usage"); - // Verify device_usage upsert was called with device_id conflict - expect(deviceUpsertChain.upsert).toHaveBeenCalled(); - expect(deviceUpsertChain.upsert.mock.calls[0][1]).toEqual({ onConflict: "user_id,date,device_id" }); - // Verify daily_usage was upserted with aggregated values - expect(dailyChain.upsert).toHaveBeenCalled(); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(0.05); - expect(dailyChain.upsert.mock.calls[0][0].session_count).toBe(1); - // Verify new response fields - expect(json.results[0].previous_cost).toBeUndefined(); - expect(json.results[0].daily_total).toBe(0.05); - expect(json.results[0].device_count).toBe(1); + vi.unstubAllEnvs(); }); - it("multi-device: re-push returns previous_cost and aggregated daily_total", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - - // Two device rows: existing device A ($5) + current device B ($3) = $8 total - const allDeviceRows = [ - { - cost_usd: 5.0, - input_tokens: 1000, - output_tokens: 500, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 1500, - models: ["claude-opus-4-20250505"], - model_breakdown: [{ model: "claude-opus-4-20250505", cost_usd: 5.0 }], - }, - { - cost_usd: 3.0, - input_tokens: 2000, - output_tokens: 800, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 2800, - models: ["claude-sonnet-4-5-20250929"], - model_breakdown: [{ model: "claude-sonnet-4-5-20250929", cost_usd: 3.0 }], + it("rejects unauthenticated requests", async () => { + vi.mocked(verifyCliTokenWithRefresh).mockReturnValue(null); + vi.mocked(createClient).mockResolvedValue({ + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user: null } }), }, - ]; - - // Guard: no existing device_usage for device B - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-2" }, error: null }), - }; - // Fetch returns BOTH device rows (device A + device B) - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: allDeviceRows, error: null }), - })), - }; - // daily_usage already exists with $5 (from device A's earlier push) - let dailySelectCount = 0; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockImplementation(() => { - dailySelectCount++; - // First maybeSingle: existing daily_usage check - if (dailySelectCount === 1) { - return Promise.resolve({ data: { id: "usage-1", cost_usd: 5.0, models: ["claude-opus-4-20250505"] }, error: null }); - } - return Promise.resolve({ data: null, error: null }); - }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - count: 1, - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Mar 31" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; - - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - if (deviceFromCallCount === 3) return deviceUpsertChain; - return deviceFetchChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; - }); - - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const DEVICE_B = "11111111-2222-3333-4444-555555555555"; - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["claude-sonnet-4-5-20250929"], - costUSD: 3.0, - inputTokens: 2000, - outputTokens: 800, - totalTokens: 2800, - })], - source: "cli", - device_id: DEVICE_B, - device_name: "home-laptop", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(1); - expect(json.results[0].action).toBe("updated"); - // Previous daily total was $5 from device A - expect(json.results[0].previous_cost).toBe(5.0); - // New daily total is $8 (device A $5 + device B $3) - expect(json.results[0].daily_total).toBe(8.0); - // Two devices contributed - expect(json.results[0].device_count).toBe(2); - }); - - it("keeps the lower-value overwrite guard for old collectors", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - - const existingDeviceRow = { - cost_usd: 100, - input_tokens: 100000, - output_tokens: 1000, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 101000, - models: ["gpt-5-codex"], - model_breakdown: [{ model: "gpt-5-codex", cost_usd: 100 }], - }; - - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { cost_usd: 100, models: ["gpt-5-codex"] }, - error: null, - }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: [existingDeviceRow], error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "usage-1", cost_usd: 100, models: ["gpt-5-codex"] }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; - - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - return deviceFetchChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; - }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["gpt-5-codex"], - costUSD: 10, - inputTokens: 10000, - outputTokens: 100, - totalTokens: 10100, + } as never); + + const response = await POST(request(legacyBody())); + + expect(response.status).toBe(401); + }); + + it("adapts legacy entries through submit_usage_day_v2", async () => { + const response = await POST(request( + legacyBody(), + { authorization: "Bearer token" }, + )); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.results).toEqual([expect.objectContaining({ + date: DATE, + usage_id: `usage-${DATE}`, + post_url: `https://straude.com/post/post-${DATE}`, + })]); + expect(rpc).toHaveBeenCalledWith("submit_usage_day_v2", expect.objectContaining({ + p_request_id: "a".repeat(64), + p_installation: { id: DEVICE_ID, name: "work-laptop" }, + p_entry: expect.objectContaining({ + date: DATE, + agents: [expect.objectContaining({ + agent: "legacy-unpartitioned", + input_tokens: 100, + total_tokens: 160, })], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results[0].daily_total).toBe(100); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(100); - }); - - it("allows native Codex repair submissions to lower inflated device and daily rows", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - - const correctedDeviceRow = { - cost_usd: 10, - input_tokens: 10000, - output_tokens: 100, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 10100, - models: ["gpt-5-codex"], - model_breakdown: [{ model: "gpt-5-codex", cost_usd: 10 }], - }; - - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { cost_usd: 100, models: ["gpt-5-codex"] }, - error: null, }), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - }; - const deviceDeleteChain: Record = { - delete: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: [correctedDeviceRow], error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "usage-1", cost_usd: 100, models: ["gpt-5-codex"] }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24 — GPT-5-Codex, $100.00" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; - - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - if (deviceFromCallCount === 3) return deviceUpsertChain; - if (deviceFromCallCount === 4) return deviceDeleteChain; - return deviceFetchChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; - }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["gpt-5-codex"], - costUSD: 10, - inputTokens: 10000, - outputTokens: 100, - totalTokens: 10100, - modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 10 }], - })], - source: "cli", - collector: { codex: "straude-codex-native-last-token-usage" }, - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(deviceUpsertChain.upsert).toHaveBeenCalled(); - expect(deviceUpsertChain.upsert.mock.calls[0][0].collector_meta).toEqual({ codex: "straude-codex-native-last-token-usage" }); - expect(deviceDeleteChain.delete).toHaveBeenCalled(); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(10); - expect(dailyChain.upsert.mock.calls[0][0].collector_meta).toEqual({ codex: "straude-codex-native-last-token-usage" }); - expect(json.results[0].previous_cost).toBe(100); - expect(json.results[0].daily_total).toBe(10); + })); }); - it("does not drop mixed legacy usage on a Codex-only repair", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - - const legacyDeviceRow = { - cost_usd: 100, - input_tokens: 100000, - output_tokens: 1000, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 101000, - models: ["claude-opus-4-20250505", "gpt-5-codex"], - model_breakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 90 }, - { model: "gpt-5-codex", cost_usd: 10 }, - ], - raw_hash: "legacy-hash", - }; - const correctedDeviceRow = { - cost_usd: 10, - input_tokens: 10000, - output_tokens: 100, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 10100, - models: ["gpt-5-codex"], - model_breakdown: [{ model: "gpt-5-codex", cost_usd: 10 }], - }; - - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 0, data: null, error: null }), - })), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceInsertChain: Record = { - insert: vi.fn().mockResolvedValue({ error: null }), - }; - const deviceDeleteChain: Record = { - delete: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ - data: [legacyDeviceRow, correctedDeviceRow], - error: null, - }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { - id: "usage-1", - cost_usd: 100, - models: ["claude-opus-4-20250505", "gpt-5-codex"], - }, - error: null, - }), - single: vi.fn() - .mockResolvedValueOnce({ data: legacyDeviceRow, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; - - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - if (deviceFromCallCount === 3) return deviceUpsertChain; - if (deviceFromCallCount === 4) return deviceInsertChain; - return deviceFetchChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; - }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); + it("derives omitted legacy reasoning tokens from the declared total", async () => { + const body = legacyBody(); + delete (body.entries[0]!.data as { reasoningOutputTokens?: number }) + .reasoningOutputTokens; - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["gpt-5-codex"], - costUSD: 10, - inputTokens: 10000, - outputTokens: 100, - totalTokens: 10100, - modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 10 }], - })], - source: "cli", - collector: { codex: "straude-codex-native-last-token-usage" }, - }) - ); - const json = await res.json(); + const response = await POST(request(body)); - expect(res.status).toBe(200); - expect(deviceDeleteChain.delete).not.toHaveBeenCalled(); - expect(deviceInsertChain.insert).toHaveBeenCalledWith( + expect(response.status).toBe(200); + expect(rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", expect.objectContaining({ - device_id: "00000000-0000-0000-0000-000000000000", - cost_usd: 100, - }) - ); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(110); - expect(json.results[0].daily_total).toBe(110); - }); - - it("does not overwrite a mixed same-device row with a Codex-only repair", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - - const mixedDeviceRow = { - cost_usd: 100, - input_tokens: 100000, - output_tokens: 1000, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 101000, - models: ["claude-opus-4-20250505", "gpt-5-codex"], - model_breakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 90 }, - { model: "gpt-5-codex", cost_usd: 10 }, - ], - }; - - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { cost_usd: 100, models: mixedDeviceRow.models }, - error: null, - }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceDeleteChain: Record = { - delete: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: [mixedDeviceRow], error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { - id: "usage-1", - cost_usd: 100, - models: mixedDeviceRow.models, - }, - error: null, + p_entry: expect.objectContaining({ + agents: [expect.objectContaining({ + reasoning_output_tokens: 10, + total_tokens: 160, + })], + }), }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; - - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - return deviceFetchChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; - }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["gpt-5-codex"], - costUSD: 10, - inputTokens: 10000, - outputTokens: 100, - totalTokens: 10100, - modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 10 }], - })], - source: "cli", - collector: { codex: "straude-codex-native-last-token-usage" }, - }) ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(deviceUpsertChain.upsert).not.toHaveBeenCalled(); - expect(deviceDeleteChain.delete).not.toHaveBeenCalled(); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(100); - expect(json.results[0].daily_total).toBe(100); }); - it("does not let a trusted Codex request lower a Claude-only device row", async () => { - (verifyCliToken as any).mockReturnValue("user-claude-lower"); + it("rejects duplicate dates before calling the transaction RPC", async () => { + const response = await POST(request(legacyBody([legacyEntry(), legacyEntry()]))); - const existingClaudeRow = { - cost_usd: 20, - input_tokens: 1000, - output_tokens: 500, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 1500, - models: ["claude-opus-4-20250505"], - model_breakdown: [{ model: "claude-opus-4-20250505", cost_usd: 20 }], - collector_meta: { claude: "ccusage-v18" }, - }; - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: existingClaudeRow, error: null }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: [existingClaudeRow], error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { - id: "usage-1", - cost_usd: 20, - models: existingClaudeRow.models, - model_breakdown: existingClaudeRow.model_breakdown, - collector_meta: { claude: "ccusage-v18" }, - }, - error: null, - }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; - - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - if (deviceFromCallCount === 3) return deviceFetchChain; - return deviceUpsertChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; - }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["claude-opus-4-20250505"], - costUSD: 10, - inputTokens: 500, - outputTokens: 250, - totalTokens: 750, - modelBreakdown: [{ model: "claude-opus-4-20250505", cost_usd: 10 }], - })], - source: "cli", - collector: { codex: "straude-codex-native-last-token-usage", claude: "ccusage-v18" }, - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(deviceUpsertChain.upsert).not.toHaveBeenCalled(); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(20); - expect(dailyChain.upsert.mock.calls[0][0].collector_meta).toEqual({ claude: "ccusage-v18" }); - expect(json.results[0].daily_total).toBe(20); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: `Duplicate date: ${DATE}` }); + expect(rpc).not.toHaveBeenCalledWith("submit_usage_day_v2", expect.anything()); }); - it.each([ - ["native last-token collector", "straude-codex-native-last-token-usage"], - ["ccusage Codex v20 collector", "ccusage-codex-v20"], - ])("allows a mixed trusted Codex correction from the %s when non-Codex cost is preserved", async (_label, codexCollector) => { - (verifyCliToken as any).mockReturnValue("user-mixed-preserved"); + it("rejects mismatched inner and outer dates", async () => { + const entry = legacyEntry(); + entry.data.date = "2026-07-22"; - const existingMixedRow = { - cost_usd: 120, - input_tokens: 100000, - output_tokens: 1000, - cache_creation_tokens: 0, - cache_read_tokens: 50000, - total_tokens: 151000, - models: ["claude-opus-4-20250505", "gpt-5-codex"], - model_breakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 90 }, - { model: "gpt-5-codex", cost_usd: 30 }, - ], - }; - const correctedMixedRow = { - ...existingMixedRow, - cost_usd: 100, - input_tokens: 80000, - cache_read_tokens: 10000, - total_tokens: 91000, - model_breakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 90 }, - { model: "gpt-5-codex", cost_usd: 10 }, - ], - collector_meta: { claude: "ccusage-v18", codex: codexCollector }, - }; - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: existingMixedRow, error: null }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceDeleteChain: Record = { - delete: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: [correctedMixedRow], error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { - id: "usage-1", - cost_usd: 120, - models: existingMixedRow.models, - model_breakdown: existingMixedRow.model_breakdown, - }, - error: null, - }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; + const response = await POST(request(legacyBody([entry]))); - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - if (deviceFromCallCount === 3) return deviceUpsertChain; - if (deviceFromCallCount === 4) return deviceDeleteChain; - return deviceFetchChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: expect.stringContaining("outside the 30-day backfill window"), }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["claude-opus-4-20250505", "gpt-5-codex"], - costUSD: 100, - inputTokens: 80000, - outputTokens: 1000, - cacheReadTokens: 10000, - totalTokens: 91000, - modelBreakdown: correctedMixedRow.model_breakdown, - })], - source: "cli", - collector: { codex: codexCollector, claude: "ccusage-claude-v20" }, - }) - ); - - expect(res.status).toBe(200); - expect(deviceUpsertChain.upsert).toHaveBeenCalled(); - expect(deviceDeleteChain.delete).toHaveBeenCalled(); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(100); - expect(dailyChain.upsert.mock.calls[0][0].collector_meta).toEqual({ - claude: "ccusage-claude-v20", - codex: codexCollector, - }); - }); - - it("accepts every ccusage source and stores row-specific collector metadata", async () => { - (verifyCliToken as any).mockReturnValue("user-collector-meta"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - agents: ["opencode"], - models: ["gpt-5.6"], - modelBreakdown: [{ model: "gpt-5.6", cost_usd: 0.05 }], - })], - source: "cli", - collector: { - claude: "ccusage-claude-v20", - codex: "ccusage-codex-v20", - ccusage_version: "20.0.16", - ccusage_agents: ALL_BUILT_IN_CCUSAGE_AGENTS, - pricing_mode: "offline", - }, - }) - ); - - const expectedMeta = { - ccusage_version: "20.0.16", - ccusage_agents: ["opencode"], - pricing_mode: "offline", - }; - expect(res.status).toBe(200); - expect(svc.upsert.mock.calls[0][0].collector_meta).toEqual(expectedMeta); - expect(svc.upsert.mock.calls[1][0].collector_meta).toEqual(expectedMeta); }); - it("blocks a mixed trusted Codex correction when non-Codex cost would fall", async () => { - (verifyCliToken as any).mockReturnValue("user-mixed-blocked"); + it("rejects accounting totals that do not match token categories", async () => { + const entry = legacyEntry(); + entry.data.totalTokens = 159; - const existingMixedRow = { - cost_usd: 120, - input_tokens: 100000, - output_tokens: 1000, - cache_creation_tokens: 0, - cache_read_tokens: 50000, - total_tokens: 151000, - models: ["claude-opus-4-20250505", "gpt-5-codex"], - model_breakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 90 }, - { model: "gpt-5-codex", cost_usd: 30 }, - ], - collector_meta: { claude: "ccusage-v18", codex: "straude-codex-native-v1" }, - }; - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: existingMixedRow, error: null }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceDeleteChain: Record = { - delete: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: [existingMixedRow], error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { - id: "usage-1", - cost_usd: 120, - models: existingMixedRow.models, - model_breakdown: existingMixedRow.model_breakdown, - }, - error: null, - }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; + const response = await POST(request(legacyBody([entry]))); - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - if (deviceFromCallCount === 3) return deviceFetchChain; - return deviceDeleteChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: `Token categories do not equal total tokens for ${DATE}`, }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["claude-opus-4-20250505", "gpt-5-codex"], - costUSD: 90, - inputTokens: 80000, - outputTokens: 1000, - cacheReadTokens: 10000, - totalTokens: 91000, - modelBreakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 80 }, - { model: "gpt-5-codex", cost_usd: 10 }, - ], - })], - source: "cli", - collector: { codex: "straude-codex-native-last-token-usage", claude: "ccusage-v18" }, - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(deviceUpsertChain.upsert).not.toHaveBeenCalled(); - expect(deviceDeleteChain.delete).not.toHaveBeenCalled(); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(120); - expect(json.results[0].daily_total).toBe(120); }); - it.each([ - ["legacy repair", { repair: "codex_inflation_repair", previous_cost_usd: 100 }], - ["v3 codex repair", { repair_v3_codex_only: "true", cost_before_v3: 100 }], - ["Claude restore", { claude_restore_2026_05_07: "true", cost_before_claude_restore: 5 }], - ])("preserves %s metadata and blocks reinflation from the older collector", async (_label, repairMeta) => { - (verifyCliToken as any).mockReturnValue(`user-repair-${String(_label).replaceAll(" ", "-")}`); + it("rejects negative cache tokens", async () => { + const entry = legacyEntry(); + entry.data.cacheReadTokens = -1; - const repairedRow = { - cost_usd: 10, - input_tokens: 10000, - output_tokens: 100, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 10100, - models: ["gpt-5-codex"], - model_breakdown: [{ model: "gpt-5-codex", cost_usd: 10 }], - collector_meta: repairMeta, - }; - const deviceGuardChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: repairedRow, error: null }), - }; - const deviceCountChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }), - })), - }; - const deviceUpsertChain: Record = { - upsert: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: { id: "dev-1" }, error: null }), - }; - const deviceFetchChain: Record = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockImplementation(() => ({ - eq: vi.fn().mockResolvedValue({ data: [repairedRow], error: null }), - })), - }; - const dailyChain: Record = { - select: vi.fn().mockReturnThis(), - upsert: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ - data: { - id: "usage-1", - cost_usd: 10, - models: repairedRow.models, - model_breakdown: repairedRow.model_breakdown, - collector_meta: repairMeta, - }, - error: null, - }), - single: vi.fn().mockResolvedValue({ data: { id: "usage-1" }, error: null }), - }; - const postChain: Record = { - select: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: "post-1", title: "Apr 24" }, error: null }), - single: vi.fn().mockResolvedValue({ data: { id: "post-1" }, error: null }), - }; + const response = await POST(request(legacyBody([entry]))); - let deviceFromCallCount = 0; - const fromFn = vi.fn((table: string) => { - if (table === "device_usage") { - deviceFromCallCount++; - if (deviceFromCallCount === 1) return deviceGuardChain; - if (deviceFromCallCount === 2) return deviceCountChain; - if (deviceFromCallCount === 3) return deviceFetchChain; - return deviceUpsertChain; - } - if (table === "daily_usage") return dailyChain; - if (table === "posts") return postChain; - return dailyChain; + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: `Invalid cache read tokens for ${DATE}`, }); - (getServiceClient as any).mockReturnValue({ from: fromFn, rpc: mockAllowedRpc() }); - - const res = await POST( - mockRequest({ - entries: [makeEntry(todayStr(), { - models: ["gpt-5-codex"], - costUSD: 100, - inputTokens: 100000, - outputTokens: 1000, - totalTokens: 101000, - modelBreakdown: [{ model: "gpt-5-codex", cost_usd: 100 }], - })], - source: "cli", - collector: { codex: "straude-codex-native-v1" }, - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(deviceUpsertChain.upsert).not.toHaveBeenCalled(); - expect(dailyChain.upsert.mock.calls[0][0].cost_usd).toBe(10); - expect(dailyChain.upsert.mock.calls[0][0].collector_meta).toMatchObject(repairMeta); - expect(json.results[0].daily_total).toBe(10); - }); - - it("rejects requests without device_id", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - mockServiceClient(); - - const res = await POST( - mockRequestRaw({ entries: [makeEntry(todayStr())], source: "cli" }) - ); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.error).toContain("device_id is required"); - }); - - it("accepts merged Claude + Codex usage in a single entry", async () => { - (verifyCliToken as any).mockReturnValue("user-1"); - const svc = mockServiceClient(); - svc.single - .mockResolvedValueOnce({ data: { id: "dev-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "usage-1" }, error: null }) - .mockResolvedValueOnce({ data: { id: "post-1" }, error: null }); - - const res = await POST( - mockRequest({ - entries: [ - makeEntry(todayStr(), { - models: ["claude-opus-4-20250505", "gpt-5-codex"], - costUSD: 13.0, - inputTokens: 3000, - outputTokens: 1300, - totalTokens: 4300, - modelBreakdown: [ - { model: "claude-opus-4-20250505", cost_usd: 10.0 }, - { model: "gpt-5-codex", cost_usd: 3.0 }, - ], - }), - ], - source: "cli", - }) - ); - const json = await res.json(); - - expect(res.status).toBe(200); - expect(json.results).toHaveLength(1); - const upsertCall = svc.upsert.mock.calls[0]; - expect(upsertCall[0].cost_usd).toBe(13.0); - expect(upsertCall[0].input_tokens).toBe(3000); - expect(upsertCall[0].models).toEqual(["claude-opus-4-20250505", "gpt-5-codex"]); - expect(upsertCall[0].model_breakdown).toEqual([ - { model: "claude-opus-4-20250505", cost_usd: 10.0 }, - { model: "gpt-5-codex", cost_usd: 3.0 }, - ]); - }); -}); - -// --------------------------------------------------------------------------- -// aggregateDeviceRows -// --------------------------------------------------------------------------- - -describe("aggregateDeviceRows", () => { - it("sums numeric fields across two device rows", () => { - const rows = [ - { - cost_usd: 5.0, - input_tokens: 1000, - output_tokens: 500, - reasoning_output_tokens: 125, - cache_creation_tokens: 100, - cache_read_tokens: 50, - total_tokens: 1650, - models: ["claude-opus-4-20250505"], - model_breakdown: [{ model: "claude-opus-4-20250505", cost_usd: 5.0 }], - }, - { - cost_usd: 3.0, - input_tokens: 2000, - output_tokens: 800, - reasoning_output_tokens: 75, - cache_creation_tokens: 0, - cache_read_tokens: 0, - total_tokens: 2800, - models: ["claude-sonnet-4-5-20250929"], - model_breakdown: [{ model: "claude-sonnet-4-5-20250929", cost_usd: 3.0 }], - }, - ]; - - const agg = aggregateDeviceRows(rows); - - expect(agg.cost_usd).toBe(8.0); - expect(agg.input_tokens).toBe(3000); - expect(agg.output_tokens).toBe(1300); - expect(agg.reasoning_output_tokens).toBe(200); - expect(agg.cache_creation_tokens).toBe(100); - expect(agg.cache_read_tokens).toBe(50); - expect(agg.total_tokens).toBe(4450); - expect(agg.session_count).toBe(2); - }); - - it("deduplicates models across devices", () => { - const rows = [ - { - cost_usd: 5.0, - input_tokens: 1000, output_tokens: 500, - cache_creation_tokens: 0, cache_read_tokens: 0, - total_tokens: 1500, - models: ["claude-opus-4-20250505"], - model_breakdown: null, - }, - { - cost_usd: 3.0, - input_tokens: 2000, output_tokens: 800, - cache_creation_tokens: 0, cache_read_tokens: 0, - total_tokens: 2800, - models: ["claude-opus-4-20250505"], - model_breakdown: null, - }, - ]; - - const agg = aggregateDeviceRows(rows); - expect(agg.models).toEqual(["claude-opus-4-20250505"]); }); - it("merges model_breakdown by summing cost per model name", () => { - const rows = [ - { - cost_usd: 5.0, - input_tokens: 1000, output_tokens: 500, - cache_creation_tokens: 0, cache_read_tokens: 0, - total_tokens: 1500, - models: ["claude-opus-4-20250505"], - model_breakdown: [{ model: "claude-opus-4-20250505", cost_usd: 5.0 }], - }, - { - cost_usd: 7.0, - input_tokens: 2000, output_tokens: 800, - cache_creation_tokens: 0, cache_read_tokens: 0, - total_tokens: 2800, - models: ["claude-opus-4-20250505"], - model_breakdown: [{ model: "claude-opus-4-20250505", cost_usd: 7.0 }], - }, - ]; + it("rejects invalid JSON and oversized bodies", async () => { + const invalid = await POST(new Request("http://localhost/api/usage/submit", { + method: "POST", + body: "{", + })); + expect(invalid.status).toBe(400); - const agg = aggregateDeviceRows(rows); - expect(agg.model_breakdown).toEqual([ - { model: "claude-opus-4-20250505", cost_usd: 12.0 }, - ]); + const oversized = await POST(request({ + ...legacyBody(), + padding: "x".repeat(260 * 1024), + })); + expect(oversized.status).toBe(413); }); - it("session_count reflects number of device rows", () => { - const rows = [ - { cost_usd: 1, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 0, models: [], model_breakdown: null }, - { cost_usd: 2, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 0, models: [], model_breakdown: null }, - { cost_usd: 3, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 0, models: [], model_breakdown: null }, - ]; - - const agg = aggregateDeviceRows(rows); - expect(agg.session_count).toBe(3); - expect(agg.cost_usd).toBe(6); - }); + it("returns non-2xx when the transaction RPC fails", async () => { + rpc.mockImplementation((name: string) => name === "check_rate_limit" + ? Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }) + : Promise.resolve({ data: null, error: { code: "40001", message: "serialization failure" } })); - it("returns null model_breakdown when no devices have breakdowns", () => { - const rows = [ - { cost_usd: 1, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 0, models: [], model_breakdown: null }, - ]; + const response = await POST(request(legacyBody())); - const agg = aggregateDeviceRows(rows); - expect(agg.model_breakdown).toBeNull(); + expect(response.status).toBe(503); + expect(response.status).not.toBe(207); + expect(await response.json()).toMatchObject({ + error: "Usage transaction is temporarily unavailable", + results: [], + errors: ["Usage transaction is temporarily unavailable"], + }); }); }); diff --git a/apps/web/__tests__/flows/cli-push-flow.test.ts b/apps/web/__tests__/flows/cli-push-flow.test.ts index 9988427d..5e1fdc5e 100644 --- a/apps/web/__tests__/flows/cli-push-flow.test.ts +++ b/apps/web/__tests__/flows/cli-push-flow.test.ts @@ -85,10 +85,26 @@ describe("Flow: CLI Push", () => { vi.useFakeTimers({ now: new Date('2026-03-13T12:00:00Z'), toFake: ['Date'] }); vi.clearAllMocks(); autoDeriveCliAuthMocks(); - mockServiceClient.rpc.mockImplementation((fn: string) => { + mockServiceClient.rpc.mockImplementation((fn: string, params?: Record) => { if (fn === "check_rate_limit") { return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); } + if (fn === "submit_usage_day_v2") { + return Promise.resolve({ + data: { + date: params?.p_entry.date, + status: "committed", + result: { + usage_id: "usage-1", + post_id: "post-1", + action: "created", + daily_total: 0.05, + device_count: 1, + }, + }, + error: null, + }); + } return Promise.resolve({ data: null, error: null }); }); vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://straude.com"); @@ -117,7 +133,7 @@ describe("Flow: CLI Push", () => { const res = await POST(req as any); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status, JSON.stringify(data)).toBe(200); expect(data.code).toBeDefined(); expect(data.code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/); expect(data.verify_url).toContain("https://straude.com/cli/verify?code="); @@ -228,22 +244,27 @@ describe("Flow: CLI Push", () => { ], hash: "abc123", source: "cli", - device_id: "aaaaaaaa-0000-0000-0000-000000000001", + device_id: "aaaaaaaa-0000-4000-8000-000000000001", device_name: "test-device", }), }); const res = await POST(req); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status, JSON.stringify(data)).toBe(200); expect(data.results).toHaveLength(1); expect(data.results[0].usage_id).toBe("usage-1"); expect(data.results[0].post_id).toBe("post-1"); expect(data.results[0].post_url).toBe("https://straude.com/post/post-1"); - const upsertCall = (usageChain.upsert as ReturnType).mock.calls[0]; - expect(upsertCall[0].is_verified).toBe(true); - expect(upsertCall[0].raw_hash).toBe("abc123"); + expect(mockServiceClient.rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", + expect.objectContaining({ + p_request_id: "abc123", + p_is_verified: true, + p_entry: expect.objectContaining({ date: today }), + }), + ); }); it("pushing same date again upserts instead of duplicating", async () => { @@ -283,14 +304,19 @@ describe("Flow: CLI Push", () => { "Content-Type": "application/json", Authorization: "Bearer mock-cli-jwt-token", }, - body: JSON.stringify({ entries: [entry], hash: "def456", source: "cli", device_id: "aaaaaaaa-0000-0000-0000-000000000001", device_name: "test-device" }), + body: JSON.stringify({ entries: [entry], hash: "def456", source: "cli", device_id: "aaaaaaaa-0000-4000-8000-000000000001", device_name: "test-device" }), }); const res = await POST(req); const data = await res.json(); expect(res.status).toBe(200); - const upsertCall = (usageChain.upsert as ReturnType).mock.calls[0]; - expect(upsertCall[1]).toEqual({ onConflict: "user_id,date" }); + expect(mockServiceClient.rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", + expect.objectContaining({ + p_request_id: "def456", + p_entry: expect.objectContaining({ date: today }), + }), + ); }); it("CLI pushes merged Claude + Codex data with model_breakdown", async () => { @@ -347,7 +373,7 @@ describe("Flow: CLI Push", () => { ], hash: "merged-hash-123", source: "cli", - device_id: "aaaaaaaa-0000-0000-0000-000000000001", + device_id: "aaaaaaaa-0000-4000-8000-000000000001", device_name: "test-device", }), }); @@ -358,18 +384,19 @@ describe("Flow: CLI Push", () => { expect(data.results).toHaveLength(1); expect(data.results[0].usage_id).toBe("usage-1"); - // Verify the upsert payload includes merged data - const upsertCall = (usageChain.upsert as ReturnType).mock.calls[0]; - expect(upsertCall[0].cost_usd).toBe(13.0); - expect(upsertCall[0].input_tokens).toBe(3000); - expect(upsertCall[0].output_tokens).toBe(1300); - expect(upsertCall[0].total_tokens).toBe(4450); - expect(upsertCall[0].models).toEqual(["claude-opus-4-20250505", "gpt-5-codex"]); - expect(upsertCall[0].model_breakdown).toEqual([ - { model: "claude-opus-4-20250505", cost_usd: 10.0 }, - { model: "gpt-5-codex", cost_usd: 3.0 }, - ]); - expect(upsertCall[0].is_verified).toBe(true); + expect(mockServiceClient.rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", + expect.objectContaining({ + p_entry: expect.objectContaining({ + agents: [expect.objectContaining({ + agent: "legacy-unpartitioned", + cost_usd: 13, + input_tokens: 3000, + total_tokens: 4450, + })], + }), + }), + ); }); it("CLI pushes Codex-only data (no Claude models)", async () => { @@ -423,7 +450,7 @@ describe("Flow: CLI Push", () => { ], hash: "codex-only-hash", source: "cli", - device_id: "aaaaaaaa-0000-0000-0000-000000000001", + device_id: "aaaaaaaa-0000-4000-8000-000000000001", device_name: "test-device", }), }); @@ -433,12 +460,18 @@ describe("Flow: CLI Push", () => { expect(res.status).toBe(200); expect(data.results).toHaveLength(1); - const upsertCall = (usageChain.upsert as ReturnType).mock.calls[0]; - expect(upsertCall[0].models).toEqual(["gpt-5-codex"]); - expect(upsertCall[0].cost_usd).toBe(3.20); - expect(upsertCall[0].model_breakdown).toEqual([ - { model: "gpt-5-codex", cost_usd: 3.20 }, - ]); + expect(mockServiceClient.rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", + expect.objectContaining({ + p_entry: expect.objectContaining({ + agents: [expect.objectContaining({ + agent: "legacy-unpartitioned", + models: ["gpt-5-codex"], + cost_usd: 3.2, + })], + }), + }), + ); }); it("two devices push same day — daily_usage shows summed totals", async () => { @@ -547,7 +580,7 @@ describe("Flow: CLI Push", () => { ], hash: "device-2-hash", source: "cli", - device_id: "22222222-2222-2222-2222-222222222222", + device_id: "22222222-2222-4222-8222-222222222222", device_name: "home-desktop", }), }); @@ -557,13 +590,14 @@ describe("Flow: CLI Push", () => { expect(res.status).toBe(200); expect(data.results).toHaveLength(1); - // Verify daily_usage was upserted with aggregated data (5 + 3 = 8) - const dailyUpsertCall = (dailyUsageChain.upsert as ReturnType).mock.calls[0]; - expect(dailyUpsertCall[0].cost_usd).toBe(8.0); - expect(dailyUpsertCall[0].input_tokens).toBe(3000); - expect(dailyUpsertCall[0].output_tokens).toBe(1300); - expect(dailyUpsertCall[0].total_tokens).toBe(4450); - expect(dailyUpsertCall[0].session_count).toBe(2); + expect(mockServiceClient.rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", + expect.objectContaining({ + p_installation: expect.objectContaining({ + id: "22222222-2222-4222-8222-222222222222", + }), + }), + ); }); it("rejects push without authentication", async () => { @@ -575,8 +609,21 @@ describe("Flow: CLI Push", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - entries: [{ date: "2026-02-16", data: { costUSD: 1, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, models: [] } }], + entries: [{ + date: "2026-03-13", + data: { + date: "2026-03-13", + costUSD: 1, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + models: ["gpt-5.6"], + }, + }], source: "cli", + device_id: "aaaaaaaa-0000-4000-8000-000000000001", }), }); const res = await POST(req); diff --git a/apps/web/__tests__/flows/web-import-flow.test.ts b/apps/web/__tests__/flows/web-import-flow.test.ts index 385eea3a..0dee3e32 100644 --- a/apps/web/__tests__/flows/web-import-flow.test.ts +++ b/apps/web/__tests__/flows/web-import-flow.test.ts @@ -82,10 +82,26 @@ describe("Flow: Web JSON Import", () => { beforeEach(() => { vi.clearAllMocks(); - mockServiceClient.rpc.mockImplementation((fn: string) => { + mockServiceClient.rpc.mockImplementation((fn: string, params?: Record) => { if (fn === "check_rate_limit") { return Promise.resolve({ data: [{ allowed: true, retry_after_seconds: 0 }], error: null }); } + if (fn === "submit_usage_day_v2") { + return Promise.resolve({ + data: { + date: params?.p_entry.date, + status: "committed", + result: { + usage_id: "usage-w1", + post_id: "post-w1", + action: "created", + daily_total: 0.25, + device_count: 1, + }, + }, + error: null, + }); + } return Promise.resolve({ data: null, error: null }); }); vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://straude.com"); @@ -141,10 +157,13 @@ describe("Flow: Web JSON Import", () => { expect(data.results).toHaveLength(1); expect(data.results[0].post_id).toBe("post-w1"); - // Verify is_verified is false for web source - const upsertCall = (usageChain.upsert as ReturnType).mock.calls[0]; - expect(upsertCall[0].is_verified).toBe(false); - expect(upsertCall[0].raw_hash).toBeNull(); + expect(mockServiceClient.rpc).toHaveBeenCalledWith( + "submit_usage_day_v2", + expect.objectContaining({ + p_is_verified: false, + p_source: "web", + }), + ); }); it("user edits auto-created post with title and description", async () => { diff --git a/apps/web/__tests__/integration/db.ts b/apps/web/__tests__/integration/db.ts index 24efb662..ee3e3857 100644 --- a/apps/web/__tests__/integration/db.ts +++ b/apps/web/__tests__/integration/db.ts @@ -23,6 +23,13 @@ export async function openTestDb(): Promise { * usually doesn't require updating this list. */ const TRUNCATE_TABLES = [ + "usage_corrections_ledger", + "usage_device_reconciliation_decisions", + "usage_device_reconciliation_candidates", + "usage_repair_batches", + "usage_submission_outcomes", + "usage_agent_daily", + "usage_installation_aliases", "device_usage", "daily_usage", "posts", @@ -30,8 +37,21 @@ const TRUNCATE_TABLES = [ ]; export async function cleanDb(client: Client): Promise { + const truncate = `TRUNCATE TABLE ${ + TRUNCATE_TABLES.map((t) => `public.${t}`).join(", ") + } RESTART IDENTITY CASCADE`; + for (let attempt = 0; ; attempt += 1) { + try { + await client.query(truncate); + break; + } catch (error) { + const retryable = (error as { code?: string }).code === "40P01"; + if (!retryable || attempt >= 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } await client.query( - `TRUNCATE TABLE ${TRUNCATE_TABLES.map((t) => `public.${t}`).join(", ")} RESTART IDENTITY CASCADE`, + "DELETE FROM auth.users WHERE email LIKE '%@example.test'", ); } diff --git a/apps/web/__tests__/integration/usage-submit.test.ts b/apps/web/__tests__/integration/usage-submit.test.ts index b30cd707..9dc576ba 100644 --- a/apps/web/__tests__/integration/usage-submit.test.ts +++ b/apps/web/__tests__/integration/usage-submit.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; -import type { Client } from "pg"; +import { Pool, type Client } from "pg"; import { openTestDb, cleanDb, insertUser } from "./db"; /** @@ -65,10 +65,763 @@ async function callSubmit( return POST(req); } -const DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const DEVICE_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; const today = new Date().toISOString().slice(0, 10); +function v2Agent(overrides: Record = {}) { + return { + agent: "codex", + models: ["gpt-5.6"], + input_tokens: 100, + output_tokens: 20, + reasoning_output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 30, + total_tokens: 160, + cost_usd: 0.25, + model_breakdown: [{ + model: "gpt-5.6", + input_tokens: 100, + output_tokens: 20, + reasoning_output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 30, + total_tokens: 160, + cost_usd: 0.25, + }], + ...overrides, + }; +} + +function v2Body(requestId: string, contentHash: string, overrides: Record = {}) { + return { + protocol_version: 2, + request_id: requestId, + source: "cli", + timezone: "UTC", + installation: { id: DEVICE_ID, name: "integration-device" }, + collector: { name: "ccusage", version: "20.0.16", pricing_mode: "online" }, + entries: [{ + date: today, + content_hash: contentHash, + agents: [v2Agent()], + ...overrides, + }], + }; +} + describe("POST /api/usage/submit (real Supabase)", () => { + it("commits v2 per-agent rows and derives device and daily aggregates", async () => { + const userId = await insertUser(db, { username: "v2_integration" }); + const token = await mintCliToken(userId, "v2_integration"); + + const res = await callSubmit(v2Body("v2-commit", "a".repeat(64)), token); + const json = await res.json(); + + expect(res.status, JSON.stringify(json)).toBe(200); + expect(json).toMatchObject({ + request_id: "v2-commit", + outcomes: [{ + date: today, + status: "committed", + result: { action: "created" }, + }], + }); + const agents = await db.query( + `SELECT agent, input_tokens, reasoning_output_tokens, total_tokens, cost_usd, model_breakdown + FROM public.usage_agent_daily + WHERE user_id = $1 AND date = $2 AND device_id = $3`, + [userId, today, DEVICE_ID], + ); + expect(agents.rows).toHaveLength(1); + expect(agents.rows[0].agent).toBe("codex"); + expect(Number(agents.rows[0].total_tokens)).toBe(160); + expect(agents.rows[0].model_breakdown[0]).toMatchObject({ + model: "gpt-5.6", + reasoning_output_tokens: 10, + }); + const daily = await db.query( + `SELECT cost_usd, total_tokens FROM public.daily_usage WHERE user_id = $1 AND date = $2`, + [userId, today], + ); + expect(Number(daily.rows[0].cost_usd)).toBeCloseTo(0.25, 6); + expect(Number(daily.rows[0].total_tokens)).toBe(160); + }); + + it("replays identical request/date/content as unchanged without duplicate rows", async () => { + const userId = await insertUser(db, { username: "v2_replay" }); + const token = await mintCliToken(userId, "v2_replay"); + const body = v2Body("v2-replay", "b".repeat(64)); + + const first = await callSubmit(body, token); + await db.query( + `INSERT INTO public.usage_device_reconciliation_candidates ( + user_id, device_id_a, device_id_b, normalized_hostname, status + ) VALUES ($1, $2, 'ffffffff-ffff-4fff-8fff-ffffffffffff', 'integration-device', 'ambiguous')`, + [userId, DEVICE_ID], + ); + const second = await callSubmit(body, token); + const secondJson = await second.json(); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(secondJson.outcomes[0].status).toBe("unchanged"); + const counts = await db.query( + `SELECT + (SELECT count(*)::int FROM public.usage_submission_outcomes WHERE user_id = $1) AS outcomes, + (SELECT count(*)::int FROM public.usage_agent_daily WHERE user_id = $1) AS agents, + (SELECT count(*)::int FROM public.daily_usage WHERE user_id = $1) AS daily`, + [userId], + ); + expect(counts.rows[0]).toEqual({ outcomes: 1, agents: 1, daily: 1 }); + }); + + it("serializes 50 concurrent replays into one exact aggregate and one post", async () => { + const userId = await insertUser(db, { username: "v2_concurrent" }); + const entry = v2Body("v2-concurrent", "9".repeat(64)).entries[0]!; + const pool = new Pool({ + connectionString: process.env.TEST_DB_URL, + max: 50, + }); + try { + const outcomes = await Promise.all( + Array.from({ length: 50 }, () => pool.query<{ outcome: { status: string } }>( + `SELECT public.submit_usage_day_v2( + $1::uuid, + 'v2-concurrent', + 'cli', + 'UTC', + $2::jsonb, + $3::jsonb, + $4::jsonb, + $5, + true + ) AS outcome`, + [ + userId, + JSON.stringify({ id: DEVICE_ID, name: "concurrent-device" }), + JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + JSON.stringify(entry), + "9".repeat(64), + ], + )), + ); + const statuses = outcomes.map((result) => result.rows[0]!.outcome.status); + expect(statuses.filter((status) => status === "committed")).toHaveLength(1); + expect(statuses.filter((status) => status === "unchanged")).toHaveLength(49); + } finally { + await pool.end(); + } + + const exact = await db.query( + `SELECT + (SELECT count(*)::int FROM public.usage_submission_outcomes WHERE user_id = $1) AS outcomes, + (SELECT count(*)::int FROM public.usage_agent_daily WHERE user_id = $1) AS agents, + (SELECT count(*)::int FROM public.device_usage WHERE user_id = $1) AS devices, + (SELECT count(*)::int FROM public.daily_usage WHERE user_id = $1) AS daily, + (SELECT count(*)::int FROM public.posts WHERE user_id = $1) AS posts, + (SELECT total_tokens::int FROM public.daily_usage WHERE user_id = $1) AS total_tokens`, + [userId], + ); + expect(exact.rows[0]).toEqual({ + outcomes: 1, + agents: 1, + devices: 1, + daily: 1, + posts: 1, + total_tokens: 160, + }); + }); + + it("isolates the legacy shared web-import device id per user", async () => { + const firstUserId = await insertUser(db, { username: "web_import_one" }); + const secondUserId = await insertUser(db, { username: "web_import_two" }); + const sharedWebId = "00000000-0000-0000-0000-000000000001"; + const entry = v2Body("unused", "7".repeat(64)).entries[0]!; + + for (const [userId, requestId] of [ + [firstUserId, "web-import-one"], + [secondUserId, "web-import-two"], + ]) { + const result = await db.query<{ outcome: { status: string } }>( + `SELECT public.submit_usage_day_v2( + $1::uuid, + $2, + 'web', + 'UTC', + $3::jsonb, + $4::jsonb, + $5::jsonb, + $6, + false + ) AS outcome`, + [ + userId, + requestId, + JSON.stringify({ id: sharedWebId, name: "web-import" }), + JSON.stringify({ name: "legacy-web-import", version: "1", pricing_mode: "online" }), + JSON.stringify(entry), + "7".repeat(64), + ], + ); + expect(result.rows[0]!.outcome.status).toBe("committed"); + } + + const devices = await db.query( + `SELECT user_id, device_id + FROM public.device_usage + WHERE user_id = ANY($1::uuid[]) + ORDER BY user_id`, + [[firstUserId, secondUserId]], + ); + expect(devices.rows).toHaveLength(2); + expect(new Set(devices.rows.map((row) => row.device_id)).size).toBe(2); + expect(devices.rows.every((row) => row.device_id !== sharedWebId)).toBe(true); + }); + + it("repairs a proof-eligible historical duplicate and rolls the batch back exactly", async () => { + const userId = await insertUser(db, { username: "v2_repair" }); + const deviceA = "10000000-0000-4000-8000-000000000001"; + const deviceB = "20000000-0000-4000-8000-000000000002"; + const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + const dates = [yesterday, today]; + + await db.query( + `INSERT INTO public.usage_installation_aliases ( + device_id, user_id, canonical_device_id, name, created_at, updated_at + ) VALUES + ($2, $1, $2, 'same-host', now() - interval '2 days', now() - interval '2 days'), + ($3, $1, $3, 'same-host', now() - interval '1 day', now() - interval '1 day')`, + [userId, deviceA, deviceB], + ); + await db.query( + `INSERT INTO public.usage_agent_daily ( + user_id, date, device_id, agent, models, input_tokens, output_tokens, + reasoning_output_tokens, cache_creation_tokens, cache_read_tokens, + total_tokens, cost_usd, model_breakdown, content_hash, collector + ) + SELECT + $1, day, device, 'codex', ARRAY['gpt-5.6'], 100, 20, 10, 0, 30, + 160, 0.25, + jsonb_build_array(jsonb_build_object( + 'model', 'gpt-5.6', 'input_tokens', 100, 'output_tokens', 20, + 'reasoning_output_tokens', 10, 'cache_creation_tokens', 0, + 'cache_read_tokens', 30, 'total_tokens', 160, 'cost_usd', 0.25 + )), + repeat('a', 64), + '{"name":"ccusage","version":"20.0.16","pricing_mode":"online"}'::jsonb + FROM unnest($2::date[]) AS day + CROSS JOIN unnest($3::uuid[]) AS device`, + [userId, dates, [deviceA, deviceB]], + ); + await db.query( + `INSERT INTO public.device_usage ( + user_id, date, device_id, device_name, cost_usd, input_tokens, + output_tokens, reasoning_output_tokens, cache_creation_tokens, + cache_read_tokens, total_tokens, models, model_breakdown, + session_count, raw_hash, collector_meta + ) + SELECT + $1, day, device, 'same-host', 0.25, 100, 20, 10, 0, 30, 160, + '["gpt-5.6"]'::jsonb, + '[{"model":"gpt-5.6","cost_usd":0.25}]'::jsonb, + 1, repeat('a', 64), + '{"name":"ccusage","version":"20.0.16","pricing_mode":"online"}'::jsonb + FROM unnest($2::date[]) AS day + CROSS JOIN unnest($3::uuid[]) AS device`, + [userId, dates, [deviceA, deviceB]], + ); + const daily = await db.query<{ id: string; date: string }>( + `INSERT INTO public.daily_usage ( + user_id, date, cost_usd, input_tokens, output_tokens, + reasoning_output_tokens, cache_creation_tokens, cache_read_tokens, + total_tokens, models, model_breakdown, session_count, is_verified + ) + SELECT + $1, day, 0.50, 200, 40, 20, 0, 60, 320, + '["gpt-5.6"]'::jsonb, + '[{"model":"gpt-5.6","cost_usd":0.50}]'::jsonb, + 2, true + FROM unnest($2::date[]) AS day + RETURNING id, date::text`, + [userId, dates], + ); + for (const row of daily.rows) { + const generated = row.date === today; + await db.query( + `INSERT INTO public.posts ( + user_id, daily_usage_id, title, usage_generated_title + ) VALUES ( + $1, $2, + CASE WHEN $3 THEN to_char($4::date, 'Mon FMDD') || ', $0.50' + ELSE 'Keep this custom title' END, + $3 + )`, + [userId, row.id, generated, row.date], + ); + } + + await db.query("SELECT public.discover_usage_device_candidates($1)", [userId]); + const candidateBefore = await db.query( + `SELECT id, status, overlap_dates, divergent_dates + FROM public.usage_device_reconciliation_candidates + WHERE user_id = $1`, + [userId], + ); + expect(candidateBefore.rows).toHaveLength(1); + expect(candidateBefore.rows[0]).toMatchObject({ + status: "proof_merge", + divergent_dates: [], + }); + expect(candidateBefore.rows[0].overlap_dates).toHaveLength(2); + + const started = await db.query<{ id: string }>( + "SELECT public.start_usage_repair_batch('integration rollback proof') AS id", + ); + const batchId = started.rows[0]!.id; + const run = await db.query<{ result: { complete: boolean } }>( + "SELECT public.run_usage_repair_batch($1, 25) AS result", + [batchId], + ); + expect(run.rows[0]!.result.complete).toBe(true); + + const repaired = await db.query( + `SELECT + (SELECT count(*)::int FROM public.device_usage WHERE user_id = $1) AS devices, + (SELECT count(*)::int FROM public.usage_agent_daily WHERE user_id = $1) AS agents, + (SELECT bool_and(cost_usd = 0.25) FROM public.daily_usage WHERE user_id = $1) AS exact_daily, + (SELECT count(*)::int FROM public.usage_installation_aliases + WHERE user_id = $1 AND canonical_device_id = $2) AS canonical_aliases, + (SELECT title FROM public.posts AS post + JOIN public.daily_usage AS daily ON daily.id = post.daily_usage_id + WHERE daily.user_id = $1 AND post.usage_generated_title) AS generated_title, + (SELECT title FROM public.posts AS post + JOIN public.daily_usage AS daily ON daily.id = post.daily_usage_id + WHERE daily.user_id = $1 AND NOT post.usage_generated_title) AS custom_title`, + [userId, deviceA], + ); + expect(repaired.rows[0]).toMatchObject({ + devices: 2, + agents: 2, + exact_daily: true, + canonical_aliases: 2, + generated_title: expect.stringContaining("$0.25"), + custom_title: "Keep this custom title", + }); + + await db.query("SELECT public.rollback_usage_repair_batch($1)", [batchId]); + const restored = await db.query( + `SELECT + (SELECT count(*)::int FROM public.device_usage WHERE user_id = $1) AS devices, + (SELECT count(*)::int FROM public.usage_agent_daily WHERE user_id = $1) AS agents, + (SELECT bool_and(cost_usd = 0.50) FROM public.daily_usage WHERE user_id = $1) AS exact_daily, + (SELECT count(*)::int FROM public.usage_installation_aliases + WHERE user_id = $1 AND canonical_device_id = device_id) AS separate_aliases, + (SELECT status FROM public.usage_device_reconciliation_candidates + WHERE user_id = $1) AS candidate_status, + (SELECT count(*)::int FROM public.usage_device_reconciliation_decisions + WHERE user_id = $1) AS decisions, + (SELECT title FROM public.posts AS post + JOIN public.daily_usage AS daily ON daily.id = post.daily_usage_id + WHERE daily.user_id = $1 AND post.usage_generated_title) AS generated_title, + (SELECT title FROM public.posts AS post + JOIN public.daily_usage AS daily ON daily.id = post.daily_usage_id + WHERE daily.user_id = $1 AND NOT post.usage_generated_title) AS custom_title`, + [userId], + ); + expect(restored.rows[0]).toMatchObject({ + devices: 4, + agents: 4, + exact_daily: true, + separate_aliases: 2, + candidate_status: "proof_merge", + decisions: 0, + generated_title: expect.stringContaining("$0.50"), + custom_title: "Keep this custom title", + }); + }); + + it("merges an ambiguous identity without dropping divergent overlapping usage", async () => { + const userId = await insertUser(db, { username: "v2_ambiguous_merge" }); + const deviceA = "30000000-0000-4000-8000-000000000003"; + const deviceB = "40000000-0000-4000-8000-000000000004"; + + await db.query( + `INSERT INTO public.usage_installation_aliases ( + device_id, user_id, canonical_device_id, name, created_at, updated_at + ) VALUES + ($2, $1, $2, 'same-host', now() - interval '2 days', now()), + ($3, $1, $3, 'same-host', now() - interval '1 day', now())`, + [userId, deviceA, deviceB], + ); + await db.query( + `INSERT INTO public.usage_agent_daily ( + user_id, date, device_id, agent, models, input_tokens, output_tokens, + reasoning_output_tokens, cache_creation_tokens, cache_read_tokens, + total_tokens, cost_usd, model_breakdown, content_hash, collector + ) VALUES + ($1, $2, $3, 'codex', ARRAY['gpt-5.6'], 100, 20, 10, 0, 30, 160, 0.25, + $5::jsonb, repeat('a', 64), $7::jsonb), + ($1, $2, $4, 'codex', ARRAY['gpt-5.6'], 200, 40, 20, 0, 60, 320, 0.50, + $6::jsonb, repeat('b', 64), $7::jsonb)`, + [ + userId, + today, + deviceA, + deviceB, + JSON.stringify(v2Agent().model_breakdown), + JSON.stringify(v2Agent({ + input_tokens: 200, + output_tokens: 40, + reasoning_output_tokens: 20, + cache_read_tokens: 60, + total_tokens: 320, + cost_usd: 0.5, + model_breakdown: [{ + model: "gpt-5.6", + input_tokens: 200, + output_tokens: 40, + reasoning_output_tokens: 20, + cache_creation_tokens: 0, + cache_read_tokens: 60, + total_tokens: 320, + cost_usd: 0.5, + }], + }).model_breakdown), + JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + ], + ); + await db.query( + `INSERT INTO public.device_usage ( + user_id, date, device_id, device_name, cost_usd, input_tokens, + output_tokens, reasoning_output_tokens, cache_creation_tokens, + cache_read_tokens, total_tokens, models, model_breakdown, + session_count, raw_hash, collector_meta + ) VALUES + ($1, $2, $3, 'same-host', 0.25, 100, 20, 10, 0, 30, 160, + '["gpt-5.6"]'::jsonb, '[{"model":"gpt-5.6","cost_usd":0.25}]'::jsonb, + 1, repeat('a', 64), $5::jsonb), + ($1, $2, $4, 'same-host', 0.50, 200, 40, 20, 0, 60, 320, + '["gpt-5.6"]'::jsonb, '[{"model":"gpt-5.6","cost_usd":0.50}]'::jsonb, + 1, repeat('b', 64), $5::jsonb)`, + [ + userId, + today, + deviceA, + deviceB, + JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + ], + ); + await db.query( + `INSERT INTO public.daily_usage ( + user_id, date, cost_usd, input_tokens, output_tokens, + reasoning_output_tokens, cache_creation_tokens, cache_read_tokens, + total_tokens, models, model_breakdown, session_count, is_verified + ) VALUES ( + $1, $2, 0.75, 300, 60, 30, 0, 90, 480, + '["gpt-5.6"]'::jsonb, + '[{"model":"gpt-5.6","cost_usd":0.75}]'::jsonb, 2, true + )`, + [userId, today], + ); + + await db.query("SELECT public.discover_usage_device_candidates($1)", [userId]); + const candidate = await db.query<{ id: string; status: string }>( + `SELECT id, status + FROM public.usage_device_reconciliation_candidates + WHERE user_id = $1`, + [userId], + ); + expect(candidate.rows[0]!.status).toBe("ambiguous"); + + await db.query( + "SELECT public.resolve_usage_device_candidate($1, $2, 'merge')", + [userId, candidate.rows[0]!.id], + ); + const merged = await db.query( + `SELECT + (SELECT count(*)::int FROM public.usage_agent_daily WHERE user_id = $1) AS agents, + (SELECT sum(total_tokens)::int FROM public.usage_agent_daily WHERE user_id = $1) AS agent_tokens, + (SELECT sum(cost_usd)::numeric FROM public.usage_agent_daily WHERE user_id = $1) AS agent_cost, + (SELECT count(*)::int FROM public.device_usage WHERE user_id = $1) AS devices, + (SELECT total_tokens::int FROM public.daily_usage WHERE user_id = $1) AS daily_tokens, + (SELECT cost_usd::numeric FROM public.daily_usage WHERE user_id = $1) AS daily_cost, + (SELECT count(*)::int FROM public.usage_installation_aliases + WHERE user_id = $1 AND canonical_device_id = $2) AS canonical_aliases, + (SELECT status FROM public.usage_device_reconciliation_candidates + WHERE user_id = $1) AS candidate_status`, + [userId, deviceA], + ); + expect(merged.rows[0]).toMatchObject({ + agents: 2, + agent_tokens: 480, + devices: 2, + daily_tokens: 480, + canonical_aliases: 2, + candidate_status: "merged", + }); + expect(Number(merged.rows[0].agent_cost)).toBeCloseTo(0.75, 6); + expect(Number(merged.rows[0].daily_cost)).toBeCloseTo(0.75, 6); + }); + + it("returns 409 when request_id plus date is retried with different content", async () => { + const userId = await insertUser(db, { username: "v2_conflict" }); + const token = await mintCliToken(userId, "v2_conflict"); + + const first = await callSubmit(v2Body("v2-conflict", "c".repeat(64)), token); + const conflict = await callSubmit(v2Body("v2-conflict", "d".repeat(64)), token); + const json = await conflict.json(); + + expect(first.status).toBe(200); + expect(conflict.status).toBe(409); + expect(json.outcomes[0]).toMatchObject({ + status: "identity_conflict", + error: { code: "idempotency_conflict" }, + }); + const row = await db.query( + `SELECT content_hash FROM public.usage_submission_outcomes + WHERE user_id = $1 AND request_id = 'v2-conflict'`, + [userId], + ); + expect(row.rows[0].content_hash).toBe("c".repeat(64)); + }); + + it("scopes the same durable installation id independently for each account", async () => { + const firstUserId = await insertUser(db, { username: "install_owner" }); + const secondUserId = await insertUser(db, { username: "install_second" }); + const firstToken = await mintCliToken(firstUserId, "install_owner"); + const secondToken = await mintCliToken(secondUserId, "install_second"); + + const first = await callSubmit(v2Body("installation-owner", "4".repeat(64)), firstToken); + const second = await callSubmit( + v2Body("installation-second-account", "5".repeat(64)), + secondToken, + ); + const json = await second.json(); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(json.outcomes[0]).toMatchObject({ + status: "committed", + }); + const rows = await db.query( + `SELECT + (SELECT count(*)::int FROM public.usage_installation_aliases + WHERE device_id = $1) AS aliases, + (SELECT count(*)::int FROM public.usage_agent_daily + WHERE device_id = $1) AS agents`, + [DEVICE_ID], + ); + expect(rows.rows[0]).toEqual({ aliases: 2, agents: 2 }); + }); + + it("rolls back aliases when the transactional RPC fails after identity resolution", async () => { + const userId = await insertUser(db, { username: "v2_rollback" }); + const invalidEntry = { + date: today, + content_hash: "e".repeat(64), + agents: [v2Agent(), v2Agent({ agent: "x".repeat(101) })], + }; + + await expect(db.query( + `SELECT public.submit_usage_day_v2( + $1::uuid, + 'rollback-request', + 'cli', + 'UTC', + $2::jsonb, + $3::jsonb, + $4::jsonb, + $5, + true + )`, + [ + userId, + JSON.stringify({ id: DEVICE_ID, name: "rollback-device" }), + JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + JSON.stringify(invalidEntry), + "f".repeat(64), + ], + )).rejects.toThrow(); + + const rows = await db.query( + `SELECT + (SELECT count(*)::int FROM public.usage_installation_aliases WHERE user_id = $1) AS aliases, + (SELECT count(*)::int FROM public.usage_agent_daily WHERE user_id = $1) AS agents, + (SELECT count(*)::int FROM public.device_usage WHERE user_id = $1) AS devices, + (SELECT count(*)::int FROM public.daily_usage WHERE user_id = $1) AS daily, + (SELECT count(*)::int FROM public.posts WHERE user_id = $1) AS posts, + (SELECT count(*)::int FROM public.usage_submission_outcomes WHERE user_id = $1) AS outcomes`, + [userId], + ); + expect(rows.rows[0]).toEqual({ + aliases: 0, + agents: 0, + devices: 0, + daily: 0, + posts: 0, + outcomes: 0, + }); + }); + + it("allows trusted ccusage-by-agent-v2 corrections but ignores untrusted decreases", async () => { + const userId = await insertUser(db, { username: "v2_correction" }); + const token = await mintCliToken(userId, "v2_correction"); + await callSubmit(v2Body("v2-high", "1".repeat(64), { + agents: [v2Agent({ + input_tokens: 200, + total_tokens: 260, + cost_usd: 0.5, + model_breakdown: [{ + model: "gpt-5.6", + input_tokens: 200, + output_tokens: 20, + reasoning_output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 30, + total_tokens: 260, + cost_usd: 0.5, + }], + })], + }), token); + + await callSubmit(v2Body("v2-untrusted-low", "2".repeat(64)), token); + let row = await db.query( + "SELECT total_tokens, cost_usd FROM public.usage_agent_daily WHERE user_id = $1", + [userId], + ); + expect(Number(row.rows[0].total_tokens)).toBe(260); + expect(Number(row.rows[0].cost_usd)).toBeCloseTo(0.5, 6); + + await callSubmit(v2Body("v2-trusted-low", "3".repeat(64), { + authoritative_correction: true, + migration_id: "ccusage-by-agent-v2", + }), token); + row = await db.query( + "SELECT total_tokens, cost_usd, migration_id FROM public.usage_agent_daily WHERE user_id = $1", + [userId], + ); + expect(Number(row.rows[0].total_tokens)).toBe(160); + expect(Number(row.rows[0].cost_usd)).toBeCloseTo(0.25, 6); + expect(row.rows[0].migration_id).toBe("ccusage-by-agent-v2"); + }); + + it("atomically replaces legacy-unpartitioned accounting with a trusted v2 snapshot", async () => { + const userId = await insertUser(db, { username: "v2_legacy_replace" }); + const token = await mintCliToken(userId, "v2_legacy_replace"); + await db.query( + `INSERT INTO public.usage_agent_daily ( + user_id, date, device_id, agent, models, input_tokens, output_tokens, + reasoning_output_tokens, cache_creation_tokens, cache_read_tokens, + total_tokens, cost_usd, model_breakdown, content_hash, collector + ) VALUES ( + $1, $2, $3, 'legacy-unpartitioned', ARRAY['gpt-5.6'], + 100, 20, 10, 0, 30, 160, 0.25, + $4::jsonb, repeat('a', 64), '{"name":"legacy-unpartitioned"}'::jsonb + )`, + [userId, today, DEVICE_ID, JSON.stringify(v2Agent().model_breakdown)], + ); + await db.query( + `INSERT INTO public.device_usage ( + user_id, date, device_id, device_name, cost_usd, input_tokens, + output_tokens, reasoning_output_tokens, cache_creation_tokens, + cache_read_tokens, total_tokens, models, model_breakdown, session_count + ) VALUES ( + $1, $2, $3, 'legacy-device', 0.25, 100, 20, 10, 0, 30, 160, + '["gpt-5.6"]'::jsonb, + '[{"model":"gpt-5.6","cost_usd":0.25}]'::jsonb, 1 + )`, + [userId, today, DEVICE_ID], + ); + await db.query( + `INSERT INTO public.daily_usage ( + user_id, date, cost_usd, input_tokens, output_tokens, + reasoning_output_tokens, cache_creation_tokens, cache_read_tokens, + total_tokens, models, model_breakdown, session_count + ) VALUES ( + $1, $2, 0.25, 100, 20, 10, 0, 30, 160, + '["gpt-5.6"]'::jsonb, + '[{"model":"gpt-5.6","cost_usd":0.25}]'::jsonb, 1 + )`, + [userId, today], + ); + + const response = await callSubmit( + v2Body("v2-legacy-replace", "8".repeat(64)), + token, + ); + + expect(response.status).toBe(200); + const exact = await db.query( + `SELECT + (SELECT array_agg(agent ORDER BY agent) + FROM public.usage_agent_daily WHERE user_id = $1) AS agents, + (SELECT total_tokens::int FROM public.device_usage WHERE user_id = $1) AS device_tokens, + (SELECT total_tokens::int FROM public.daily_usage WHERE user_id = $1) AS daily_tokens`, + [userId], + ); + expect(exact.rows[0]).toEqual({ + agents: ["codex"], + device_tokens: 160, + daily_tokens: 160, + }); + }); + + it("keeps v2 tables and RPC private to service_role", async () => { + const grants = await db.query( + `SELECT grantee, privilege_type, table_name + FROM information_schema.role_table_grants + WHERE table_schema = 'public' + AND table_name IN ( + 'usage_installation_aliases', + 'usage_agent_daily', + 'usage_submission_outcomes', + 'usage_device_reconciliation_decisions', + 'usage_corrections_ledger', + 'device_usage' + ) + ORDER BY table_name, grantee, privilege_type`, + ); + const grantedRoles = new Set( + grants.rows + .filter((row) => row.table_name !== "device_usage") + .map((row) => row.grantee), + ); + expect(grantedRoles).toContain("service_role"); + expect(grantedRoles).not.toContain("anon"); + expect(grantedRoles).not.toContain("authenticated"); + const privileges = new Set(grants.rows.map( + (row) => `${row.table_name}:${row.privilege_type}`, + )); + expect(privileges).toContain("usage_submission_outcomes:UPDATE"); + expect(privileges).toContain("usage_corrections_ledger:UPDATE"); + expect(privileges).toContain("usage_device_reconciliation_decisions:DELETE"); + expect(privileges).toContain("device_usage:DELETE"); + const rls = await db.query( + `SELECT relname, relrowsecurity + FROM pg_class + JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace + WHERE pg_namespace.nspname = 'public' + AND relname IN ( + 'usage_installation_aliases', + 'usage_agent_daily', + 'usage_submission_outcomes' + )`, + ); + expect(rls.rows).toHaveLength(3); + expect(rls.rows.every((row) => row.relrowsecurity)).toBe(true); + const functionGrants = await db.query( + `SELECT grantee + FROM information_schema.routine_privileges + WHERE specific_schema = 'public' + AND routine_name = 'submit_usage_day_v2'`, + ); + const functionRoles = new Set(functionGrants.rows.map((row) => row.grantee)); + expect(functionRoles).toContain("service_role"); + expect(functionRoles).not.toContain("anon"); + expect(functionRoles).not.toContain("authenticated"); + }); + it("rejects unauthenticated requests without writing anything", async () => { const before = await db.query("SELECT count(*)::int AS n FROM public.daily_usage"); const res = await callSubmit( @@ -115,7 +868,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { reasoningOutputTokens: 125, cacheCreationTokens: 100, cacheReadTokens: 200, - totalTokens: 1800, + totalTokens: 1925, costUSD: 0.05, modelBreakdown: [{ model: "claude-sonnet-4-5-20250929", cost_usd: 0.05 }], }, @@ -156,7 +909,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { expect(Number(rows[0].input_tokens)).toBe(1000); expect(Number(rows[0].output_tokens)).toBe(500); expect(Number(rows[0].reasoning_output_tokens)).toBe(125); - expect(Number(rows[0].total_tokens)).toBe(1800); + expect(Number(rows[0].total_tokens)).toBe(1925); expect(rows[0].models).toContain("claude-sonnet-4-5-20250929"); // The route also writes a device_usage row keyed by device_id. diff --git a/apps/web/__tests__/unit/migration-safety.test.ts b/apps/web/__tests__/unit/migration-safety.test.ts index b7c83356..d3f399dd 100644 --- a/apps/web/__tests__/unit/migration-safety.test.ts +++ b/apps/web/__tests__/unit/migration-safety.test.ts @@ -5,6 +5,10 @@ import { join } from "path"; const MIGRATIONS_DIR = join(__dirname, "../../../../supabase/migrations"); const DIRECT_USAGE_REPAIR_ROLLBACK = "20260507000200_rollback_codex_sql_repairs.sql"; +const USAGE_SUBMISSION_RPC = + "20260723133731_usage_submission_v2.sql"; +const USAGE_RECONCILIATION = + "20260723135641_usage_reconciliation.sql"; function getAllMigrations(): { name: string; content: string }[] { const files = readdirSync(MIGRATIONS_DIR) @@ -246,7 +250,9 @@ describe("Migration safety", () => { const futureMigrations = migrations.filter( (migration) => migration.name > DIRECT_USAGE_REPAIR_ROLLBACK - && migration.name !== DIRECT_USAGE_REPAIR_ROLLBACK, + && migration.name !== DIRECT_USAGE_REPAIR_ROLLBACK + && migration.name !== USAGE_SUBMISSION_RPC + && migration.name !== USAGE_RECONCILIATION, ); for (const migration of futureMigrations) { @@ -257,6 +263,37 @@ describe("Migration safety", () => { /\b(UPDATE|INSERT\s+INTO|DELETE\s+FROM)\s+public\.(daily_usage|device_usage)\b/i, ); } + + const submissionMigration = migrations.find( + (migration) => migration.name === USAGE_SUBMISSION_RPC, + ); + expect(submissionMigration, "Expected the atomic usage submission RPC migration").toBeTruthy(); + expect(submissionMigration!.content).toMatch( + /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.submit_usage_day_v2/i, + ); + expect(submissionMigration!.content).toMatch( + /pg_catalog\.left\(max\(usage\.device_name\),\s*255\)/i, + ); + + const reconciliationMigration = migrations.find( + (migration) => migration.name === USAGE_RECONCILIATION, + ); + expect(reconciliationMigration, "Expected the ledgered usage reconciliation migration").toBeTruthy(); + expect(reconciliationMigration!.content).toMatch( + /CREATE\s+TABLE\s+public\.usage_corrections_ledger/i, + ); + expect(reconciliationMigration!.content).toMatch( + /CREATE\s+OR\s+REPLACE\s+FUNCTION\s+public\.rollback_usage_repair_batch/i, + ); + expect(reconciliationMigration!.content).toMatch( + /\^\[A-Z\]\[a-z\]\{2\}\s+\[0-9\]\{1,2\}\(\s+—\s+\.\+\)\?\$/, + ); + expect(reconciliationMigration!.content).toMatch( + /canonical\.model_breakdown\s+IS\s+NOT\s+DISTINCT\s+FROM\s+duplicate\.model_breakdown/i, + ); + expect(reconciliationMigration!.content).toMatch( + /UPDATE\s+public\.usage_agent_daily\s+AS\s+rows[\s\S]*?AND\s+NOT\s+EXISTS/i, + ); }); it("rollback migration does not undo rows already healed by the fixed CLI collector", () => { diff --git a/apps/web/__tests__/unit/usage-import.test.ts b/apps/web/__tests__/unit/usage-import.test.ts new file mode 100644 index 00000000..5c1903c1 --- /dev/null +++ b/apps/web/__tests__/unit/usage-import.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { toLegacyUsageImportEntries } from "@/lib/usage-import"; + +describe("toLegacyUsageImportEntries", () => { + const base = { + date: "2026-07-23", + models: ["gpt-5.6"], + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 30, + totalTokens: 170, + costUSD: 0.25, + }; + + it("omits absent reasoning tokens so the server can infer the residual", () => { + const [entry] = toLegacyUsageImportEntries([base]); + + expect(entry?.data).not.toHaveProperty("reasoningOutputTokens"); + }); + + it("preserves an explicit reasoning-token value", () => { + const [entry] = toLegacyUsageImportEntries([{ + ...base, + reasoningOutputTokens: 20, + }]); + + expect(entry?.data).toHaveProperty("reasoningOutputTokens", 20); + }); +}); diff --git a/apps/web/__tests__/unit/usage-protocol.test.ts b/apps/web/__tests__/unit/usage-protocol.test.ts new file mode 100644 index 00000000..c36ff9d4 --- /dev/null +++ b/apps/web/__tests__/unit/usage-protocol.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalizeUsageEntryV2, + parseAgentUsageComponent, + parseUsageSubmitV2, + parseUsageSubmitResponseV2, + type UsageSubmitRequestV2, +} from "@straude/shared/usage-protocol"; + +const DATE = "2026-07-23"; + +function validRequest(): UsageSubmitRequestV2 { + return { + protocol_version: 2, + request_id: "request-123", + source: "cli", + timezone: "America/Vancouver", + installation: { + id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + name: "work-laptop", + }, + collector: { + name: "ccusage", + version: "20.0.16", + pricing_mode: "online", + }, + entries: [{ + date: DATE, + content_hash: "a".repeat(64), + agents: [{ + agent: "codex", + models: ["gpt-5.6"], + input_tokens: 100, + output_tokens: 20, + reasoning_output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 30, + total_tokens: 160, + cost_usd: 0.25, + model_breakdown: [{ + model: "gpt-5.6", + input_tokens: 100, + output_tokens: 20, + reasoning_output_tokens: 10, + cache_creation_tokens: 0, + cache_read_tokens: 30, + total_tokens: 160, + cost_usd: 0.25, + }], + }], + }], + }; +} + +describe("usage protocol v2", () => { + it("accepts a complete per-agent request", () => { + expect(parseUsageSubmitV2(validRequest())).toEqual({ + ok: true, + value: validRequest(), + }); + }); + + it("parses a source component independently with the same invariants", () => { + expect(parseAgentUsageComponent(validRequest().entries[0]!.agents[0])).toMatchObject({ + ok: true, + value: { agent: "codex", total_tokens: 160, cost_usd: 0.25 }, + }); + }); + + it("rejects aggregate fields that do not equal the model breakdown", () => { + const request = validRequest(); + request.entries[0]!.agents[0]!.cost_usd = 0.24; + + const parsed = parseUsageSubmitV2(request); + + expect(parsed.ok).toBe(false); + if (parsed.ok) return; + expect(parsed.error.code).toBe("invalid_agent_aggregate"); + }); + + it("rejects duplicate agents and mismatched installation identities", () => { + const duplicate = validRequest(); + duplicate.entries[0]!.agents.push(duplicate.entries[0]!.agents[0]!); + expect(parseUsageSubmitV2(duplicate)).toMatchObject({ + ok: false, + error: { code: "duplicate_agent" }, + }); + + const sameIdentity = validRequest(); + sameIdentity.installation.previous_device_id = sameIdentity.installation.id; + expect(parseUsageSubmitV2(sameIdentity)).toMatchObject({ + ok: false, + error: { code: "invalid_installation" }, + }); + }); + + it("canonicalizes semantically identical entries deterministically", () => { + const first = validRequest().entries[0]!; + const second = structuredClone(first); + second.agents[0]!.models = [...second.agents[0]!.models].reverse(); + + expect(canonicalizeUsageEntryV2(first)).toBe(canonicalizeUsageEntryV2(second)); + }); + + it("validates response outcomes and rejects duplicate dates", () => { + expect(parseUsageSubmitResponseV2({ + request_id: "request-123", + outcomes: [{ + date: DATE, + status: "committed", + result: { + usage_id: "usage-1", + post_id: "post-1", + post_url: "https://straude.com/post/post-1", + action: "created", + }, + }], + })).toMatchObject({ ok: true }); + + expect(parseUsageSubmitResponseV2({ + request_id: "request-123", + outcomes: [{ + date: DATE, + status: "unchanged", + }], + })).toEqual({ + ok: true, + value: { + request_id: "request-123", + outcomes: [{ + date: DATE, + status: "unchanged", + }], + }, + }); + + expect(parseUsageSubmitResponseV2({ + request_id: "request-123", + outcomes: [ + { + date: DATE, + status: "permanent_error", + error: { code: "bad_data", message: "bad data" }, + }, + { + date: DATE, + status: "retryable_error", + error: { code: "timeout", message: "try again" }, + }, + ], + })).toMatchObject({ + ok: false, + error: { code: "duplicate_date" }, + }); + }); +}); diff --git a/apps/web/app/(app)/settings/import/page.tsx b/apps/web/app/(app)/settings/import/page.tsx index b23a3561..02d5a252 100644 --- a/apps/web/app/(app)/settings/import/page.tsx +++ b/apps/web/app/(app)/settings/import/page.tsx @@ -5,6 +5,7 @@ import { Check, Copy } from "lucide-react"; import { usePostHog } from "posthog-js/react"; import { Textarea } from "@/components/ui/Textarea"; import { Button } from "@/components/ui/Button"; +import { toLegacyUsageImportEntries } from "@/lib/usage-import"; interface ImportResult { date: string; @@ -51,19 +52,9 @@ export default function ImportPage() { return; } - const entries = (obj.data as Record[]).map((d) => ({ - date: d.date as string, - data: { - date: d.date as string, - models: (d.models as string[]) ?? [], - inputTokens: (d.inputTokens as number) ?? 0, - outputTokens: (d.outputTokens as number) ?? 0, - cacheCreationTokens: (d.cacheCreationTokens as number) ?? 0, - cacheReadTokens: (d.cacheReadTokens as number) ?? 0, - totalTokens: (d.totalTokens as number) ?? 0, - costUSD: (d.costUSD as number) ?? 0, - }, - })); + const entries = toLegacyUsageImportEntries( + obj.data as Record[], + ); const res = await fetch("/api/usage/submit", { method: "POST", diff --git a/apps/web/app/api/usage/devices/auth.ts b/apps/web/app/api/usage/devices/auth.ts new file mode 100644 index 00000000..cda9c411 --- /dev/null +++ b/apps/web/app/api/usage/devices/auth.ts @@ -0,0 +1,44 @@ +import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; +import { createClient } from "@/lib/supabase/server"; + +export interface UsageDevicesAuth { + userId: string; + source: "cli" | "web"; + refreshedToken: string | null; +} + +export async function resolveUsageDevicesAuth( + request: Request, +): Promise { + const authorization = request.headers.get("authorization"); + if (authorization) { + const cli = verifyCliTokenWithRefresh(authorization); + return cli + ? { + userId: cli.userId, + source: "cli", + refreshedToken: cli.refreshedToken, + } + : null; + } + + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + return user?.id + ? { userId: user.id, source: "web", refreshedToken: null } + : null; + } catch { + return null; + } +} + +export function usageDevicesHeaders( + auth: UsageDevicesAuth, +): Record { + return auth.source === "cli" && auth.refreshedToken + ? { "X-Straude-Refreshed-Token": auth.refreshedToken } + : {}; +} diff --git a/apps/web/app/api/usage/devices/resolve/route.ts b/apps/web/app/api/usage/devices/resolve/route.ts new file mode 100644 index 00000000..f20d2611 --- /dev/null +++ b/apps/web/app/api/usage/devices/resolve/route.ts @@ -0,0 +1,150 @@ +import { NextResponse } from "next/server"; +import { getServiceClient } from "@/lib/supabase/service"; +import { + resolveUsageDevicesAuth, + usageDevicesHeaders, +} from "../auth"; + +type ResolutionDecision = "merge" | "keep_separate"; + +interface ResolutionRequest { + candidate_id: string; + decision: ResolutionDecision; +} + +interface ResolvedCandidate { + id: string; + status: string; + decision: ResolutionDecision; + canonical_device_id?: string; +} + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function invalidRequest(message: string): NextResponse { + return NextResponse.json( + { error: { code: "invalid_request", message } }, + { status: 400 }, + ); +} + +async function parseRequest(request: Request): Promise< + { ok: true; value: ResolutionRequest } + | { ok: false; response: NextResponse } +> { + let body: unknown; + try { + body = await request.json(); + } catch { + return { ok: false, response: invalidRequest("Invalid JSON") }; + } + + if (!isRecord(body) || typeof body.candidate_id !== "string") { + return { + ok: false, + response: invalidRequest("candidate_id must be a UUID"), + }; + } + if (!UUID_PATTERN.test(body.candidate_id)) { + return { + ok: false, + response: invalidRequest("candidate_id must be a UUID"), + }; + } + if (body.decision !== "merge" && body.decision !== "keep_separate") { + return { + ok: false, + response: invalidRequest("decision must be merge or keep_separate"), + }; + } + + return { + ok: true, + value: { + candidate_id: body.candidate_id, + decision: body.decision, + }, + }; +} + +function parseResolvedCandidate(value: unknown): ResolvedCandidate | null { + const candidate = Array.isArray(value) ? value[0] : value; + if ( + !isRecord(candidate) + || typeof candidate.id !== "string" + || typeof candidate.status !== "string" + || (candidate.decision !== "merge" + && candidate.decision !== "keep_separate") + || (candidate.canonical_device_id !== undefined + && candidate.canonical_device_id !== null + && typeof candidate.canonical_device_id !== "string") + ) { + return null; + } + + return { + id: candidate.id, + status: candidate.status, + decision: candidate.decision, + ...(typeof candidate.canonical_device_id === "string" + ? { canonical_device_id: candidate.canonical_device_id } + : {}), + }; +} + +export async function POST(request: Request): Promise { + const parsed = await parseRequest(request); + if (!parsed.ok) return parsed.response; + + const auth = await resolveUsageDevicesAuth(request); + if (!auth) { + return NextResponse.json( + { error: { code: "unauthorized", message: "Unauthorized" } }, + { status: 401 }, + ); + } + + const { data, error } = await getServiceClient().rpc( + "resolve_usage_device_candidate", + { + p_user_id: auth.userId, + p_candidate_id: parsed.value.candidate_id, + p_decision: parsed.value.decision, + }, + ); + const headers = usageDevicesHeaders(auth); + if (error) { + const notFound = error.code === "P0002"; + return NextResponse.json( + { + error: { + code: notFound ? "candidate_not_found" : "candidate_resolution_failed", + message: notFound + ? "Usage device candidate not found" + : "Failed to resolve usage device candidate", + }, + }, + { status: notFound ? 404 : 500, headers }, + ); + } + + const candidate = parseResolvedCandidate(data); + if (!candidate) { + return NextResponse.json( + { + error: { + code: "invalid_candidate_response", + message: "Invalid usage device candidate response", + }, + }, + { status: 502, headers }, + ); + } + + return NextResponse.json({ candidate }, { headers }); +} diff --git a/apps/web/app/api/usage/devices/route.ts b/apps/web/app/api/usage/devices/route.ts new file mode 100644 index 00000000..50983793 --- /dev/null +++ b/apps/web/app/api/usage/devices/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { getServiceClient } from "@/lib/supabase/service"; +import { + resolveUsageDevicesAuth, + usageDevicesHeaders, +} from "./auth"; + +interface UsageDeviceCandidate { + id: string; + device_id_a: string; + device_id_b: string; + normalized_hostname: string; + overlap_dates: string[]; + status: string; + created_at: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCandidate(value: unknown): UsageDeviceCandidate | null { + if ( + !isRecord(value) + || typeof value.id !== "string" + || typeof value.device_id_a !== "string" + || typeof value.device_id_b !== "string" + || typeof value.normalized_hostname !== "string" + || !Array.isArray(value.overlap_dates) + || !value.overlap_dates.every((date) => typeof date === "string") + || typeof value.status !== "string" + || typeof value.created_at !== "string" + ) { + return null; + } + + return { + id: value.id, + device_id_a: value.device_id_a, + device_id_b: value.device_id_b, + normalized_hostname: value.normalized_hostname, + overlap_dates: value.overlap_dates, + status: value.status, + created_at: value.created_at, + }; +} + +export async function GET(request: Request): Promise { + const auth = await resolveUsageDevicesAuth(request); + if (!auth) { + return NextResponse.json( + { error: { code: "unauthorized", message: "Unauthorized" } }, + { status: 401 }, + ); + } + + const { data, error } = await getServiceClient().rpc( + "list_usage_device_candidates", + { p_user_id: auth.userId }, + ); + const headers = usageDevicesHeaders(auth); + if (error) { + return NextResponse.json( + { + error: { + code: "candidate_list_failed", + message: "Failed to list usage device candidates", + }, + }, + { status: 500, headers }, + ); + } + + if (!Array.isArray(data)) { + return NextResponse.json( + { + error: { + code: "invalid_candidate_response", + message: "Invalid usage device candidate response", + }, + }, + { status: 502, headers }, + ); + } + + const candidates = data.map(parseCandidate); + if (candidates.some((candidate) => candidate === null)) { + return NextResponse.json( + { + error: { + code: "invalid_candidate_response", + message: "Invalid usage device candidate response", + }, + }, + { status: 502, headers }, + ); + } + + return NextResponse.json( + { candidates }, + { headers }, + ); +} diff --git a/apps/web/app/api/usage/submit/route.ts b/apps/web/app/api/usage/submit/route.ts index 0d5f1770..73101c12 100644 --- a/apps/web/app/api/usage/submit/route.ts +++ b/apps/web/app/api/usage/submit/route.ts @@ -1,4 +1,17 @@ +import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; +import { + canonicalizeUsageEntryV2, + parseUsageSubmitV2, + parseUsageSubmitResponseV2, + type AgentUsageComponent, + type JsonValue, + type ModelUsageComponent, + type UsageEntryV2, + type UsageOutcomeV2, + type UsageSubmitRequestV2, + type UsageSubmitResponseV2, +} from "@straude/shared/usage-protocol"; import { after } from "@/lib/utils/after"; import { captureServerActivationEvent } from "@/lib/analytics/server"; import { createClient } from "@/lib/supabase/server"; @@ -6,90 +19,85 @@ import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; import { getServiceClient } from "@/lib/supabase/service"; import { checkAndAwardAchievements } from "@/lib/achievements"; import { rateLimit } from "@/lib/rate-limit"; -import { formatCurrency } from "@/lib/utils/format"; -import type { UsageSubmitRequest, UsageSubmitResponse, CcusageDailyEntry, ModelBreakdownEntry, UsageCollectorMeta } from "@/types"; +import type { + CcusageDailyEntry, + UsageCollectorMeta, + UsageSubmitResponse, +} from "@/types"; const MAX_BACKFILL_DAYS = 30; const MAX_USAGE_ENTRIES = MAX_BACKFILL_DAYS + 2; const MAX_USAGE_BODY_BYTES = 256 * 1024; const USAGE_PROCESS_CONCURRENCY = 4; -// Trusted collectors are the only ones allowed to *lower* Codex totals on -// UPSERT, which is how the server accepts retroactive collector corrections. -const TRUSTED_CODEX_COLLECTORS = new Set([ +const COST_EPSILON_USD = 0.005; +const DEFAULT_V1_CUTOFF = "2026-08-06"; +const RETRYABLE_DATABASE_CODES = new Set([ + "40001", + "40P01", + "53300", + "55P03", + "57014", + "57P01", + "57P02", + "57P03", +]); +const TRUSTED_CORRECTION_COLLECTORS = new Set([ "straude-codex-native-last-token-usage", "ccusage-codex-v20", ]); -const LEGACY_DEVICE_ID = "00000000-0000-0000-0000-000000000000"; -const CODEX_MODEL_RE = /^(gpt-|o3|o4)/i; -const COST_EPSILON_USD = 0.005; -const REPAIR_META_KEYS = [ - "repair", - "previous_cost_usd", - "previous_input_tokens", - "previous_cache_read_tokens", - "repaired_at", - "repair_v3_codex_only", - "cost_before_v3", - "total_tokens_before_v3", - "cache_read_before_v3", - "model_breakdown_before_v3", - "repaired_at_v3", - "claude_restore_2026_05_07", - "cost_before_claude_restore", -] as const; - -function isValidDate(dateStr: string): boolean { - const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) return false; - const d = new Date(dateStr); - return !isNaN(d.getTime()); + +interface AuthContext { + userId: string; + source: "cli" | "web"; + refreshedToken?: string | null; } -function isWithinBackfillWindow(dateStr: string, maxBackfillDays: number): boolean { - const now = new Date(); - const target = new Date(dateStr); - const diffMs = now.getTime() - target.getTime(); - const diffDays = diffMs / (1000 * 60 * 60 * 24); - return diffDays >= -1 && diffDays <= maxBackfillDays; +interface RpcError { + code?: string; + message: string; } -function validateEntry(entry: CcusageDailyEntry): string | null { - if (entry.costUSD < 0) return `Negative cost for ${entry.date}`; - if (entry.inputTokens < 0) return `Negative input tokens for ${entry.date}`; - if (entry.outputTokens < 0) return `Negative output tokens for ${entry.date}`; - if ((entry.reasoningOutputTokens ?? 0) < 0) return `Negative reasoning output tokens for ${entry.date}`; - if (entry.totalTokens < 0) return `Negative total tokens for ${entry.date}`; - return null; +interface UsageRpcClient { + rpc( + name: string, + params: Record, + ): PromiseLike<{ data: unknown; error: RpcError | null }>; } -function validateCollectorMeta(collector: UsageCollectorMeta | undefined): string | null { - if (!collector) return null; - if ( - collector.pricing_mode != null && - collector.pricing_mode !== "online" && - collector.pricing_mode !== "offline" - ) { - return "Unsupported pricing mode; ccusage submissions must use online or offline pricing"; - } - if (collector.ccusage_agents != null) { - if (!Array.isArray(collector.ccusage_agents)) { - return "Invalid ccusage_agents collector metadata"; - } - if (collector.ccusage_agents.some((agent) => typeof agent !== "string" || agent.length === 0)) { - return "Invalid ccusage_agents collector metadata"; - } - } - return null; +interface JsonReadSuccess { + ok: true; + body: unknown; } -type JsonReadResult = - | { ok: true; body: T } - | { ok: false; response: NextResponse }; +interface JsonReadFailure { + ok: false; + response: NextResponse; +} + +type JsonReadResult = JsonReadSuccess | JsonReadFailure; + +interface LegacyAdaptResult { + request: UsageSubmitRequestV2; +} -async function readJsonBodyWithLimit( +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isV2Request(value: unknown): boolean { + return isRecord(value) && value.protocol_version === 2; +} + +function isLegacyProtocolSunset(): boolean { + const configured = process.env.STRAUDE_USAGE_V1_CUTOFF ?? DEFAULT_V1_CUTOFF; + const cutoff = Date.parse(`${configured}T00:00:00Z`); + return Number.isFinite(cutoff) && Date.now() >= cutoff; +} + +async function readJsonBodyWithLimit( request: Request, maxBytes: number, -): Promise> { +): Promise { const contentLength = request.headers.get("content-length"); if (contentLength) { const parsedLength = Number(contentLength); @@ -100,7 +108,6 @@ async function readJsonBodyWithLimit( }; } } - if (!request.body) { return { ok: false, @@ -111,12 +118,10 @@ async function readJsonBodyWithLimit( const reader = request.body.getReader(); const chunks: Uint8Array[] = []; let totalBytes = 0; - while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; - totalBytes += value.byteLength; if (totalBytes > maxBytes) { return { @@ -133,12 +138,8 @@ async function readJsonBodyWithLimit( bytes.set(chunk, offset); offset += chunk.byteLength; } - try { - return { - ok: true, - body: JSON.parse(new TextDecoder().decode(bytes)) as T, - }; + return { ok: true, body: JSON.parse(new TextDecoder().decode(bytes)) }; } catch { return { ok: false, @@ -147,733 +148,608 @@ async function readJsonBodyWithLimit( } } -async function mapSettledWithConcurrency( - items: T[], - concurrency: number, - mapper: (item: T, index: number) => Promise, -): Promise[]> { - const results = new Array>(items.length); - let nextIndex = 0; - - async function worker() { - while (nextIndex < items.length) { - const index = nextIndex; - nextIndex += 1; - - try { - results[index] = { status: "fulfilled", value: await mapper(items[index]!, index) }; - } catch (reason) { - results[index] = { status: "rejected", reason }; - } - } +async function resolveAuthContext(request: Request): Promise { + const cliAuth = verifyCliTokenWithRefresh(request.headers.get("authorization")); + if (cliAuth) { + return { + userId: cliAuth.userId, + source: "cli", + refreshedToken: cliAuth.refreshedToken, + }; + } + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + return user?.id ? { userId: user.id, source: "web" } : null; + } catch { + return null; } - - await Promise.all( - Array.from({ length: Math.min(concurrency, items.length) }, () => worker()), - ); - return results; -} - -function isCodexModel(model: unknown): boolean { - return typeof model === "string" && CODEX_MODEL_RE.test(model); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); } -function containsCodexModel(models: unknown): boolean { - return Array.isArray(models) && models.some(isCodexModel); +function calendarDateInTimezone(timezone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date()); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${value.year}-${value.month}-${value.day}`; } -function containsNonCodexModel(models: unknown): boolean { - return Array.isArray(models) && models.some((model) => !isCodexModel(model)); +function isWithinBackfillWindow(date: string, timezone = "UTC"): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) return false; + const targetDay = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + const localToday = calendarDateInTimezone(timezone); + const today = Date.UTC( + Number(localToday.slice(0, 4)), + Number(localToday.slice(5, 7)) - 1, + Number(localToday.slice(8, 10)), + ); + const difference = Math.round((today - targetDay) / 86_400_000); + return difference >= 0 && difference <= MAX_BACKFILL_DAYS; } -function hasNonCodexModels(models: unknown): boolean { - if (!Array.isArray(models) || models.length === 0) return true; - return containsNonCodexModel(models); +function hashCanonicalEntry(entry: UsageEntryV2): string { + return createHash("sha256") + .update(canonicalizeUsageEntryV2(entry)) + .digest("hex"); } -function sumBreakdownCost( - breakdown: unknown, - matchesModel: (model: unknown) => boolean, -): number | null { - if (!Array.isArray(breakdown)) return null; - - let total = 0; - for (const item of breakdown) { - if (!isRecord(item)) return null; - if (!matchesModel(item.model)) continue; - - const cost = Number(item.cost_usd); - if (!Number.isFinite(cost)) return null; - total += cost; - } - - return total; +function sumAgentUsage(agents: AgentUsageComponent[]) { + return agents.reduce((total, agent) => ({ + cost_usd: total.cost_usd + agent.cost_usd, + total_tokens: total.total_tokens + agent.total_tokens, + }), { cost_usd: 0, total_tokens: 0 }); } -function entryContainsCodexUsage(entry: CcusageDailyEntry): boolean { - if (containsCodexModel(entry.models)) return true; - const codexBreakdownCost = sumBreakdownCost(entry.modelBreakdown, isCodexModel); - return codexBreakdownCost != null && codexBreakdownCost > 0; +function isRetryableRpcError(error: RpcError): boolean { + if (error.code && RETRYABLE_DATABASE_CODES.has(error.code)) return true; + return /(connection|timeout|temporar|unavailable|too many clients|network)/i.test(error.message); } -function entryContainsNonCodexUsage(entry: CcusageDailyEntry): boolean { - if (containsNonCodexModel(entry.models)) return true; - const nonCodexBreakdownCost = sumBreakdownCost(entry.modelBreakdown, (model) => !isCodexModel(model)); - return nonCodexBreakdownCost != null && nonCodexBreakdownCost > 0; +function responseHeaders(auth: AuthContext): Record { + return auth.source === "cli" && auth.refreshedToken + ? { "X-Straude-Refreshed-Token": auth.refreshedToken } + : {}; } -function rowContainsNonCodexUsage(models: unknown, breakdown: unknown): boolean { - if (hasNonCodexModels(models)) return true; - const nonCodexBreakdownCost = sumBreakdownCost(breakdown, (model) => !isCodexModel(model)); - return nonCodexBreakdownCost != null && nonCodexBreakdownCost > 0; +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + async function worker(): Promise { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index]!); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, () => worker()), + ); + return results; } -function collectorForEntry( - collector: UsageCollectorMeta | undefined, - entry: CcusageDailyEntry, -): UsageCollectorMeta | undefined { - if (!collector) return undefined; - - const entryCollector: Record = {}; - const reportedAgents = Array.isArray(entry.agents) && entry.agents.length > 0 - ? [...new Set(entry.agents)] - : undefined; - const containsClaude = reportedAgents - ? reportedAgents.includes("claude") - : entryContainsNonCodexUsage(entry); - const containsCodex = reportedAgents - ? reportedAgents.includes("codex") - : entryContainsCodexUsage(entry); - - if (collector.claude && containsClaude) { - entryCollector.claude = collector.claude; - } - if (collector.codex && containsCodex) { - entryCollector.codex = collector.codex; +async function submitEntry( + db: UsageRpcClient, + auth: AuthContext, + request: UsageSubmitRequestV2, + entry: UsageEntryV2, + appUrl: string, + cliVersion: string | null, + retryAttempt: number, +): Promise { + const startedAt = performance.now(); + const finish = (outcome: UsageOutcomeV2): UsageOutcomeV2 => { + const elapsedMs = Math.round(performance.now() - startedAt); + console.info(JSON.stringify({ + event: "usage_submit_day", + protocol_version: request.protocol_version, + request_id: request.request_id, + date: entry.date, + source: request.source, + collector_name: request.collector.name, + collector_version: request.collector.version, + cli_version: cliVersion, + stage_timings_ms: { + transaction: elapsedMs, + total: elapsedMs, + }, + outcome: outcome.status, + error_code: outcome.error?.code ?? null, + retry_count: retryAttempt, + })); + return outcome; + }; + const canonicalPayloadHash = hashCanonicalEntry(entry); + const { data, error } = await db.rpc("submit_usage_day_v2", { + p_user_id: auth.userId, + p_request_id: request.request_id, + p_source: request.source, + p_timezone: request.timezone, + p_installation: request.installation, + p_collector: request.collector, + p_entry: entry, + p_canonical_payload_hash: canonicalPayloadHash, + p_is_verified: auth.source === "cli", + }); + if (error) { + const retryable = isRetryableRpcError(error); + return finish({ + date: entry.date, + status: retryable ? "retryable_error" : "permanent_error", + error: { + code: error.code ?? "database_error", + message: retryable + ? "Usage transaction is temporarily unavailable" + : "Usage transaction failed", + }, + }); } - mergeCollectorRunMeta(entryCollector, { - ...collector, - ccusage_agents: reportedAgents ?? collector.ccusage_agents, + const rawCandidate = Array.isArray(data) ? data[0] : data; + const candidate = isRecord(rawCandidate) + && isRecord(rawCandidate.result) + && typeof rawCandidate.result.post_id === "string" + && typeof rawCandidate.result.post_url !== "string" + ? { + ...rawCandidate, + result: { + ...rawCandidate.result, + post_url: `${appUrl}/post/${rawCandidate.result.post_id}`, + }, + } + : rawCandidate; + const parsedResponse = parseUsageSubmitResponseV2({ + request_id: request.request_id, + outcomes: [candidate], }); - - return Object.keys(entryCollector).length > 0 ? entryCollector as UsageCollectorMeta : undefined; -} - -function nonCodexCostIsPreserved( - existingBreakdown: unknown, - incomingBreakdown: unknown, -): boolean { - const existingNonCodexCost = sumBreakdownCost(existingBreakdown, (model) => !isCodexModel(model)); - const incomingNonCodexCost = sumBreakdownCost(incomingBreakdown, (model) => !isCodexModel(model)); - if (existingNonCodexCost == null || incomingNonCodexCost == null) return false; - return incomingNonCodexCost + COST_EPSILON_USD >= existingNonCodexCost; + if (!parsedResponse.ok) { + return finish({ + date: entry.date, + status: "retryable_error", + error: { + code: "invalid_rpc_response", + message: "Usage transaction returned an invalid response", + }, + }); + } + const parsedOutcome = parsedResponse.value.outcomes[0]!; + return finish(parsedOutcome); } -function trustedCodexEntryPreservesNonCodex( - existingModels: unknown, - existingBreakdown: unknown, - incomingEntry: CcusageDailyEntry, -): boolean { - if (!rowContainsNonCodexUsage(existingModels, existingBreakdown)) return true; - return nonCodexCostIsPreserved(existingBreakdown, incomingEntry.modelBreakdown); +function statusForOutcomes(outcomes: UsageOutcomeV2[], allowPartialSuccess: boolean): number { + const hasSuccess = outcomes.some( + (outcome) => outcome.status === "committed" || outcome.status === "unchanged", + ); + const hasFailure = outcomes.some( + (outcome) => outcome.status !== "committed" && outcome.status !== "unchanged", + ); + if (allowPartialSuccess && hasSuccess && hasFailure) return 207; + if (outcomes.some((outcome) => outcome.status === "identity_conflict")) return 409; + if (outcomes.some((outcome) => outcome.status === "permanent_error")) return 400; + if (outcomes.some((outcome) => outcome.status === "retryable_error")) return 503; + return 200; } -function isTruthyMetaValue(value: unknown): boolean { - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value !== "string") return value != null; - - const normalized = value.trim().toLowerCase(); - return normalized !== "" && normalized !== "false" && normalized !== "0"; +function legacyError(message: string): { ok: false; error: string } { + return { ok: false, error: message }; } -function rowWasRepaired(meta: unknown): boolean { - if (!isRecord(meta)) return false; - return isTruthyMetaValue(meta.repair) - || isTruthyMetaValue(meta.repair_v3_codex_only) - || isTruthyMetaValue(meta.claude_restore_2026_05_07); +function isLegacyError( + value: unknown, +): value is { ok: false; error: string } { + return isRecord(value) && value.ok === false && typeof value.error === "string"; } -function mergeRepairMeta(target: Record, meta: unknown): void { - if (!isRecord(meta) || !rowWasRepaired(meta)) return; - for (const key of REPAIR_META_KEYS) { - if (key in meta) target[key] = meta[key]; +function readLegacyNumber( + value: unknown, + field: string, + date: string, +): number | { ok: false; error: string } { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return legacyError(`Invalid ${field} for ${date}`); } + return value; } -function mergeCollectorRunMeta(target: Record, meta: unknown): void { - if (!isRecord(meta)) return; - if (typeof meta.ccusage_version === "string") target.ccusage_version = meta.ccusage_version; - if (Array.isArray(meta.ccusage_agents) && meta.ccusage_agents.every((agent) => typeof agent === "string")) { - target.ccusage_agents = meta.ccusage_agents; - } - if (typeof meta.pricing_mode === "string") target.pricing_mode = meta.pricing_mode; +function legacyAgentId(entry: CcusageDailyEntry): string { + void entry; + return "legacy-unpartitioned"; } -function mergeCollectorSourceMeta(target: Record, meta: unknown): void { - if (!isRecord(meta)) return; - if (typeof meta.claude === "string") target.claude = meta.claude; - if (typeof meta.codex === "string") target.codex = meta.codex; - mergeCollectorRunMeta(target, meta); +function legacyEntryIsCodexOnly(entry: CcusageDailyEntry): boolean { + if (entry.agents?.length === 1) return entry.agents[0] === "codex"; + return entry.models.length > 0 + && entry.models.every((model) => /^(gpt-|o3|o4|codex)/i.test(model)); } -function mergeDailyCollectorMeta( - currentCollector: UsageCollectorMeta | undefined, - existingDailyMeta: unknown, - deviceRows: DeviceUsageRow[], -): UsageCollectorMeta | null { - const merged: Record = {}; - mergeCollectorSourceMeta(merged, existingDailyMeta); - mergeRepairMeta(merged, existingDailyMeta); - for (const row of deviceRows) { - mergeCollectorSourceMeta(merged, row.collector_meta); - mergeRepairMeta(merged, row.collector_meta); +function legacyModelBreakdown( + entry: CcusageDailyEntry, + numbers: Omit, +): ModelUsageComponent[] | { ok: false; error: string } { + const models = [...new Set(entry.models)]; + if (models.length === 1) { + return [{ model: models[0]!, ...numbers }]; } - if (currentCollector) Object.assign(merged, currentCollector); - return Object.keys(merged).length > 0 ? merged as UsageCollectorMeta : null; -} - -function mergeCollectorWithRepairMeta( - currentCollector: UsageCollectorMeta | undefined, - existingMeta: unknown, -): UsageCollectorMeta | null { - const merged: Record = {}; - mergeCollectorSourceMeta(merged, existingMeta); - mergeRepairMeta(merged, existingMeta); - if (currentCollector) Object.assign(merged, currentCollector); - return Object.keys(merged).length > 0 ? merged as UsageCollectorMeta : null; -} - -interface AuthContext { - userId: string; - username?: string | null; - source: "cli" | "web"; - /** When set, the response should include X-Straude-Refreshed-Token. */ - refreshedToken?: string | null; -} -async function resolveAuthContext(request: Request): Promise { - // Try CLI JWT first - const authHeader = request.headers.get("authorization"); - const cliAuth = verifyCliTokenWithRefresh(authHeader); - if (cliAuth) { - return { - userId: cliAuth.userId, - username: cliAuth.username, - source: "cli", - refreshedToken: cliAuth.refreshedToken, - }; + const costs = new Map(); + for (const item of entry.modelBreakdown ?? []) { + if ( + !item + || typeof item.model !== "string" + || item.model.length === 0 + || typeof item.cost_usd !== "number" + || !Number.isFinite(item.cost_usd) + || item.cost_usd < 0 + ) { + return legacyError(`Invalid model breakdown for ${entry.date}`); + } + costs.set(item.model, (costs.get(item.model) ?? 0) + item.cost_usd); } - - // Fall back to Supabase session (web) - try { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (!user?.id) return null; - return { userId: user.id, source: "web" }; - } catch { - return null; + const attributedCost = [...costs.values()].reduce((sum, cost) => sum + cost, 0); + if (attributedCost > numbers.cost_usd + COST_EPSILON_USD) { + return legacyError(`Model breakdown exceeds total cost for ${entry.date}`); } -} -interface DeviceUsageRow { - cost_usd: number; - input_tokens: number; - output_tokens: number; - reasoning_output_tokens?: number; - cache_creation_tokens: number; - cache_read_tokens: number; - total_tokens: number; - models: string[]; - model_breakdown: ModelBreakdownEntry[] | null; - collector_meta?: UsageCollectorMeta | null; + for (const model of models) { + if (!costs.has(model)) costs.set(model, 0); + } + const breakdown = [...costs.entries()].map(([model, cost]) => ({ + model, + input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + cost_usd: cost, + })); + const unattributedModel = models.includes("legacy-unattributed") + ? "legacy-combined-unattributed" + : "legacy-unattributed"; + breakdown.push({ + model: unattributedModel, + ...numbers, + cost_usd: Math.max(numbers.cost_usd - attributedCost, 0), + }); + return breakdown; } -/** - * Aggregate multiple device_usage rows into a single daily_usage summary. - * SUMs numeric fields, unions models (deduplicated), merges model_breakdowns - * by summing cost_usd per model name. - */ -export function aggregateDeviceRows(rows: DeviceUsageRow[]) { - let cost_usd = 0; - let input_tokens = 0; - let output_tokens = 0; - let reasoning_output_tokens = 0; - let cache_creation_tokens = 0; - let cache_read_tokens = 0; - let total_tokens = 0; - const modelsSet = new Set(); - const breakdownMap = new Map(); - - for (const row of rows) { - cost_usd += Number(row.cost_usd); - input_tokens += Number(row.input_tokens); - output_tokens += Number(row.output_tokens); - reasoning_output_tokens += Number(row.reasoning_output_tokens ?? 0); - cache_creation_tokens += Number(row.cache_creation_tokens ?? 0); - cache_read_tokens += Number(row.cache_read_tokens ?? 0); - total_tokens += Number(row.total_tokens); - - if (Array.isArray(row.models)) { - for (const m of row.models) modelsSet.add(m); - } - if (Array.isArray(row.model_breakdown)) { - for (const entry of row.model_breakdown) { - breakdownMap.set(entry.model, (breakdownMap.get(entry.model) ?? 0) + entry.cost_usd); - } - } +function legacyEntryToV2( + outerDate: string, + entry: CcusageDailyEntry, + collector: UsageCollectorMeta | undefined, +): UsageEntryV2 | { ok: false; error: string } { + if (entry.date !== outerDate || !isWithinBackfillWindow(outerDate)) { + return legacyError( + `Date ${outerDate} is invalid or outside the 30-day backfill window`, + ); + } + if (!Array.isArray(entry.models) || entry.models.some((model) => typeof model !== "string")) { + return legacyError(`Invalid models for ${outerDate}`); + } + const input = readLegacyNumber(entry.inputTokens, "input tokens", outerDate); + if (typeof input !== "number") return input; + const output = readLegacyNumber(entry.outputTokens, "output tokens", outerDate); + if (typeof output !== "number") return output; + const cacheCreation = readLegacyNumber(entry.cacheCreationTokens, "cache creation tokens", outerDate); + if (typeof cacheCreation !== "number") return cacheCreation; + const cacheRead = readLegacyNumber(entry.cacheReadTokens, "cache read tokens", outerDate); + if (typeof cacheRead !== "number") return cacheRead; + const total = readLegacyNumber(entry.totalTokens, "total tokens", outerDate); + if (typeof total !== "number") return total; + const inferredReasoning = total - input - output - cacheCreation - cacheRead; + const reasoning = readLegacyNumber( + entry.reasoningOutputTokens ?? inferredReasoning, + "reasoning tokens", + outerDate, + ); + if (typeof reasoning !== "number") return reasoning; + const cost = readLegacyNumber(entry.costUSD, "cost", outerDate); + if (typeof cost !== "number") return cost; + if (total !== input + output + reasoning + cacheCreation + cacheRead) { + return legacyError(`Token categories do not equal total tokens for ${outerDate}`); } - const models = [...modelsSet]; - const model_breakdown: ModelBreakdownEntry[] = breakdownMap.size > 0 - ? [...breakdownMap.entries()].map(([model, cost]) => ({ model, cost_usd: cost })) - : []; - - return { - cost_usd, - input_tokens, - output_tokens, - reasoning_output_tokens, - cache_creation_tokens, - cache_read_tokens, - total_tokens, + const numeric = { + input_tokens: input, + output_tokens: output, + reasoning_output_tokens: reasoning, + cache_creation_tokens: cacheCreation, + cache_read_tokens: cacheRead, + total_tokens: total, + cost_usd: cost, + }; + const modelBreakdown = legacyModelBreakdown(entry, numeric); + if (!Array.isArray(modelBreakdown)) return modelBreakdown; + const models = modelBreakdown.map((model) => model.model); + const agentId = legacyAgentId(entry); + const agents: AgentUsageComponent[] = [{ + agent: agentId, models, - model_breakdown: model_breakdown.length > 0 ? model_breakdown : null, - session_count: rows.length, + ...numeric, + model_breakdown: modelBreakdown, + }]; + const trustedCodexCorrection = legacyEntryIsCodexOnly(entry) + && typeof collector?.codex === "string" + && TRUSTED_CORRECTION_COLLECTORS.has(collector.codex); + const v2Entry: UsageEntryV2 = { + date: outerDate, + content_hash: "0".repeat(64), + agents, + ...(trustedCodexCorrection + ? { + authoritative_correction: true, + migration_id: "legacy-codex-correction-v1", + } + : {}), }; + return { ...v2Entry, content_hash: hashCanonicalEntry(v2Entry) }; } -function resolveClaudeTitleLabel(models: string[] | null | undefined): string | null { - if (!models || models.length === 0) return null; - const slugs = models.map((model) => model.trim().toLowerCase()); - return slugs.some((slug) => slug.includes("fable")) ? "Claude Fable" - : slugs.some((slug) => slug.includes("opus")) ? "Claude Opus" - : slugs.some((slug) => slug.includes("sonnet")) ? "Claude Sonnet" - : slugs.some((slug) => slug.includes("haiku")) ? "Claude Haiku" - : null; +function jsonMetadata(value: unknown): { [key: string]: JsonValue } { + if (!isRecord(value)) return {}; + return JSON.parse(JSON.stringify(value)); } -export async function POST(request: Request) { - const parsed = await readJsonBodyWithLimit(request, MAX_USAGE_BODY_BYTES); - if (!parsed.ok) return parsed.response; - const body = parsed.body; - - if (!body.entries || !Array.isArray(body.entries) || body.entries.length === 0) { - return NextResponse.json({ error: "No entries provided" }, { status: 400 }); - } - if (body.entries.length > MAX_USAGE_ENTRIES) { - return NextResponse.json( - { error: `Too many entries provided. Maximum is ${MAX_USAGE_ENTRIES}.` }, - { status: 400 }, - ); +function adaptLegacyRequest(value: unknown): LegacyAdaptResult | { ok: false; error: string } { + if (!isRecord(value)) return legacyError("Invalid request body"); + if (!Array.isArray(value.entries) || value.entries.length === 0) { + return legacyError("No entries provided"); } - - if (!body.source || !["cli", "web"].includes(body.source)) { - return NextResponse.json({ error: "Invalid source" }, { status: 400 }); + if (value.entries.length > MAX_USAGE_ENTRIES) { + return legacyError(`Too many entries provided. Maximum is ${MAX_USAGE_ENTRIES}.`); } - - const collectorValidationError = validateCollectorMeta(body.collector); - if (collectorValidationError) { - return NextResponse.json({ error: collectorValidationError }, { status: 400 }); + if (value.source !== "cli" && value.source !== "web") return legacyError("Invalid source"); + if (typeof value.device_id !== "string") { + return legacyError("device_id is required. Please update your CLI: npx straude@latest"); } - - const auth = await resolveAuthContext(request); - if (!auth) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const collector = isRecord(value.collector) + ? jsonMetadata(value.collector) + : {}; + const rawCollector = value.collector as UsageCollectorMeta | undefined; + const entries: UsageEntryV2[] = []; + const seenDates = new Set(); + for (const raw of value.entries) { + if (!isRecord(raw) || typeof raw.date !== "string" || !isRecord(raw.data)) { + return legacyError("Invalid usage entry"); + } + if (seenDates.has(raw.date)) return legacyError(`Duplicate date: ${raw.date}`); + seenDates.add(raw.date); + const data = raw.data as unknown as CcusageDailyEntry; + const converted = legacyEntryToV2(raw.date, data, rawCollector); + if (isLegacyError(converted)) return converted; + entries.push(converted); } + const pricingMode = collector.pricing_mode === "offline" ? "offline" : "online"; + const requestId = createHash("sha256") + .update([ + "straude-legacy-v1", + value.source, + value.device_id, + ...entries + .map((entry) => `${entry.date}:${entry.content_hash}`) + .sort(), + ].join("\0")) + .digest("hex"); + return { + request: { + protocol_version: 2, + request_id: typeof value.hash === "string" && value.hash.length > 0 + ? value.hash + : requestId, + source: value.source, + timezone: "UTC", + installation: { + id: value.device_id, + ...(typeof value.device_name === "string" ? { name: value.device_name } : {}), + }, + collector: { + name: "legacy-ccusage", + version: typeof collector.ccusage_version === "string" + ? collector.ccusage_version + : "legacy", + pricing_mode: pricingMode, + metadata: collector, + }, + entries, + }, + }; +} - const userId = auth.userId; +function schedulePostCommitWork( + userId: string, + request: UsageSubmitRequestV2, + outcomes: UsageOutcomeV2[], +): void { + const successful = outcomes.filter( + (outcome) => outcome.status === "committed" || outcome.status === "unchanged", + ); + if (successful.length === 0) return; + const totals = request.entries.reduce((sum, entry) => { + const entryTotal = sumAgentUsage(entry.agents); + return { + cost: sum.cost + entryTotal.cost_usd, + tokens: sum.tokens + entryTotal.total_tokens, + }; + }, { cost: 0, tokens: 0 }); + + after(async () => { + await Promise.allSettled([ + checkAndAwardAchievements(userId, "usage"), + Promise.resolve( + getServiceClient().rpc("recalculate_user_level", { p_user_id: userId }), + ), + captureServerActivationEvent({ + event: "usage_submit_succeeded", + distinctId: userId, + properties: { + surface: "usage_submit", + activation_state: "first_usage_submitted", + is_authenticated: true, + protocol_version: 2, + days_pushed: successful.length, + result_count: successful.length, + total_cost_usd: Math.round(totals.cost * 100) / 100, + total_tokens: totals.tokens, + has_errors: successful.length !== outcomes.length, + "$insert_id": `usage_submit_succeeded:${userId}:${request.request_id}`, + }, + }), + ]); + }); +} - const limited = await rateLimit("usage-submit", userId, { limit: 20 }); - if (limited) return limited; +export async function POST(request: Request): Promise { + const requestStartedAt = performance.now(); + const rawCliVersion = request.headers.get("x-straude-cli-version"); + const cliVersion = rawCliVersion && /^[0-9A-Za-z.+_-]{1,64}$/.test(rawCliVersion) + ? rawCliVersion + : null; + const rawRetryAttempt = request.headers.get("x-straude-retry-attempt"); + const retryAttempt = rawRetryAttempt && /^\d{1,2}$/.test(rawRetryAttempt) + ? Math.min(Number(rawRetryAttempt), 99) + : 0; + const parsedBody = await readJsonBodyWithLimit(request, MAX_USAGE_BODY_BYTES); + if (!parsedBody.ok) return parsedBody.response; - const seenDates = new Set(); - for (const entry of body.entries) { - if (!isValidDate(entry.date)) { - return NextResponse.json({ error: `Invalid date: ${entry.date}` }, { status: 400 }); - } - if (seenDates.has(entry.date)) { - return NextResponse.json({ error: `Duplicate date: ${entry.date}` }, { status: 400 }); - } - seenDates.add(entry.date); - if (!isWithinBackfillWindow(entry.date, MAX_BACKFILL_DAYS)) { - return NextResponse.json( - { error: `Date ${entry.date} is outside the ${MAX_BACKFILL_DAYS}-day backfill window` }, - { status: 400 }, - ); + const auth = await resolveAuthContext(request); + if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const v2 = isV2Request(parsedBody.body); + if (!v2 && auth.source === "cli" && isLegacyProtocolSunset()) { + return NextResponse.json({ + error: "This Straude CLI version is no longer supported.", + code: "usage_protocol_upgrade_required", + update_command: "npx straude@latest", + }, { status: 426 }); + } + let usageRequest: UsageSubmitRequestV2; + if (v2) { + const parsed = parseUsageSubmitV2(parsedBody.body); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); } - const validationError = validateEntry(entry.data); - if (validationError) { - return NextResponse.json({ error: validationError }, { status: 400 }); + usageRequest = parsed.value; + } else { + const adapted = adaptLegacyRequest(parsedBody.body); + if (isLegacyError(adapted)) { + return NextResponse.json({ error: adapted.error }, { status: 400 }); } + usageRequest = adapted.request; + } + const outOfWindow = usageRequest.entries.find( + (entry) => !isWithinBackfillWindow(entry.date, usageRequest.timezone), + ); + if (outOfWindow) { + const message = `Date ${outOfWindow.date} is outside the ${MAX_BACKFILL_DAYS}-day backfill window`; + return v2 + ? NextResponse.json({ + request_id: usageRequest.request_id, + outcomes: [{ + date: outOfWindow.date, + status: "permanent_error", + error: { code: "date_out_of_range", message }, + }], + }, { status: 400 }) + : NextResponse.json({ error: message }, { status: 400 }); } - const db = getServiceClient(); - const isVerified = auth.source === "cli"; - const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "https://straude.com").replace(/\/+$/, ""); - - const deviceId = body.device_id; - const deviceName = body.device_name; - if (!deviceId) { - return NextResponse.json( - { error: "device_id is required. Please update your CLI: npx straude@latest" }, - { status: 400 }, - ); + if (usageRequest.source !== auth.source) { + const message = `Authenticated ${auth.source} requests cannot submit source ${usageRequest.source}`; + return v2 + ? NextResponse.json({ + request_id: usageRequest.request_id, + outcomes: usageRequest.entries.map((entry) => ({ + date: entry.date, + status: "permanent_error", + error: { code: "source_mismatch", message }, + })), + }, { status: 403, headers: responseHeaders(auth) }) + : NextResponse.json({ error: message }, { status: 403, headers: responseHeaders(auth) }); } + const limited = await rateLimit("usage-submit", auth.userId, { limit: 20 }); + if (limited) return limited; - const settled = await mapSettledWithConcurrency( - body.entries, + const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "https://straude.com").replace(/\/+$/, ""); + const db = getServiceClient(); + const outcomes = await mapWithConcurrency( + usageRequest.entries, USAGE_PROCESS_CONCURRENCY, - async (entry) => { - // Check if a record already exists to determine create vs update - const { data: existing } = await db - .from("daily_usage") - .select("id, cost_usd, models, model_breakdown, collector_meta") - .eq("user_id", userId) - .eq("date", entry.date) - .maybeSingle(); - - const action: "created" | "updated" = existing ? "updated" : "created"; - const previousCost = existing ? Number(existing.cost_usd) : undefined; - - let usage: { id: string } | null = null; - let usageErrorMessage: string | null = null; - const entryCollector = collectorForEntry(body.collector, entry.data); - const entryIsTrustedCodexCorrection = typeof entryCollector?.codex === "string" - && TRUSTED_CODEX_COLLECTORS.has(entryCollector.codex) - && entryContainsCodexUsage(entry.data); - - // Guard against decreasing values (e.g., ccusage log rotation). Trusted - // Codex collectors may lower totals because they repair inflated rows - // produced by older Codex aggregation behavior. - const { data: existingDevice } = await db - .from("device_usage") - .select("cost_usd,models,model_breakdown,collector_meta") - .eq("user_id", userId) - .eq("date", entry.date) - .eq("device_id", deviceId) - .maybeSingle(); - const existingDeviceMeta = (existingDevice as { collector_meta?: UsageCollectorMeta | null } | null | undefined)?.collector_meta; - const existingDeviceWasRepaired = rowWasRepaired(existingDeviceMeta); - - let preexistingDeviceCount = 0; - if (existing) { - const { count } = await db - .from("device_usage") - .select("id", { count: "exact", head: true }) - .eq("user_id", userId) - .eq("date", entry.date); - preexistingDeviceCount = count ?? 0; - } - - const trustedEntryCanOverwriteDevice = entryIsTrustedCodexCorrection - && (!existingDevice || trustedCodexEntryPreservesNonCodex( - (existingDevice as { models?: unknown }).models, - (existingDevice as { model_breakdown?: unknown }).model_breakdown, - entry.data, - )); - - // Protect rows that the codex-only repair migration corrected from - // being re-inflated by an older untrusted collector. Without this guard, a - // user still on the older collector auto-pushes their next daily payload, the - // payload's cost is higher than the repaired row, and the existing - // "raise allowed" path overwrites the repair. Trusted uploads bypass the - // guard and heal the row to ground truth. - const mayOverwriteDevice = ( - !existingDevice - || (entry.data.costUSD >= Number(existingDevice.cost_usd) && !existingDeviceWasRepaired) - || trustedEntryCanOverwriteDevice - ); - - // Only upsert if new data is >= existing, unless this is a trusted repair. - if (mayOverwriteDevice) { - const { error: deviceError } = await db - .from("device_usage") - .upsert( - { - user_id: userId, - device_id: deviceId, - device_name: deviceName ?? null, - date: entry.date, - cost_usd: entry.data.costUSD, - input_tokens: entry.data.inputTokens, - output_tokens: entry.data.outputTokens, - reasoning_output_tokens: entry.data.reasoningOutputTokens ?? 0, - cache_creation_tokens: entry.data.cacheCreationTokens, - cache_read_tokens: entry.data.cacheReadTokens, - total_tokens: entry.data.totalTokens, - models: entry.data.models, - model_breakdown: entry.data.modelBreakdown ?? null, - session_count: 1, - raw_hash: body.hash ?? null, - collector_meta: mergeCollectorWithRepairMeta(entryCollector, existingDeviceMeta), - updated_at: new Date().toISOString(), - }, - { onConflict: "user_id,date,device_id" }, - ) - .select("id") - .single(); - - if (deviceError) { - throw new Error(`Failed to upsert device_usage for ${entry.date}: ${deviceError.message}`); - } - } - - const existingHasNonCodexUsage = existing - ? rowContainsNonCodexUsage( - (existing as { models?: unknown }).models, - (existing as { model_breakdown?: unknown }).model_breakdown, - ) - : false; - const canDropLegacyDevice = entryIsTrustedCodexCorrection - && (!existingHasNonCodexUsage || nonCodexCostIsPreserved( - (existing as { model_breakdown?: unknown } | null)?.model_breakdown, - entry.data.modelBreakdown, - )); - - if (canDropLegacyDevice) { - await db - .from("device_usage") - .delete() - .eq("user_id", userId) - .eq("date", entry.date) - .eq("device_id", LEGACY_DEVICE_ID); - } - - // Backfill legacy data: if daily_usage exists but has no device_usage rows, - // the data was written before device tracking. Insert it as a "legacy" device - // so the aggregation doesn't discard it. - if (existing && !canDropLegacyDevice) { - if (preexistingDeviceCount === 0) { - const { data: legacyRow } = await db - .from("daily_usage") - .select("cost_usd,input_tokens,output_tokens,reasoning_output_tokens,cache_creation_tokens,cache_read_tokens,total_tokens,models,model_breakdown,raw_hash") - .eq("id", existing.id) - .single(); - - if (legacyRow) { - await db.from("device_usage").insert({ - user_id: userId, - device_id: LEGACY_DEVICE_ID, - device_name: "legacy", - date: entry.date, - cost_usd: legacyRow.cost_usd, - input_tokens: legacyRow.input_tokens, - output_tokens: legacyRow.output_tokens, - reasoning_output_tokens: legacyRow.reasoning_output_tokens ?? 0, - cache_creation_tokens: legacyRow.cache_creation_tokens ?? 0, - cache_read_tokens: legacyRow.cache_read_tokens ?? 0, - total_tokens: legacyRow.total_tokens, - models: legacyRow.models ?? [], - model_breakdown: legacyRow.model_breakdown ?? null, - session_count: 1, - raw_hash: legacyRow.raw_hash ?? null, - collector_meta: null, - updated_at: new Date().toISOString(), - }); - } - } - } - - // Fetch all device rows for this (user_id, date) and aggregate - const { data: deviceRows, error: fetchError } = await db - .from("device_usage") - .select("cost_usd,input_tokens,output_tokens,reasoning_output_tokens,cache_creation_tokens,cache_read_tokens,total_tokens,models,model_breakdown,collector_meta") - .eq("user_id", userId) - .eq("date", entry.date); - - if (fetchError || !deviceRows) { - throw new Error(`Failed to fetch device_usage for ${entry.date}: ${fetchError?.message}`); - } - - const agg = aggregateDeviceRows(deviceRows as DeviceUsageRow[]); - const dailyCollectorMeta = mergeDailyCollectorMeta( - mayOverwriteDevice ? entryCollector : undefined, - (existing as { collector_meta?: unknown } | null)?.collector_meta, - deviceRows as DeviceUsageRow[], - ); - - const { data, error } = await db - .from("daily_usage") - .upsert( - { - user_id: userId, - date: entry.date, - cost_usd: agg.cost_usd, - input_tokens: agg.input_tokens, - output_tokens: agg.output_tokens, - reasoning_output_tokens: agg.reasoning_output_tokens, - cache_creation_tokens: agg.cache_creation_tokens, - cache_read_tokens: agg.cache_read_tokens, - total_tokens: agg.total_tokens, - models: agg.models, - model_breakdown: agg.model_breakdown, - session_count: agg.session_count, - is_verified: isVerified, - raw_hash: body.hash ?? null, - collector_meta: dailyCollectorMeta, - updated_at: new Date().toISOString(), - }, - { onConflict: "user_id,date" }, - ) - .select("id") - .single(); - - usage = data; - usageErrorMessage = error?.message ?? null; - - if (usageErrorMessage || !usage) { - throw new Error(`Failed to upsert usage for ${entry.date}: ${usageErrorMessage ?? "Unknown error"}`); - } - - // Build auto-title from aggregated usage data - const models = agg.models; - const claudeLabel = resolveClaudeTitleLabel(models); - const hasClaude = Boolean(claudeLabel || models?.some((m) => m.toLowerCase().includes("claude"))); - const codexModel = models?.find((m) => /^gpt-/i.test(m) || /^o3/i.test(m) || /^o4/i.test(m)); - const codexLabel = codexModel - ? /^gpt-/i.test(codexModel) - ? codexModel.replace(/^gpt/i, "GPT").replace(/-codex$/i, "-Codex") - : /^o3/i.test(codexModel) ? "o3" - : /^o4/i.test(codexModel) ? "o4" - : codexModel - : null; - const toolLabels = [claudeLabel, codexLabel].filter(Boolean); - const modelLabel = toolLabels.length > 0 ? toolLabels.join(" + ") : (hasClaude ? "Claude" : null); - const dateLabel = new Date(entry.date).toLocaleDateString("en-US", { month: "short", day: "numeric" }); - const costLabel = agg.cost_usd > 0 ? `, $${formatCurrency(agg.cost_usd)}` : ""; - const autoTitle = modelLabel ? `${dateLabel} — ${modelLabel}${costLabel}` : `${dateLabel}${costLabel}`; - - // Create or update post linked to the daily_usage record - // Only overwrite the title on re-sync if it's still auto-generated - const { data: existingPost } = await db - .from("posts") - .select("id, title") - .eq("daily_usage_id", usage.id) - .maybeSingle(); - - let post: { id: string } | null = null; - let postErrorMessage: string | null = null; - - if (existingPost) { - // Auto-generated titles match "Mon DD" or "Mon DD — Models, $X.XX" - const isAutoTitle = !existingPost.title || /^[A-Z][a-z]{2} \d{1,2}( — .+)?$/.test(existingPost.title); - const updateFields: Record = { updated_at: new Date().toISOString() }; - if (isAutoTitle) updateFields.title = autoTitle; - - const { data, error } = await db - .from("posts") - .update(updateFields) - .eq("id", existingPost.id) - .select("id") - .single(); - post = data; - postErrorMessage = error?.message ?? null; - } else { - const { data, error } = await db - .from("posts") - .insert({ - user_id: userId, - daily_usage_id: usage.id, - title: autoTitle, - updated_at: new Date().toISOString(), - }) - .select("id") - .single(); - post = data; - postErrorMessage = error?.message ?? null; - } - - if (postErrorMessage || !post) { - throw new Error(`Failed to create post for ${entry.date}: ${postErrorMessage ?? "Unknown error"}`); - } - - return { - date: entry.date, - usage_id: usage.id, - post_id: post.id, - post_url: `${appUrl}/post/${post.id}`, - action, - previous_cost: previousCost, - daily_total: agg.cost_usd, - device_count: deviceRows.length, - }; - }, + (entry) => submitEntry( + db, + auth, + usageRequest, + entry, + appUrl, + cliVersion, + retryAttempt, + ), ); + const status = statusForOutcomes(outcomes, v2); + const headers = responseHeaders(auth); + const outcomeCounts = outcomes.reduce>((counts, outcome) => { + counts[outcome.status] = (counts[outcome.status] ?? 0) + 1; + return counts; + }, {}); + console.info(JSON.stringify({ + event: "usage_submit_request", + protocol_version: v2 ? 2 : 1, + request_id: usageRequest.request_id, + cli_version: cliVersion, + collector_name: usageRequest.collector.name, + collector_version: usageRequest.collector.version, + pricing_mode: usageRequest.collector.pricing_mode, + date_count: usageRequest.entries.length, + outcome_counts: outcomeCounts, + retry_count: retryAttempt, + unresolved_partial: status === 207, + http_status: status, + submit_duration_ms: Math.round(performance.now() - requestStartedAt), + })); + schedulePostCommitWork(auth.userId, usageRequest, outcomes); - // Collect results and errors - const results: UsageSubmitResponse["results"] = []; - const errors: string[] = []; - - for (const result of settled) { - if (result.status === "fulfilled") { - results.push(result.value); - } else { - errors.push(result.reason?.message ?? "Unknown error"); - } - } - - if (errors.length > 0 && results.length === 0) { - return NextResponse.json({ error: errors.join("; ") }, { status: 500 }); + if (v2) { + const response: UsageSubmitResponseV2 = { + request_id: usageRequest.request_id, + outcomes, + }; + return NextResponse.json(response, { status, headers }); } - checkAndAwardAchievements(userId, "usage").catch(() => {}); - Promise.resolve( - db.rpc("recalculate_user_level", { p_user_id: userId }), - ).catch(() => {}); - - // Recheck referrer's crew-spend achievements when a referred user logs usage - Promise.resolve( - getServiceClient() - .from("users") - .select("referred_by") - .eq("id", userId) - .single(), - ) - .then(({ data }) => { - if (data?.referred_by) { - checkAndAwardAchievements(data.referred_by, "referral").catch(() => {}); - } - }) - .catch(() => {}); - - const responseHeaders: Record = {}; - if (auth.source === "cli" && auth.refreshedToken) { - responseHeaders["X-Straude-Refreshed-Token"] = auth.refreshedToken; + const results: UsageSubmitResponse["results"] = outcomes.flatMap((outcome) => { + if ( + (outcome.status !== "committed" && outcome.status !== "unchanged") + || !outcome.result + ) { + return []; + } + return [{ + date: outcome.date, + usage_id: outcome.result.usage_id, + post_id: outcome.result.post_id, + post_url: outcome.result.post_url, + action: outcome.result.action, + previous_cost: outcome.result.previous_cost, + daily_total: outcome.result.daily_total, + device_count: outcome.result.device_count, + }]; + }); + const errors = outcomes.flatMap((outcome) => outcome.error ? [outcome.error.message] : []); + if (status !== 200) { + return NextResponse.json({ + error: errors.join("; ") || "Usage submission failed", + results, + errors, + }, { status, headers }); } - const datesCreated = results.filter((r) => r.action === "created").length; - const datesUpdated = results.filter((r) => r.action === "updated").length; - const totalCost = body.entries.reduce((sum, entry) => sum + entry.data.costUSD, 0); - const totalTokens = body.entries.reduce((sum, entry) => sum + entry.data.totalTokens, 0); - - after(() => captureServerActivationEvent({ - event: "usage_submit_succeeded", - distinctId: userId, - properties: { - surface: "usage_submit", - activation_state: "first_usage_submitted", - is_authenticated: true, - days_pushed: results.length, - dates_created: datesCreated, - dates_updated: datesUpdated, - result_count: results.length, - total_cost_usd: Math.round(totalCost * 100) / 100, - total_tokens: totalTokens, - pricing_mode: body.collector?.pricing_mode, - ccusage_version: body.collector?.ccusage_version, - ccusage_agents: body.collector?.ccusage_agents, - has_errors: errors.length > 0, - "$insert_id": `usage_submit_succeeded:${userId}:${body.hash ?? body.device_id ?? "unknown"}:${results.map((r) => r.date).join(",")}`, - }, - })); + // Keep the legacy response contract until current clients adopt protocol v2. const response: UsageSubmitResponse = { results }; - if (errors.length > 0) { - return NextResponse.json({ ...response, errors }, { status: 207, headers: responseHeaders }); - } - return NextResponse.json(response, { headers: responseHeaders }); + return NextResponse.json(response, { headers }); } diff --git a/apps/web/lib/usage-import.ts b/apps/web/lib/usage-import.ts new file mode 100644 index 00000000..78b2420a --- /dev/null +++ b/apps/web/lib/usage-import.ts @@ -0,0 +1,18 @@ +export function toLegacyUsageImportEntries(data: Record[]) { + return data.map((day) => ({ + date: day.date as string, + data: { + date: day.date as string, + models: (day.models as string[]) ?? [], + inputTokens: (day.inputTokens as number) ?? 0, + outputTokens: (day.outputTokens as number) ?? 0, + ...(day.reasoningOutputTokens === undefined + ? {} + : { reasoningOutputTokens: day.reasoningOutputTokens as number }), + cacheCreationTokens: (day.cacheCreationTokens as number) ?? 0, + cacheReadTokens: (day.cacheReadTokens as number) ?? 0, + totalTokens: (day.totalTokens as number) ?? 0, + costUSD: (day.costUSD as number) ?? 0, + }, + })); +} diff --git a/bun.lock b/bun.lock index 90754e9e..627f621a 100644 --- a/bun.lock +++ b/bun.lock @@ -67,13 +67,13 @@ }, "packages/cli": { "name": "straude", - "version": "0.1.30", + "version": "0.2.0", "bin": { "straude": "dist/index.js", }, "dependencies": { "@pppp606/ink-chart": "^0.2.4", - "ccusage": "^20.0.16", + "ccusage": "20.0.16", "chalk": "^5.6.2", "ink": "^6.8.0", "posthog-node": "^5.29.1", diff --git a/docs/API.md b/docs/API.md index 6ffae5bf..4b820870 100644 --- a/docs/API.md +++ b/docs/API.md @@ -306,38 +306,98 @@ Check if a username is available. ### `POST /api/usage/submit` -Submit daily usage data. Primary endpoint for CLI syncs and web imports. +Submit daily usage data. Protocol v2 is the primary CLI contract; legacy web +imports are adapted server-side during the migration. - **Auth**: CLI JWT or Session - **Rate limit**: `usage-submit` (20/min) -- **Request body**: +- **Protocol v2 request body**: ```json { - "source": "cli" | "web", - "device_id": "uuid (optional, enables multi-device aggregation)", - "device_name": "string (optional)", - "hash": "sha256 hex string (optional)", + "protocol_version": 2, + "request_id": "019f8f0b-08ee-78d3-8063-19a0485cc61f", + "source": "cli", + "timezone": "America/Vancouver", + "installation": { + "id": "6e3d74e2-c82b-4ed4-81e3-96a76ce39d11", + "name": "work-laptop" + }, + "collector": { + "name": "ccusage", + "version": "20.0.16", + "pricing_mode": "online" + }, "entries": [ { "date": "YYYY-MM-DD", - "data": { - "costUSD": 4.82, - "inputTokens": 150000, - "outputTokens": 50000, - "cacheCreationTokens": 0, - "cacheReadTokens": 10000, - "totalTokens": 210000, - "models": ["claude-sonnet-4-6"], - "modelBreakdown": [{ "model": "claude-sonnet-4-6", "cost_usd": 4.82 }] - } + "content_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "agents": [{ + "agent": "codex", + "models": ["gpt-5.6"], + "input_tokens": 150000, + "output_tokens": 50000, + "reasoning_output_tokens": 0, + "cache_creation_tokens": 0, + "cache_read_tokens": 10000, + "total_tokens": 210000, + "cost_usd": 4.82, + "model_breakdown": [{ + "model": "gpt-5.6", + "input_tokens": 150000, + "output_tokens": 50000, + "reasoning_output_tokens": 0, + "cache_creation_tokens": 0, + "cache_read_tokens": 10000, + "total_tokens": 210000, + "cost_usd": 4.82 + }] + }] } ] } ``` -- **Validation**: Dates must be valid ISO format within a 7-day backfill window. No negative values. -- **Response**: `{ results: [{ date, usage_id, post_id, post_url, action }] }` -- **Status codes**: `200` success, `207` partial failure (some entries failed), `400` validation error, `401` unauthorized. -- **Side effects**: Creates/updates `daily_usage` rows, creates posts with auto-generated titles, triggers achievement checks, multi-device aggregation when `device_id` provided. +- **Validation**: Body size is capped at 256 KiB. Requests contain 1–32 unique + dates inside the 30-day backfill window; token totals must equal their + components; costs must be finite and non-negative; installation IDs are + UUIDs; and every entry has a content hash. +- **Protocol v2 response**: + `{ request_id, outcomes: [{ date, status, result?, error? }] }`. Status is one + of `committed`, `unchanged`, `retryable_error`, `permanent_error`, or + `identity_conflict`. Successful results include `usage_id`, `post_id`, + `post_url`, and `action`. +- **Status codes**: `200` success, `207` mixed outcomes, `400` invalid request + or permanent failure, `401` unauthorized, `403` authenticated-source + mismatch, `409` installation identity conflict, `413` oversized body, `429` + rate limited, `426` legacy protocol expired, and `503` retryable failure. +- **Side effects**: Reconciles installation-scoped daily usage transactionally, + creates or updates posts, and schedules achievements and analytics after + committed outcomes. Stable request IDs and entry hashes make retries + idempotent. +- **Legacy cutoff**: Protocol v1 is routed through the same transactional + function until `2026-08-06` by default (override with + `STRAUDE_USAGE_V1_CUTOFF`). After the cutoff it receives `426` with the exact + update command `npx straude@latest`. + +### `GET /api/usage/devices` + +List unresolved installation identity candidates for the authenticated user. + +- **Auth**: CLI JWT or Session +- **Response**: + `{ candidates: [{ id, device_id_a, device_id_b, normalized_hostname, overlap_dates, status, created_at }] }` +- **Notes**: Candidates remain quarantined from new aggregation until resolved. + +### `POST /api/usage/devices/resolve` + +Resolve an installation identity candidate. + +- **Auth**: CLI JWT or Session +- **Request body**: + `{ candidate_id: string, decision: "merge" | "keep_separate" }` +- **Response**: + `{ candidate: { id, status, decision, canonical_device_id? } }` +- **Errors**: `400` invalid decision/UUID, `401` unauthorized, `404` candidate + not found, `500` resolution failed. ### `GET /api/usage/status` @@ -346,6 +406,13 @@ Aggregated usage stats for the authenticated user. - **Auth**: Session - **Response**: `{ has_data: boolean, cost_usd?: number, total_tokens?: number, session_count?: number, top_model?: string }` +### `GET /api/cli/dashboard` + +Return the scorecard rendered by `straude status` and after a successful push. + +- **Auth**: CLI JWT +- **Response**: Username, level, streak, 28 days of daily cost, current and previous week cost, leaderboard neighbors, model breakdown, and total output tokens. + --- ## Notifications diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 641b6eaa..0a1fbd66 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### CLI 0.2.0 hardening + +- **Versioned, retry-safe usage sync.** CLI and server now share a validated protocol-v2 envelope with stable request IDs, per-date content hashes, installation identity, collector provenance, and explicit per-date outcomes. The CLI writes batches to a durable outbox before submission, advances only contiguous committed dates, preserves unresolved work across crashes, and serializes overlapping scheduler or hook runs with a lock and date queue. +- **Transactional, source-aware ingestion.** The service-role-only `submit_usage_day_v2` function locks each user/date, applies installation and per-agent updates, recomputes the daily aggregate, and creates or updates the post in one transaction. Replays return the recorded outcome, payload-hash conflicts return `409`, mixed batches return `207`, missing agents do not erase prior sources, and trusted collector migrations are the only path allowed to lower usage. +- **Durable installation reconciliation.** The CLI keeps its installation UUID outside the login config and submits the prior device ID once for alias migration. The server quarantines ambiguous identities, proves automatic merges from hostname plus identical overlapping accounting fingerprints, exposes explicit `straude devices` resolution commands, and records historical repair changes in an append-only ledger with an admin-only rollback function. +- **Bounded failure handling.** API calls retry network failures, 408, 425, 429, and 5xx responses within an absolute deadline, using jitter and `Retry-After` when supplied. Login initialization has a 10-second deadline, noninteractive runs fail with actionable exit codes instead of opening a browser, telemetry shutdown is bounded, and automatic-run logs rotate. +- **Live pricing is a commit gate.** Collection keeps ccusage diagnostic output enabled, rejects embedded or incomplete pricing, and makes at most three attempts inside a shared 60-second recovery budget. Pricing failure leaves both the outbox and contiguous watermark unchanged, so stale estimates can never become committed usage. +- **Reproducible package and release pipeline.** The CLI now requires Node 20+, pins `ccusage` to the fixture-tested `20.0.16`, targets Node 20, and emits a source map that is excluded from npm and retained as a CI artifact. Bun is fixed at 1.3.3 and CI uses the frozen lockfile. `npm pack` builds from a clean `tsup` output, while the package allowlist ships only `dist/index.js`. +- **Cross-platform packaged verification.** CI and tag releases build one tarball, install that exact artifact on Linux, macOS, and Windows under Node 20 and 22, then run the real bundled collector against the GPT-5.6 fixture and a delayed scorecard server. The check also verifies the CLI version, Node engine, exact ccusage dependency, bin entry, and absence of source maps or TypeScript build metadata. +- **Tag-driven publishing.** A `straude@` tag runs CLI typecheck/tests, packages once, waits for the full OS/Node matrix, publishes the tested tarball to npm with provenance, and creates the matching GitHub release with the tarball and its `SHA256SUMS` digest. Publishing remains manual until a matching tag is pushed; this workflow does not create tags. +- **Repeatable performance benchmarks.** `bun run --cwd packages/cli benchmark` measures packed CLI startup, while `benchmark:collector` runs the pinned collector over deterministic 1, 3, 7, and 30-day fixtures and reports first-run and warm median/p95 latency. CI archives collector results without imposing a machine-dependent threshold; accuracy fixtures remain a mandatory gate. +- **CLI documentation corrected.** The documented windows now match behavior: 3 days on a fresh first sync, up to 7 contiguous uncommitted days per normal run, and up to 30 days only when explicitly requested. The reference also documents exit codes, platform support, automatic sync, telemetry, packaged testing, and the real `/api/cli/dashboard` endpoint. + ### Fixed - **The CLI now waits for and renders the scorecard after a successful sync.** A healthy dashboard response taking longer than 1.5 seconds is no longer discarded with a suggestion to run `straude status` separately. diff --git a/docs/CLI.md b/docs/CLI.md index 0aaf523b..dc1e6eae 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -15,12 +15,12 @@ bunx straude npm install -g straude ``` -**Requirements**: Node.js >= 18 +**Requirements**: Node.js >= 20 ## Quick Start ```bash -# First run: authenticates via browser, then runs the one-time 30-day backfill +# First run: authenticates via browser, then syncs the last 3 days npx straude@latest # Subsequent runs: pushes only new data since last sync @@ -64,6 +64,7 @@ straude push --dry-run # Preview without posting | `--date YYYY-MM-DD` | Push a specific date (must be within last 30 days) | | `--days N` | Push last N days (max 30) | | `--dry-run` | Preview what would be submitted without actually posting | +| `--non-interactive` | Never open a browser or wait for login | **Date range logic:** @@ -72,8 +73,13 @@ straude push --dry-run # Preview without posting | `--date` specified | That single date | | `--days N` specified | Last N days (capped at 30) | | Previously pushed today | Today only (re-sync) | -| Previously pushed before today | Days since last push (capped at 7) | -| First run after the ccusage v20 migration | Last 30 days | +| Previously pushed before today | Next uncommitted date through at most 7 contiguous days | +| No previous push | Last 3 days | + +The CLI does not automatically run a 30-day migration backfill. Use +`straude push --days 30` when you deliberately want the full server window. +When more than seven dates are pending, repeated normal runs advance the +committed watermark in contiguous chunks instead of skipping ahead. ### `straude status` @@ -94,29 +100,63 @@ Output: Last push: 2026-03-11 (today) ``` -**Note:** This command calls `GET /api/users/me/status` which is a CLI-specific endpoint. +**Note:** This command calls the CLI-authenticated `GET /api/cli/dashboard` endpoint. + +### `straude devices` + +List unresolved installation-identity candidates, or resolve one explicitly: + +```bash +straude devices +straude devices merge +straude devices keep-separate +``` + +Straude only auto-merges installations when their normalized hostnames match, +at least two overlapping dates have identical source-level accounting, and no +overlap diverges. Ambiguous candidates are quarantined from new accounting until +you choose whether to merge them or keep them separate. + +### Automatic sync + +```bash +straude --auto # Install a daily launchd/cron job +straude --auto --time 14:30 # Choose a local run time +straude --auto hooks # Install a Claude Code SessionEnd hook +straude --no-auto # Disable the configured mechanism +straude auto # Show current configuration +straude auto logs # Print scheduler logs +``` + +The OS scheduler uses launchd on macOS and cron on Linux. It is not available +on Windows. Claude Code hooks are independent of the OS scheduler. ## Global Options | Flag | Description | |------|-------------| | `--api-url URL` | Override the API URL (useful for local development) | +| `--timeout N` | Set the ccusage subprocess timeout in seconds (default 240) | +| `--debug` | Write diagnostic detail to stderr | | `--help`, `-h` | Show help text | | `--version`, `-v` | Show CLI version | ## Data Sources -Straude invokes its bundled `ccusage >=20.0.16` native binary once per sync: +Straude invokes its installed, exact `ccusage@20.0.16` native binary once per sync: ```bash -ccusage daily --json --since YYYYMMDD --until YYYYMMDD --no-offline +ccusage daily --json --since YYYYMMDD --until YYYYMMDD --no-offline --by-agent --timezone IANA_TIMEZONE ``` The unified report automatically detects and combines every source ccusage supports. As of ccusage 20.0.16, those built-in sources are Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, and Gemini CLI. Configured custom pi-format stores are accepted too. ccusage owns local path discovery, source-format parsing, deduplication, token accounting, model aliases, and per-model cost calculation. Straude validates the unified daily JSON, preserves each row's `metadata.agents`, and submits the aggregate token buckets, models, and model cost breakdown. The raw local logs and paths are never uploaded. -Online LiteLLM pricing is the default so new models and price corrections do not wait for Straude's lockfile or ccusage's embedded offline snapshot. ccusage retains that embedded snapshot as its fallback when a live refresh is unavailable. +Online LiteLLM pricing is required, so model prices can change independently of +the pinned collector code. If ccusage reports missing prices or falls back to +its embedded snapshot, Straude retries within a bounded 60-second recovery +budget and submits nothing unless live pricing becomes complete. A SHA-256 hash of the ccusage version, detected sources, date range, and raw unified JSON is sent for deduplication. @@ -130,8 +170,7 @@ Auth tokens and sync state are stored in `~/.straude/config.json` with `0o600` p "username": "ohong", "api_url": "https://straude.com", "last_push_date": "2026-03-11", - "device_id": "a1b2c3d4-...", - "device_name": "MacBook-Pro.local" + "usage_protocol_v2_migration_completed_at": "2026-07-23T18:00:00.000Z" } ``` @@ -140,15 +179,25 @@ Auth tokens and sync state are stored in `~/.straude/config.json` with `0o600` p | `token` | CLI JWT token from login | | `username` | Username at time of login | | `api_url` | API base URL (default: `https://straude.com`) | -| `last_push_date` | Last successfully pushed date (for smart sync) | -| `device_id` | Auto-generated UUID on first push (for multi-device support) | -| `device_name` | Machine hostname (informational) | +| `last_push_date` | Last contiguous committed date (the smart-sync watermark) | +| `usage_protocol_v2_migration_completed_at` | Marks completion of the bounded v2 migration sync | ## Multi-Device Support -When `device_id` is present, usage data is stored per-device in a `device_usage` table and aggregated into `daily_usage`. This means users who code on multiple machines see summed totals rather than one device overwriting the other. - -The `device_id` is auto-generated on first push and stored in the config file. +Each installation has a durable UUID in `~/.straude/machine_id`, created with +`0o600` permissions. Usage is reconciled per installation and aggregated into +`daily_usage`, so multiple machines add to the same day without overwriting one +another. Installation aliases are user-scoped, so switching Straude accounts on +one machine does not transfer or collide with the first account's usage. Legacy +`device_id` values in the config are sent once as +`previous_device_id` so existing rows can be reassigned safely. + +Pending v2 requests are durably stored in `~/.straude/pending-sync.json` before +submission. A sync lock prevents overlapping writers, and dates requested by a +second automatic run are queued for the lock holder. Committed outcomes advance +the watermark; retryable or unresolved dates remain in the outbox with the same +request ID and content hash. Permanently rejected dates are removed from the +retry queue, while the contiguous watermark stays behind the failed date. ## Troubleshooting @@ -172,9 +221,85 @@ npx straude@latest ccusage did not detect local activity in the selected date range. Confirm the coding agent has created local usage logs and check that source's path or environment-variable setup in the [ccusage data-source guide](https://ccusage.com/guide/). -### Windows support +## Platform support + +The packaged CLI is tested on Linux, macOS, and Windows under Node 20 and 22. +Straude resolves ccusage's platform-specific native package directly. The +Windows config path is `%USERPROFILE%\.straude\config.json`; macOS and Linux use +`~/.straude/config.json`. + +Automatic OS scheduling is limited to launchd on macOS and cron on Linux. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Command completed successfully, including `--help` and `--version` | +| `1` | Permanent input, configuration, collection, or identity error | +| `2` | A non-interactive command requires authentication | +| `75` | Retryable network, service, pricing, lock, or unresolved partial failure | + +When output is piped to a reader that closes early, an `EPIPE` exits cleanly and +preserves an error status already set by the command. + +## Telemetry + +The CLI sends operational events to Straude's PostHog project: command and CLI +version, success/failure outcome, stage timings, collector version and detected +source IDs, and aggregate counts such as days, tokens, and cost. It does not send +prompts, code, conversation content, or raw ccusage rows. Before transmission, +the configured home-directory prefix is replaced with `~` in free-form values. + +Disable telemetry with either environment variable: + +```bash +export STRAUDE_TELEMETRY_DISABLED=1 +# or +export DO_NOT_TRACK=1 +``` + +## Package and release verification + +```bash +bun install --frozen-lockfile +bun run --cwd packages/cli typecheck +bun run --cwd packages/cli test +bun run --cwd packages/cli test:packaged +``` + +`test:packaged` performs a clean build through `npm pack`, installs the tarball +in a temporary project, checks its manifest and version, runs the real pinned +ccusage binary against the GPT-5.6 fixture, submits to a local HTTP server, and +waits for the scorecard render. CI repeats the installed-tarball check on Linux, +macOS, and Windows with Node 20 and 22. + +Tags of the form `straude@` trigger the release workflow. It +publishes the exact matrix-tested tarball to npm with provenance and creates a +matching GitHub release containing the tarball and its `SHA256SUMS` digest. +Source maps are not shipped to npm or attached to the +release; they are retained as GitHub Actions artifacts. The workflow validates tags but never +creates them. Before the first release, configure `ohong/straude` and +`release-cli.yml` as the trusted publisher for the `straude` package on npm; +the workflow intentionally has no long-lived npm publish token. + +## Benchmark + +```bash +bun run --cwd packages/cli benchmark +bun run --cwd packages/cli benchmark:collector +``` -Straude resolves the platform-specific ccusage native package directly on Windows, macOS, and Linux. The Windows config path resolves to `%USERPROFILE%\.straude\config.json`. +The first harness packs and installs the CLI in isolation, warms filesystem +caches, then prints JSON with median and p95 `--version` process latency. +Override its default 15 samples with `STRAUDE_BENCH_ITERATIONS`. + +The collector harness creates deterministic 1, 3, 7, and 30-day Codex fixture +sets and records the first process plus warm median/p95 for the pinned ccusage +binary. Override its default seven warm samples with +`STRAUDE_COLLECTOR_BENCH_ITERATIONS`. It uses offline fixture pricing to isolate +local scan cost from network availability. CI archives these measurements; +accuracy tests gate the release, while benchmark thresholds are compared only +between runs on the same class of machine. ## Constants diff --git a/docs/CLI_OPERATIONS.md b/docs/CLI_OPERATIONS.md new file mode 100644 index 00000000..d52e55b2 --- /dev/null +++ b/docs/CLI_OPERATIONS.md @@ -0,0 +1,98 @@ +# Straude CLI 0.2 Operations + +This runbook covers the server rollout, alerts, historical repair, rollback, +and the evidence required to close the July 23 CLI reliability audit. It does +not authorize a production deploy by itself. + +## Release order + +1. Apply `20260723133731_usage_submission_v2.sql` and + `20260723135641_usage_reconciliation.sql` to staging. Run + `bun run --cwd apps/web test:integration` against the migrated database and + retain the test output. +2. Enable protocol-v2 routing for 5% of production users, then 25%, then 100%. + Hold each stage for at least 24 healthy hours. Rollback disables routing; do + not drop the additive tables or ledger. +3. After the server accepts v2 at 100%, create the + `straude@0.2.0` tag. The release workflow publishes only the tarball already + tested on Linux, macOS, and Windows with Node 20 and 22, then attaches that + tarball and `SHA256SUMS` to the GitHub release. +4. After 48 healthy hours, run historical repair in bounded batches. Verify a + representative rollback before completing all batches. +5. The default v1 cutoff is `2026-08-06`. Keep the compatibility path for one + further release, then remove it after observing no v1 traffic. + +## Required alerts and dashboards + +The submit route emits one redacted `usage_submit_request` event per request and +one `usage_submit_day` event per date. Logs contain request/protocol/CLI and +collector versions, pricing mode, status, retry count, and stage duration. They +exclude tokens, costs, paths, hostnames, auth data, collector stderr, and raw +usage. + +Configure these production alerts before moving beyond 5%: + +- Page on any invariant or transaction failure. Group by stable error code and + release fingerprint so one defect creates one incident. +- Page when submit `5xx` exceeds 5% with at least five requests in ten minutes. +- Warn when unresolved partial outcomes reach 0.1%, or when any external + operation exceeds its documented deadline. +- Track pricing failures, collector duration, submit duration, dashboard + degradation, identity conflicts, and authentication failures separately. + A dashboard failure does not make an already committed sync fail. + +The release gate is warm three-day sync p95 below five seconds excluding login, +submit p95 below two seconds, unresolved partials below 0.1%, zero invariant +failures, and no operation running past its deadline. Compare performance +changes on the same runner class using `benchmark` and `benchmark:collector`; +accuracy fixtures must pass regardless of latency. + +## Historical repair + +Only a service-role database session may call the repair functions. Start a +batch once, store its UUID, then call the runner repeatedly with a bounded +limit until its result reports completion: + +```sql +select public.start_usage_repair_batch('protocol v2 historical repair'); +select public.run_usage_repair_batch('', 500); +``` + +Each merge and aggregate change records full before/after rows in +`usage_corrections_ledger`. Proof-eligible identities require the same normalized +hostname, at least two identical overlapping accounting fingerprints, and no +divergent overlap. Ambiguous candidates are recorded but never changed. + +Verify after the final batch: + +```sql +select count(*) +from public.usage_device_reconciliation_candidates +where status = 'proof_merge'; + +select status, count(*) +from public.usage_device_reconciliation_candidates +group by status +order by status; + +select count(*) +from public.usage_corrections_ledger +where batch_id = ''; +``` + +If representative verification fails, restore that batch exactly: + +```sql +select public.rollback_usage_repair_batch(''); +``` + +Do not edit ledger rows or manually merge candidates. Use `straude devices +merge ` or `straude devices keep-separate ` for ambiguous +user-owned decisions. + +## Audit closure + +Keep the audit open until production has met all correctness, latency, and +error-rate targets for 30 consecutive days. Preserve the release tarball, +digest, source-map artifact, integration output, benchmark JSON, rollout +metrics, and repair/rollback evidence with the closure record. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2918b572..1a62fa70 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -47,6 +47,8 @@ Supabase can check passwords against HaveIBeenPwned to block known-compromised p ### Passed +**CLI release supply chain.** CLI runtime collection is pinned to `ccusage@20.0.16`. CI installs the Bun lockfile with Bun 1.3.3 in frozen mode, builds one npm tarball, and installs that exact artifact on Linux, macOS, and Windows under Node 20 and 22. The tag workflow requests npm trusted-publishing credentials over OIDC, carries no long-lived publish token, and publishes only after the package matrix passes; npm must be configured to trust `release-cli.yml` before the first release. Source maps are excluded from npm and GitHub releases, then retained as short-lived GitHub Actions artifacts for production diagnosis. + **RLS enabled on all 9 tables with appropriate policies.** Live database confirmed: | Table | Policies | Write guard | diff --git a/docs/audit-2026-07-23.md b/docs/audit-2026-07-23.md new file mode 100644 index 00000000..b77ee420 --- /dev/null +++ b/docs/audit-2026-07-23.md @@ -0,0 +1,433 @@ +# Straude CLI Performance and Reliability Audit + +Audit date: 2026-07-23 +Scope: `packages/cli`, the `/api/usage/submit` and CLI-auth server paths, supporting database state, CI, packaging, and release operations +Mode: read-only investigation; no production or source changes were made + +## 1. Executive Summary + +**Overall grade: C-.** The CLI has a strong validated ccusage adapter, focused tests, bounded subprocess execution, and a currently healthy 30-day data snapshot, but the surrounding state, network, ingestion, and release boundaries do not yet provide reliable end-to-end behavior. The top correctness risk is device identity regeneration: deleting or losing `~/.straude/config.json` creates a new random device ID, so the same physical machine can be summed twice; live historical data contains 59 highly suspicious duplicate user-days across four users. The second risk is non-transactional ingestion: concurrent device submissions can race, and partial HTTP 207 responses are treated as full success while the client advances its sync watermark. The third risk is unbounded external I/O: API requests have no deadline, login polling suppresses fatal errors, and every normal collection uses online pricing with a four-minute subprocess ceiling. Reliability is also weakened by direct, non-atomic config writes, overlapping hook-triggered pushes, a floating core collector dependency, a false Node 18 compatibility claim, and a manual publish path that skips the packaged-install test. Current production evidence is better than the code risk alone suggests: the last 30 days contain no daily/device reconciliation, token-invariant, breakdown-cost, or obvious duplicate-row mismatches, and current `main` CI passed 203 CLI tests. The highest-leverage remediation is to make each `(user, date)` submission transactional and idempotent, then make local state atomic and single-writer. Next, bound and classify every network operation, pin and package-test the collector users actually install, and add route-level structured timings and error codes. Replacing ccusage with another collector should wait until these system boundaries are fixed, because a faster parser would still double-count devices, lose partial failures, hang on API calls, and ship through the same release path. + +## 2. Repo Map + +### Purpose and maturity + +Straude is a production CLI and web service for collecting local coding-agent usage, converting it into daily token/cost aggregates, uploading it, and rendering social/statistical feedback. The published npm package is `straude@0.1.30`; the current repository package is also `0.1.30`. The product has hundreds of users and persistent leaderboard/accounting data, so silent inaccuracies matter more than they would in a prototype. + +### Stack and control flow + +```text +argv + -> packages/cli/src/index.ts + -> auth/config (~/.straude/config.json) + -> ccusage native binary + -> normalize + validate daily rows + -> POST /api/usage/submit + -> device_usage rows + -> aggregate daily_usage row + post + -> GET /api/cli/dashboard + -> Ink/plain-text output +``` + +- `packages/cli/src/index.ts`: argument parsing, command dispatch, telemetry lifecycle. +- `packages/cli/src/commands/push.ts`: date selection, collection, upload, sync watermark, result rendering. +- `packages/cli/src/lib/ccusage.ts`: native binary resolution, subprocess control, output validation, normalization. +- `packages/cli/src/lib/api.ts`: authenticated fetch and one-shot interactive token refresh. +- `packages/cli/src/lib/auth.ts`: token, device, repair, watermark, and auto-push state. +- `packages/cli/src/lib/scheduler.ts` and `hooks.ts`: launchd/cron/Claude SessionEnd automation. +- `apps/web/app/api/usage/submit/route.ts`: runtime validation, per-device storage, daily aggregation, post creation, partial-result response. +- `supabase/migrations/20260303075413_create_device_usage.sql`: device identity uniqueness and indexes. +- `.github/workflows/ci.yml`: build, source tests, local Supabase integration tests, and web E2E. + +### Current evidence snapshot + +- Working tree: clean `main` at `f007465`, aligned with `origin/main`. +- Remote CI: current SHA passed on 2026-07-23; the CLI job reported 19 files and 203 tests passing. +- Local execution: not run because dependencies are absent in this checkout. Installing them would have violated the read-only audit boundary. +- Registry: `straude@0.1.30` declares `ccusage: ^20.0.16`; a clean install currently resolves `20.0.18`, while `bun.lock:859` and CI use `20.0.16`. +- Production API, previous seven days: `/api/usage/submit` recorded 192 HTTP 200s, 53 HTTP 401s, two HTTP 500s, and one HTTP 400. No 207 was observed in that window. The route emits no structured start/end/error logs, so the 500s could not be attributed. +- Live database, previous 30 days: 1,368 `daily_usage` rows and 1,530 `device_usage` rows; zero daily/device aggregate mismatches, token-invariant mismatches, model-breakdown cost mismatches, duplicate raw-hash groups, or exact same-host duplicate fingerprints. +- Historical database: 233 daily/device value mismatches across 39 users, ending 2026-05-05, plus 59 same-user/same-hostname user-days with different device IDs but identical token totals and cost within half a cent. Those 59 rows span four users and imply up to $10,593.37 and 14.8B tokens of duplicated aggregation. This is a strong duplicate-device heuristic, not proof that every row in the set is invalid. + +### Notable surprise + +GitHub issue #24 was closed with a comment stating that an advisory-lock RPC made multi-device aggregation atomic, but no such function or call exists in the current tree. `apps/web/app/api/usage/submit/route.ts:528-724` still performs device upsert, re-read, aggregation, and daily upsert as separate operations. The originally reported race therefore remains possible. + +## 3. Audit Report + +Facts below are verified observations. Judgments describe severity and expected user consequence. + +### Correctness and data integrity + +#### C1. Regenerated device identity can double-count one machine + +**Severity: Critical** + +**Fact:** When config has no `device_id`, every push creates a fresh UUID and writes it into config (`packages/cli/src/commands/push.ts:320-325`). The server keys device data by `(user_id, date, device_id)` and sums all device rows (`apps/web/app/api/usage/submit/route.ts:401-447`; `supabase/migrations/20260303075413_create_device_usage.sql:19`). Losing or corrupting the config therefore turns the same physical machine into a new additive device. + +**Production evidence:** Live history contains 59 same-user, same-hostname days with distinct device IDs, identical total tokens, and cost within $0.005. The potential duplicated aggregate is $10,593.37 and 14.8B tokens. None occurred in the last 30 days. + +**Judgment:** This is the clearest explanation for some “inaccurate” reports. The data model treats an installation ID as a physical-device identity but stores it in the same replaceable file as the auth token. + +**Remediation:** Separate installation identity from auth/session state, add a server-side device registry/rebind flow, and send a canonical per-day content identity. When a new device ID submits content already attributed to the same account and hostname/install lineage, return an explicit reconciliation response instead of summing it silently. Audit and repair the historical candidate rows only after review. + +#### C2. Concurrent multi-device aggregation is not atomic + +**Severity: Critical** + +**Fact:** A submit request separately reads `daily_usage`, reads/upserts `device_usage`, reads all devices, and upserts `daily_usage` (`apps/web/app/api/usage/submit/route.ts:532-724`). There is no transaction, row lock, advisory lock, or current RPC. Two devices submitting the same date can each aggregate a different snapshot and last-write-wins the daily row. + +**Production evidence:** Live history has 233 daily/device value mismatches across 39 users, ending 2026-05-05; the last 30 days are currently clean. Some old mismatches may come from legacy migrations rather than races, but the present algorithm still permits the race described in GitHub issue #24. + +**Judgment:** The probability per user is low, but the failure is silent and changes leaderboard/accounting totals. + +**Remediation:** Move the entire per-date device upsert, legacy reconciliation, aggregate, daily upsert, and linked-post write behind one database transaction serialized by `(user_id, date)`. Add a two-request concurrency test that proves the final daily row equals the sum of both device rows. + +#### C3. HTTP 207 partial failures are reported and persisted as full success + +**Severity: High** + +**Fact:** The server returns HTTP 207 with `{ results, errors }` when some dates fail (`apps/web/app/api/usage/submit/route.ts:808-821,874-877`). Fetch treats 207 as successful (`packages/cli/src/lib/api.ts:44-61`), the client response type omits `errors`, success output counts all submitted entries, and `last_push_date` advances from the input entries instead of successful results (`packages/cli/src/commands/push.ts:45-55,490-530`). + +**Judgment:** A failed date can be printed as synced and eventually fall outside the seven-day overlap, converting a transient failure into a durable gap. + +**Remediation:** Model per-date success/failure, exit nonzero on any partial result, print the failed dates and retry command, and advance the watermark only through a contiguous successfully processed range. Pair this with a server idempotency key so submission retries are safe. + +#### C4. A single date can be left half-written + +**Severity: High** + +**Fact:** Within one date, `device_usage` can be committed before `daily_usage`, and both can be committed before post creation (`apps/web/app/api/usage/submit/route.ts:592-793`). Later failure returns a rejected date even though some writes remain. + +**Judgment:** Retries are often self-healing, but observers can see inconsistent state between attempts and failures are much harder to reason about. + +**Remediation:** Include all per-date database changes in the same transactional RPC as C2. Return a result only after the device row, daily aggregate, and linked post are committed together. + +#### C5. Mixed-source monotonicity can erase a source + +**Severity: High** + +**Fact:** The server permits a device overwrite whenever incoming total cost is at least the stored total (`apps/web/app/api/usage/submit/route.ts:586-590`). Component-preservation checks apply to the trusted Codex-lowering branch, but the general higher-total branch can replace Claude $10 + Codex $1 with Claude $9 + Codex $3 because the aggregate rose. + +**Judgment:** A partial source disappearance can be masked by another source increasing, silently lowering one component while the aggregate guard appears safe. + +**Remediation:** Store/upsert per-agent or per-model components, or enforce component-wise monotonicity except for an explicitly trusted correction to that component. + +#### C6. Runtime request and accounting invariants are incomplete + +**Severity: High** + +**Fact:** `readJsonBodyWithLimit` casts parsed JSON rather than validating it (`apps/web/app/api/usage/submit/route.ts:89-146`). `validateEntry` checks only a subset of fields for `< 0` and does not enforce types, finiteness, cache fields, model/breakdown shapes, or `entry.date === entry.data.date` (`apps/web/app/api/usage/submit/route.ts:56-83,494-513`). The CLI validates individual numbers but does not assert token-bucket or breakdown-cost sums (`packages/cli/src/lib/ccusage.ts:280-329,397-428`). + +**Judgment:** Collector schema drift or an old client can produce 500s or internally contradictory totals. + +**Remediation:** Define one runtime schema shared by client and server, require finite non-negative integer token buckets, strict calendar dates, validated UUIDs and arrays, matching outer/inner dates, and cost/token sum invariants with explicit tolerances. + +#### C7. Migration/repair state does not control the range it claims to control + +**Severity: High** + +**Fact:** `resolvePushDateRange` accepts `shouldRunMigrationBackfill` but never uses it (`packages/cli/src/commands/push.ts:139-184`). A fresh install defaults to three days, while old incremental state caps at seven. `packages/cli/README.md:31` and `docs/CLI.md:23-77` promise an automatic 30-day migration backfill, but tests explicitly lock in the smaller range (`packages/cli/__tests__/resolve-push-date-range.test.ts:80-106`). + +**Judgment:** Users can believe a historical correction or migration ran when it did not, leaving old totals incomplete or inflated. + +**Remediation:** Make an explicit product decision. Either restore one-time 30-day repair behavior, with resumable progress, or delete the flag/markers and correct the docs/UI to require a manual backfill. Do not retain state fields that imply a repair guarantee the command does not provide. + +### Local state, concurrency, and automation + +#### R1. Config persistence is non-atomic and loses concurrent updates + +**Severity: High** + +**Fact:** Config is overwritten directly (`packages/cli/src/lib/auth.ts:52-58`). Token refresh, login, auto-push, device creation, and watermark changes use independent read-modify-write cycles; `updateLastPushDate` reloads state before writing (`packages/cli/src/lib/auth.ts:60-64`). + +**Judgment:** A crash can leave truncated JSON, which `loadConfig` silently treats as logged out. Overlapping processes can lose a refreshed token, device ID, auto settings, or repair marker even when both writes are individually complete. + +**Remediation:** Write a `0600` temporary file, fsync it, atomically rename it, and serialize updates with an advisory file lock. Put updates behind one merge function rather than exposing whole-config overwrites. + +#### R2. Pushes have no singleton/coalescing guard + +**Severity: High** + +**Fact:** Claude SessionEnd hooks run asynchronously (`packages/cli/src/lib/hooks.ts:67-74`), while `pushCommand` has no lock around collection, submission, or config mutation. + +**Judgment:** Two session ends, a manual command, and a scheduler can start multiple expensive collectors and amplify both API and config races. + +**Remediation:** Add a per-user push lock containing PID and start time, coalesce or skip overlapping routine pushes, and recover stale locks. Explicit/manual backfills should wait or fail with a clear message rather than silently overlap. + +#### R3. Reauthentication discards valid non-auth state + +**Severity: High** + +**Fact:** Same-identity login rewrites only token, username, URL, last-push date, and device fields (`packages/cli/src/commands/login.ts:110-122`). It drops `auto_push` and all migration/repair fields declared in `packages/cli/src/lib/auth.ts:15-24`. + +**Judgment:** A transparent 401 recovery can orphan an installed scheduler and resurrect migration prompts/state. + +**Remediation:** For the same API origin and account, merge the existing config and replace only auth fields. For an account/origin change, reset identity-scoped state through a named, tested policy. + +#### R4. Background first-run behavior attempts interactive browser login + +**Severity: High** + +**Fact:** A missing config always calls `loginCommand` (`packages/cli/src/commands/push.ts:301-307`) without checking interactivity. Login spawns a browser and polls for up to five minutes (`packages/cli/src/commands/login.ts:65-155`). + +**Judgment:** Cron, launchd, hooks, and CI can repeatedly attempt GUI login and stall. + +**Remediation:** In a non-interactive process, fail immediately with a stable `AUTH_REQUIRED` error and remediation text. Validate auth before enabling automation. + +#### R5. Auto-push installation can misreport success and execute interpolated crontab text + +**Severity: High** + +**Fact:** launchd load errors are swallowed, and installed status checks only plist existence (`packages/cli/src/lib/scheduler.ts:17-27,103-118`). Cron installation embeds the entire existing crontab into an `execSync` shell string (`packages/cli/src/lib/scheduler.ts:125-140,156-180`), so shell substitutions in existing content can execute during installation. The wrapper logs a failed child exit but does not exit with that code (`packages/cli/src/lib/scheduler.ts:46-63`). Log rotation exists but has no production caller (`packages/cli/src/lib/auto-push-logger.ts:15-27`). + +**Judgment:** Users can be told automation is enabled when it is not, monitoring sees false success, and logs grow without bound. + +**Remediation:** Use `execFile`/`spawn` with crontab passed through stdin, quote installed paths, adopt current launchctl bootstrap/bootout semantics, verify the loaded service, roll back on failure, propagate the child exit code, and rotate before every scheduled run. + +### Network, latency, and error recovery + +#### P1. API operations have no deadline or cancellation + +**Severity: High** + +**Fact:** Authenticated and unauthenticated fetches have no `AbortSignal` (`packages/cli/src/lib/api.ts:32-45,112-124`). The five-minute login deadline is outside each poll request, so one stuck request can exceed it indefinitely (`packages/cli/src/commands/login.ts:91-105`). `--timeout` applies only to ccusage. + +**Judgment:** A slow or half-open connection can hang login, status, submit, or the post-submit dashboard forever. + +**Remediation:** Give every request a deadline and error code, pass the remaining overall login budget into polls, and use different budgets for poll, GET/dashboard, and idempotent submit operations. + +#### P2. Login suppresses permanent failures and has no backoff + +**Severity: High** + +**Fact:** Every poll exception is swallowed and retried after two seconds (`packages/cli/src/commands/login.ts:93-105`), including 400, 401, 403, 429, and server errors. `Retry-After` is not exposed by `apiRequestNoAuth`. + +**Judgment:** A fatal contract/auth error becomes five minutes of dots and up to roughly 150 requests, potentially worsening a rate limit. + +**Remediation:** Return a typed HTTP error, fail immediately on permanent 4xx responses, honor `Retry-After`, and use capped exponential backoff with jitter for network/5xx failures. + +#### P3. Online pricing maximizes freshness but has no availability fallback budget + +**Severity: Medium** + +**Fact:** The default pricing mode is online (`packages/cli/src/lib/ccusage.ts:9,449-477`), and normal pushes explicitly select it (`packages/cli/src/commands/push.ts:359-362`). Online collection can run for four minutes (`packages/cli/src/config.ts:20`). The code supports offline-to-online fallback, not online-to-offline fallback (`packages/cli/src/lib/ccusage.ts:479-495`). The deliberate rationale is that ccusage 20.0.16's embedded GPT-5.6 price differed from current LiteLLM pricing (`docs/DECISIONS.md:927-937`). + +**Judgment:** The accuracy choice is reasonable, but it makes every sync depend on pricing-network behavior with no short availability fallback. The repository no longer contains a reproducible collector benchmark; the only current recorded comparison is a median +152 ms / 9.5% (`docs/DECISIONS.md:5-9`). + +**Remediation:** Keep ccusage as the pricing owner, but apply a short online refresh budget, fall back to the bundled snapshot with explicit `pricing_mode` and snapshot-age metadata, and schedule a correction on the next online success. Add a fixture benchmark and a real-log canary that tracks p50/p95 by collector version and source set. + +#### P4. Transient submit failures force recollection + +**Severity: Medium** + +**Fact:** Normal API requests get one attempt; only 401 has a one-shot interactive recovery (`packages/cli/src/lib/api.ts:87-109`). A brief 429, 502, reset, or deployment restart after collection fails the command. + +**Judgment:** Blind POST retry is unsafe with the current server semantics, but repeating a multi-minute local scan is unnecessary. + +**Remediation:** Cache the validated payload for the duration of the command, add server idempotency, then retry submission with bounded backoff. Retry GETs directly; do not retry permanent validation/auth failures. + +### Dependencies, packaging, and release + +#### D1. Published users execute a different collector than CI tests + +**Severity: High** + +**Fact:** `packages/cli/package.json:32-38` publishes `ccusage: ^20.0.16`, while `bun.lock:859` fixes CI to 20.0.16. The npm range currently resolves 20.0.18. ccusage owns source discovery, parsing, deduplication, token accounting, and pricing (`packages/cli/README.md:12-18`). + +**Judgment:** This creates exactly the kind of parser/schema drift seen in issues #13, #87, #99, and #132, without requiring a Straude release. + +**Remediation:** Prefer an exact collector version in the published package. If a range is retained, release-gate both the minimum and the current maximum with golden fixtures and a packed-install test before declaring compatibility. + +#### D2. The declared Node 18 floor is false + +**Severity: High** + +**Fact:** Straude declares and builds for Node 18 (`packages/cli/package.json:13-15`; `packages/cli/tsup.config.ts:8-16`; `packages/cli/README.md:15-18`), but runtime dependencies `ink@6.8.0` and `@pppp606/ink-chart@0.2.4` declare Node >=20. CI has no Node-version or OS matrix (`.github/workflows/ci.yml:13-22`). + +**Judgment:** Users can satisfy Straude's documented requirement while running an unsupported dependency graph. + +**Remediation:** Raise the package and documentation floor to Node 20 and test every declared major. Keeping Node 18 would require removing or downgrading incompatible runtime dependencies and proving the packed binary on Node 18. + +#### D3. Manual publishing bypasses packaged validation + +**Severity: Medium** + +**Fact:** CI builds and source-tests but never runs `test:packaged` (`.github/workflows/ci.yml:56-76`). `prepublishOnly` builds but does not run checks (`packages/cli/package.json:16-22`). npm has releases through 0.1.30, but Git has only tag `straude@0.1.24` and no GitHub releases. The packaged test calls `npm pack` without first building, while npm pack does not run `prepublishOnly` (`packages/cli/scripts/packaged-cli-e2e.mjs:38-47`). + +**Judgment:** A manual publish can ship stale/missing output or a package that source tests never exercised. + +**Remediation:** Add a clean-checkout release workflow: exact Bun version, frozen install, typecheck, tests, build, `npm pack`, isolated install, packaged E2E on the supported Node/OS matrix, then publish that exact tarball with provenance and matching tag/release. + +### User experience and observability + +#### O1. Production failures cannot be attributed from server logs + +**Severity: High** + +**Fact:** The submit route emits no structured start, completion, duration, request ID, status, or per-stage error logs. Production showed two 500s in seven days, but Vercel logs could not identify their cause. CLI push-failure telemetry sends a truncated message/name rather than exception stack (`packages/cli/src/lib/telemetry.ts:29-51`), and production sourcemaps are disabled (`packages/cli/tsup.config.ts:16`). + +**Judgment:** The team has timing fields for successful CLI telemetry but insufficient evidence for the failures users are reporting. + +**Remediation:** Add allowlisted error codes and stage timings to CLI and API, structured request logs, request/attempt IDs, collector version/source/pricing dimensions, and private sourcemap upload or scrubbed stack fingerprints. Alert on failure rate, 207s, p95 stage latency, watermark lag, and daily/device reconciliation drift. + +#### O2. Dry-run and dashboard degradation hide useful evidence + +**Severity: Medium** + +**Fact:** A healthy dry-run renders the existing remote dashboard and prints pending local entries only when the dashboard/Ink path fails (`packages/cli/src/commands/push.ts:406-429`). Post-submit dashboard errors are swallowed with no fallback detail (`packages/cli/src/commands/push.ts:237-265,536-540`). + +**Judgment:** `--dry-run` does not reliably preview what would be uploaded, and a successful upload followed by a dashboard failure can look like an incomplete command. + +**Remediation:** Always print/render the local pending entries in dry-run. After submit, print the durable sync summary and link first, then treat dashboard rendering as optional enrichment with a concise fallback and `--debug` detail. + +#### O3. Argument and date handling accepts unintended ranges + +**Severity: Medium** + +**Fact:** `--days` uses loose `parseInt` and no range validation (`packages/cli/src/index.ts:82-123,187-194`); negative, zero, suffixed, and non-numeric values can become a future range or silently fall back to smart sync (`packages/cli/src/commands/push.ts:148-163`). JavaScript date construction normalizes impossible dates, and local-midnight range validation disagrees with UTC fractional-day server/prefilter checks at the 30-day boundary (`packages/cli/src/commands/push.ts:97-127`; `apps/web/app/api/usage/submit/route.ts:41-54`). + +**Judgment:** Users can request one range and scan or submit another. + +**Remediation:** Use a strict parser, reject unknown/missing flags, require `days` in `1..30`, round-trip calendar dates, and share calendar-day ordinal logic between client and server. + +### Strengths to preserve + +- `execFile` uses `shell: false`, a 20 MiB buffer, and a configurable timeout (`packages/cli/src/lib/ccusage.ts:232-260`). +- Native binary resolution is platform-aware, validates supported architecture, enforces a minimum collector version, and repairs executable mode (`packages/cli/src/lib/ccusage.ts:122-219`). +- Collector rows reject non-finite/negative numbers and malformed required structures (`packages/cli/src/lib/ccusage.ts:280-329,374-447`). +- Request byte size, entry count, date duplication, per-user rate, and server-side processing concurrency are bounded (`apps/web/app/api/usage/submit/route.ts:12-15,89-175,465-513`). +- Config and machine marker files use owner-only modes; browser launch validates HTTP(S) and avoids shell interpolation. +- The test suite includes real HTTP, real filesystem permissions, subprocess spawning, parser validation, source fixtures, scheduler/hook behavior, and a valuable isolated packed-install flow. +- Successful push telemetry already records auth, collection, submit, dashboard, total timing, collector version, source set, and pricing mode (`packages/cli/src/commands/push.ts:208-234,542-566`). +- Current production data has been internally consistent for the last 30 days, so the remediation can focus on preventing rare fault paths rather than replacing a universally broken pipeline. + +## 4. Improvement Strategy + +### Theme 1: Make ingestion transactional, idempotent, and explicit about partial outcomes + +**Target state:** A date is either fully committed or not committed. Concurrent devices serialize on `(user, date)`. Every submission has a reusable idempotency key, and the client receives a per-date terminal state. + +**Principle:** Reliability begins at the commit boundary. Client retry logic is unsafe until the server can prove that repeating a request does not duplicate or partially mutate data. + +### Theme 2: Treat local state as a small database + +**Target state:** Installation identity, auth credentials, sync watermark, repair progress, and automation state have defined lifetimes; writes are atomic and serialized; one push runs at a time. + +**Principle:** A file containing identity and progress is persistent state, not disposable preferences. It needs atomic updates, recovery, and concurrency control. + +### Theme 3: Bound every external dependency and publish the tested graph + +**Target state:** Every network/subprocess operation has a deadline and typed failure; pricing has a documented freshness/availability policy; users install the collector version and Node runtime tested in CI. + +**Principle:** An unbounded call and an untested semver range turn upstream behavior into Straude incidents. + +### Theme 4: Make recovery visible and correct + +**Target state:** Permanent, transient, partial, auth, validation, and rendering failures produce distinct error codes, next actions, and exit statuses. Watermarks advance only over proven successes. + +**Principle:** “Best effort” is acceptable for optional dashboard/telemetry output, not for sync accounting. + +### Theme 5: Close the production feedback loop + +**Target state:** Releases come from a tested tarball; production has stage-level latency/failure data; nightly reconciliation finds duplicate devices, daily/device drift, and invariant violations before users do. + +**Principle:** A large test count does not replace verification of the package and traffic that users actually run. + +### Explicit trade-offs + +- Do **not** replace ccusage now. An adapter evaluation may be useful later, but it does not address duplicate device identity, partial HTTP semantics, config races, request hangs, or release drift. +- Do **not** reimplement model pricing in Straude. Keep ccusage as the pricing owner and make freshness/fallback state explicit. +- Do **not** add a queue or distributed workflow engine yet. A transactional Postgres function, idempotency key, and local single-writer lock are sufficient for this scale. +- Do **not** auto-delete historical suspected duplicates. Exact-looking duplicate rows still need account/device review or local source-of-truth confirmation. +- Do **not** expand into unrelated Supabase advisor findings. The current CLI submit path uses indexed keys and service-role access; unrelated RLS and foreign-key warnings do not explain reported CLI latency. + +### Definition of done + +- Zero daily/device reconciliation mismatches and zero unresolved exact same-install duplicate candidates in the nightly check. +- Two concurrent device submissions always produce the exact aggregate in 100 repeated integration-test runs. +- A 207/partial result never prints full success, never skips a failed date, and returns nonzero. +- Config fault-injection tests survive process interruption and 20 concurrent update attempts without invalid JSON or lost fields. +- All CLI/API calls abort within documented budgets; login honors an overall five-minute ceiling and permanent poll failures terminate immediately. +- Warm one-day sync provisional SLO: p50 <2 seconds and p95 <5 seconds excluding interactive login; collection, submit, and dashboard have separate p95 alerts. Recalibrate after two weeks of real telemetry. +- CI installs the exact published dependency graph and passes packed CLI tests on Node 20 and 22 across macOS, Linux, and Windows. +- Every production submit has a request ID, outcome code, total duration, and per-stage failure signal; 500 and 207 causes are attributable without user logs. + +## 5. Task Plan + +### Milestone 0: Safety net + +| ID | Task | Areas | Acceptance criteria | Effort | Change risk | Dependencies | +|---|---|---|---|---|---|---| +| M0.1 | Add ingestion characterization and concurrency tests | submit route, local Supabase integration tests | Reproduce concurrent-device lost aggregate, within-date partial write, 207 client handling, and component-masked decrease before fixes | L | Low | None | +| M0.2 | Add config, process-lock, network, and argv fault tests | CLI auth/config/api/push tests | Cover truncated write, concurrent updates, stale lock, hung fetch, 429/5xx polling, non-TTY auth, invalid dates/days | L | Low | None | +| M0.3 | Restore a reproducible collector benchmark | `packages/cli/scripts`, fixtures, CI artifact | Offline/online, 1/7/30-day, source-count, and cold/warm medians recorded with non-flaky regression thresholds | M | Low | None | +| M0.4 | Add structured production diagnostics | submit/auth routes, CLI telemetry | Request ID, stage, duration, status/error code, collector/version/mode emitted without prompts/paths; dashboard exposes p50/p95/failure rate | M | Low | None | + +### Milestone 1: Critical fixes + +| ID | Task | Areas | Acceptance criteria | Effort | Change risk | Dependencies | +|---|---|---|---|---|---|---| +| M1.1 | Make per-date ingestion transactional and serialized | new migration/RPC, submit route | Device, daily, and post state commit atomically; advisory lock prevents lost aggregate; 100-run concurrency test passes | XL | High | M0.1 | +| M1.2 | Define idempotent partial-result protocol | API types, submit route, CLI push/API | Per-date statuses/errors; retry key; client exits nonzero on partial; watermark advances only over contiguous successes | L | Medium | M0.1, M1.1 | +| M1.3 | Separate and reconcile device identity | CLI state, submit contract, device registry/admin repair | Config deletion + relogin + identical push does not inflate; suspected historical duplicates have a review/repair report | XL | High | M0.1, M1.1 | +| M1.4 | Make config atomic and single-writer | `auth.ts`, push lock, login/auto paths | Temp+fsync+rename; lock/merge API; concurrent/fault tests preserve every field and valid JSON | L | Medium | M0.2 | +| M1.5 | Add runtime schemas and accounting invariants | shared types/schema, ccusage parser, submit route | Malformed types/dates/UUIDs/breakdowns and inconsistent totals receive stable 400/local validation errors, never 500/storage | L | Medium | M0.1 | + +### Milestone 2: High-leverage improvements + +| ID | Task | Areas | Acceptance criteria | Effort | Change risk | Dependencies | +|---|---|---|---|---|---|---| +| M2.1 | Add HTTP deadlines and classified retry policy | `api.ts`, login, status, push | Every request aborts; permanent poll errors fail immediately; 429 honors Retry-After; safe retries use jitter/backoff | M | Medium | M0.2, M1.2 | +| M2.2 | Preserve config and fail fast in background auth | login, push, auto | Same-identity login changes auth only; account change follows tested reset policy; non-TTY missing auth exits immediately | S | Low | M0.2, M1.4 | +| M2.3 | Pin collector and align runtime contract | package manifest, lockfile, docs, CI | Published collector equals tested collector; Node floor is truthful; matrix covers Node 20/22 and three OS families | M | Medium | M0.3 | +| M2.4 | Build a release-from-tarball workflow | GitHub Actions, package scripts | Frozen clean build, source tests, packed E2E, provenance, tag/release, and publish all use one tarball | L | Medium | M2.3 | +| M2.5 | Define pricing availability policy | ccusage adapter, telemetry, docs | Online refresh has short budget; validated offline fallback is visible and later corrected; SLO/canary catches drift | M | Medium | M0.3, M1.2 | +| M2.6 | Make auto-push verifiable and safe | scheduler, hooks, logger | No shell interpolation; service load verified; failures roll back/exit nonzero; singleton lock and log rotation active | L | Medium | M1.4 | +| M2.7 | Move overwrite safety to per-source components | storage model/collector metadata | One source cannot fall while another masks it; trusted corrections lower only their component; mixed-source tests pass | XL | High | M1.1, M1.5 | + +### Milestone 3: Quality and polish + +| ID | Task | Areas | Acceptance criteria | Effort | Change risk | Dependencies | +|---|---|---|---|---|---|---| +| M3.1 | Resolve migration/backfill contract | range resolver, config, docs | Code/tests/docs agree; repair is resumable or obsolete fields are removed | M | Medium | M1.2, product decision | +| M3.2 | Replace loose argv/date parsing | CLI entry/range helpers | Unknown/missing flags and invalid calendar/range values fail before side effects; timezone boundary tests pass | S | Low | M0.2 | +| M3.3 | Make dry-run and dashboard fallback truthful | push/status rendering | Dry-run always shows pending local payload; successful upload always prints durable summary/link; debug has render/API cause | S | Low | M0.4 | +| M3.4 | Improve diagnostic artifacts safely | tsup, telemetry, release workflow | Private sourcemaps or stable error fingerprints available; only allowlisted structured error data is sent | M | Low | M0.4, M2.4 | +| M3.5 | Reconcile stale CLI documentation | root/package docs, decisions, changelog | Endpoints, flags, max days, Node floor, migration behavior, and auto-push commands match the shipped package | S | Low | M2.2, M2.3, M3.1 | + +### Quick wins + +- **S:** Strictly validate `--days`, `--date`, missing values, and unknown flags before login or collection. +- **S:** Preserve the entire same-identity config on re-login, changing only auth fields. +- **S:** Make missing auth in non-interactive mode fail immediately. +- **S:** Model 207 response errors now and return nonzero, even before resumable watermark logic is complete. +- **S:** Print local entries on every dry-run and print a fallback summary when dashboard rendering fails. +- **S:** Propagate scheduled push exit codes and call existing log rotation. +- **S:** Raise the documented/package Node floor to 20. +- **S:** Pin `ccusage` to the currently verified version until the release matrix exists. +- **S:** Add structured start/end/error logs to `/api/usage/submit` so existing 500s are attributable. + +### Implementation sketches for the top three tasks + +#### 1. Transactional, serialized per-date ingestion + +Create one Postgres function that accepts authenticated user ID, device identity, canonical entry, collector metadata, and idempotency key. Inside one transaction, take `pg_advisory_xact_lock` on a stable hash of `(user_id, date)`, validate/claim the idempotency key, upsert the device component, reconcile legacy state, aggregate all device components, upsert the daily row, and create/update the post. Return the previous/new totals and action from the function. If implemented as `SECURITY DEFINER`, fix `search_path`, revoke `EXECUTE` from `PUBLIC`, grant only the service role, and keep user identity resolution in the route. The main gotchas are preserving repair semantics and avoiding a public privileged RPC. + +#### 2. Durable device identity and duplicate reconciliation + +Split `config.json` into authentication/sync state and an installation record whose ID survives token refresh and normal logout. Add a canonical per-day content hash independent of requested date range and collector patch version. On a new device registration or a submission whose canonical content matches another device for the same account/date, do not immediately add it; return `device_reconciliation_required` with enough opaque IDs for a user/admin merge. Add a safe device-rebind endpoint and a report over current same-host/matching-content candidates. Avoid hardware serial numbers and do not silently merge solely by hostname. + +#### 3. Idempotent partial-result and watermark protocol + +Give each push attempt an idempotency key and each date a deterministic child key. The server response should be `{ dates: [{ date, status, action?, error_code?, retryable? }] }` regardless of whether all dates succeed; reserve non-2xx for request-wide auth/validation/infrastructure failures. The CLI prints exactly what committed, exits nonzero if any date failed, and stores a watermark only through the last contiguous successful day. Cache the validated payload during the command and retry retryable dates without rerunning ccusage. Test a mixed three-date request where the middle date fails, then succeeds on retry, without duplicating the other two. + +## 6. Open Questions + +1. Is Straude willing to show a clearly labeled embedded-snapshot cost when live pricing refresh exceeds a short deadline, then correct it later, or must a pricing-network failure block every sync? +2. Should the historical duplicate/mismatch candidates be reviewed and repaired now? The exact-looking duplicate subset is small enough to audit, but automatic deletion would be unsafe without account/device confirmation. +3. Is Node 20 an acceptable minimum, or is Node 18 support important enough to replace the current Ink/chart dependencies? +4. Should partial success be a hard nonzero exit for manual commands and automation, or should automation retry retryable dates before returning nonzero? +5. Should an automatic first/migration sync cover 30 days, or is the current three/seven-day behavior intentional? The code, tests, state fields, and published docs currently disagree. +6. What sync SLO should be product policy? The provisional p95 <5-second warm-sync target is achievable from existing measurements, but it should be confirmed against production telemetry and large real-log users. +7. Is Windows auto-push a supported product goal? Collection supports Windows, while scheduler automation explicitly does not. diff --git a/packages/cli/README.md b/packages/cli/README.md index 1324f584..170c18bd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,10 +14,10 @@ Running with no arguments performs a smart sync: logs you in if needed, then pus ## Requirements -- Node 18+ +- Node 20+ - Local session data from any source supported by ccusage. -Straude invokes its installed [`ccusage`](https://github.com/ccusage/ccusage) dependency directly. The compatible `ccusage@^20.0.16` range owns source parsing, model recognition, token accounting, and LiteLLM pricing updates. Straude uses ccusage's unified report, so all detected sources are included by default: Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and compatible custom source IDs. +Straude invokes its installed [`ccusage`](https://github.com/ccusage/ccusage) dependency directly. Version `20.0.16` is pinned so the parser, token accounting, and native binary match the release fixture tested by Straude. Live LiteLLM pricing is required; embedded-price fallback is detected, retried within a bounded recovery budget, and never submitted. Straude uses ccusage's unified per-agent report, so all detected sources are included by default: Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and compatible custom source IDs. ## Commands @@ -27,9 +27,9 @@ Straude invokes its installed [`ccusage`](https://github.com/ccusage/ccusage) de straude ``` -- First run: opens a browser tab to authenticate, then pushes today's usage. -- First run after the ccusage v20 migration: backfills the last 30 days once. -- Subsequent runs: pushes all days since the last sync (up to 7 days). +- First run: opens a browser tab to authenticate, then pushes the last 3 days. +- Subsequent runs: resume after the last committed date and process up to 7 contiguous days per run. +- Explicit backfill: `straude push --days 30` reads the maximum 30-day window. - Already synced today: prints today's stats and exits. ### `login` @@ -53,6 +53,10 @@ Push usage data to Straude. | `--date YYYY-MM-DD` | Push a specific date (must be within the last 30 days) | | `--days N` | Push the last N days (max 30) | | `--dry-run` | Preview what would be submitted without posting | +| `--timeout N` | Set the ccusage timeout in seconds (default 240) | +| `--api-url URL` | Use a different Straude API origin | +| `--debug` | Print diagnostic detail to stderr | +| `--non-interactive` | Never open a browser or wait for login | ### `status` @@ -62,6 +66,30 @@ straude status Show your current streak, weekly spend, token usage, and global rank. +### `devices` + +```sh +straude devices +straude devices merge +straude devices keep-separate +``` + +List or resolve ambiguous installation identities. Automatic merging requires +matching hostnames, at least two identical source-level overlap dates, and no +divergent overlap; ambiguous candidates remain quarantined until resolved. + +### Automatic sync + +```sh +straude --auto # Daily launchd/cron job +straude --auto --time 14:30 # Choose the local run time +straude --auto hooks # Claude Code SessionEnd hook +straude --no-auto # Disable the configured mechanism +straude auto logs # Inspect scheduler output +``` + +The OS scheduler is supported on macOS and Linux. Claude Code hooks work anywhere Claude Code supports `SessionEnd`; Windows does not support the OS scheduler. + ## Examples ```sh @@ -87,6 +115,20 @@ straude status ## Config Credentials are stored in `~/.straude/config.json` (mode `0600`, owner-only). +The installation UUID is stored separately in `~/.straude/machine_id`, so +deleting the config does not create a second logical device. Server aliases are +scoped to the Straude account, so account switches on the same machine remain +independent. Pending validated requests are stored in +`~/.straude/pending-sync.json` until each date commits. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Complete, empty, or safely coalesced work | +| `1` | Permanent input, configuration, collection, or identity error | +| `2` | Non-interactive authentication required | +| `75` | Retryable network, service, pricing, lock, or partial failure | ## Debug mode @@ -106,7 +148,7 @@ normal output. ## Telemetry -The CLI sends anonymous usage events (command name, CLI version, success/failure outcomes, aggregate counts like `days_pushed` and `total_cost_usd`) to Straude's PostHog project so we can prioritise features and catch regressions. We never send prompts, code, conversation content, file paths, or ccusage rows — home directory paths are scrubbed from any free-form payload before transmission. +The CLI sends operational events (command name, CLI version, success/failure outcomes, timings, and aggregate counts such as `days_pushed` and `total_cost_usd`) to Straude's PostHog project. It does not send prompts, code, conversation content, or raw ccusage rows. The configured home-directory prefix is replaced with `~` in free-form telemetry before transmission. To opt out, set either env var: diff --git a/packages/cli/__tests__/api.test.ts b/packages/cli/__tests__/api.test.ts index 0957cb7f..5fb40ff3 100644 --- a/packages/cli/__tests__/api.test.ts +++ b/packages/cli/__tests__/api.test.ts @@ -15,7 +15,7 @@ import type { StraudeConfig } from "../src/lib/auth.js"; * response body, or honor `res.headers.get`. With a real server, every byte * the production code writes and reads is exercised. * - * We mock at one boundary: `auth.saveConfig`, because the real implementation + * We mock at one boundary: `auth.updateConfig`, because the real implementation * writes to `~/.straude/config.json` and we don't want test runs touching the * user's actual config. That mock is captured-and-asserted, not faked-and- * forgotten. @@ -23,7 +23,7 @@ import type { StraudeConfig } from "../src/lib/auth.js"; vi.mock("../src/lib/auth.js", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, saveConfig: vi.fn() }; + return { ...actual, updateConfig: vi.fn() }; }); vi.mock("../src/lib/prompt.js", () => ({ @@ -31,9 +31,9 @@ vi.mock("../src/lib/prompt.js", () => ({ promptYesNo: vi.fn(), })); -import { saveConfig } from "../src/lib/auth.js"; +import { updateConfig } from "../src/lib/auth.js"; import { isInteractive } from "../src/lib/prompt.js"; -const mockSaveConfig = vi.mocked(saveConfig); +const mockUpdateConfig = vi.mocked(updateConfig); const mockIsInteractive = vi.mocked(isInteractive); interface RequestRecord { @@ -47,6 +47,7 @@ interface PlannedResponse { status: number; body: unknown; headers?: Record; + delayMs?: number; } let server: Server; @@ -75,12 +76,16 @@ beforeAll(async () => { res.end(JSON.stringify({ error: "no planned response" })); return; } - res.statusCode = next.status; - res.setHeader("content-type", "application/json"); - for (const [k, v] of Object.entries(next.headers ?? {})) { - res.setHeader(k, v); - } - res.end(JSON.stringify(next.body)); + const respond = (): void => { + res.statusCode = next.status; + res.setHeader("content-type", "application/json"); + for (const [k, v] of Object.entries(next.headers ?? {})) { + res.setHeader(k, v); + } + res.end(JSON.stringify(next.body)); + }; + if (next.delayMs) setTimeout(respond, next.delayMs); + else respond(); }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -98,7 +103,8 @@ afterAll(async () => { beforeEach(() => { recorded = []; plan = []; - mockSaveConfig.mockReset(); + mockUpdateConfig.mockReset(); + mockUpdateConfig.mockImplementation((updater) => updater(null)); mockIsInteractive.mockReset(); mockIsInteractive.mockReturnValue(false); setAuthRefreshStrategy(null); @@ -168,12 +174,37 @@ describe("apiRequest — error handling", () => { it("surfaces the body's error string on other non-2xx", async () => { plan.push({ status: 500, body: { error: "boom" } }); - await expect(apiRequest(configFor(), "/api/test")).rejects.toThrow("boom"); + await expect(apiRequest(configFor(), "/api/test", { maxRetries: 0 })).rejects.toThrow("boom"); }); it("falls back to HTTP when the body has no error key", async () => { plan.push({ status: 503, body: { unrelated: "field" } }); - await expect(apiRequest(configFor(), "/api/test")).rejects.toThrow("HTTP 503"); + await expect(apiRequest(configFor(), "/api/test", { maxRetries: 0 })).rejects.toThrow("HTTP 503"); + }); + + it("returns typed protocol bodies for explicitly accepted non-2xx statuses", async () => { + plan.push({ + status: 409, + body: { + request_id: "request-1", + outcomes: [{ + date: "2026-03-13", + status: "identity_conflict", + error: { + code: "device_reconciliation_required", + message: "Resolve device identity", + }, + }], + }, + }); + await expect(apiRequest(configFor(), "/api/usage/submit", { + method: "POST", + acceptedStatuses: [400, 409, 503], + })).resolves.toMatchObject({ + request_id: "request-1", + outcomes: [{ status: "identity_conflict" }], + }); + expect(recorded).toHaveLength(1); }); }); @@ -187,7 +218,7 @@ describe("apiRequest — sliding token refresh", () => { const cfg = configFor(); await apiRequest(cfg, "/api/test"); expect(cfg.token).toBe("new-token-xyz"); - expect(mockSaveConfig).toHaveBeenCalledWith( + expect(mockUpdateConfig.mock.results[0]!.value).toEqual( expect.objectContaining({ token: "new-token-xyz" }), ); }); @@ -195,7 +226,7 @@ describe("apiRequest — sliding token refresh", () => { it("does not save when the refresh header is absent", async () => { plan.push({ status: 200, body: {} }); await apiRequest(configFor(), "/api/test"); - expect(mockSaveConfig).not.toHaveBeenCalled(); + expect(mockUpdateConfig).not.toHaveBeenCalled(); }); it("uses the refreshed token on the very next request", async () => { @@ -212,8 +243,8 @@ describe("apiRequest — sliding token refresh", () => { expect(recorded[1]!.headers.authorization).toBe("Bearer rotated-1"); }); - it("swallows read-only-fs saveConfig errors so the request still resolves", async () => { - mockSaveConfig.mockImplementation(() => { + it("swallows read-only-fs updateConfig errors so the request still resolves", async () => { + mockUpdateConfig.mockImplementation(() => { const err = new Error("read-only filesystem") as NodeJS.ErrnoException; err.code = "EROFS"; throw err; @@ -226,8 +257,8 @@ describe("apiRequest — sliding token refresh", () => { await expect(apiRequest(configFor(), "/api/test")).resolves.toEqual({ ok: true }); }); - it("propagates unexpected saveConfig errors instead of swallowing them", async () => { - mockSaveConfig.mockImplementation(() => { + it("propagates unexpected updateConfig errors instead of swallowing them", async () => { + mockUpdateConfig.mockImplementation(() => { const err = new Error("disk full") as NodeJS.ErrnoException; err.code = "ENOSPC"; throw err; @@ -321,3 +352,71 @@ describe("apiRequestNoAuth", () => { expect(recorded[0]!.path).toBe("/health"); }); }); + +describe("apiRequest — resilience", () => { + it("makes three total attempts when maxRetries is two", async () => { + const random = vi.spyOn(Math, "random").mockReturnValue(0); + plan.push({ + status: 503, + body: { error: "warming up" }, + }); + plan.push({ status: 599, body: { error: "still warming up" } }); + plan.push({ status: 200, body: { ok: true } }); + + await expect(apiRequest<{ ok: boolean }>( + configFor(), + "/api/test", + { maxRetries: 2 }, + )) + .resolves.toEqual({ ok: true }); + expect(recorded).toHaveLength(3); + expect(random).toHaveBeenCalledTimes(2); + }); + + it.each([408, 425, 429, 500, 501, 599])( + "retries HTTP %i", + async (status) => { + plan.push({ + status, + body: { error: "temporarily unavailable" }, + headers: { "retry-after": "0" }, + }); + plan.push({ status: 200, body: { ok: true } }); + await expect(apiRequest(configFor(), "/api/test", { maxRetries: 1 })) + .resolves.toEqual({ ok: true }); + }, + ); + + it("retries a transient network error", async () => { + const fetch = vi.spyOn(globalThis, "fetch") + .mockRejectedValueOnce(new TypeError("connection reset")) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + })); + vi.spyOn(Math, "random").mockReturnValue(0); + + await expect(apiRequest(configFor(), "/api/test", { maxRetries: 1 })) + .resolves.toEqual({ ok: true }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("does not shorten Retry-After to fit an internal cap", async () => { + plan.push({ + status: 429, + body: { error: "slow down" }, + headers: { "retry-after": "60" }, + }); + await expect( + apiRequest(configFor(), "/api/test", { timeoutMs: 20, maxRetries: 1 }), + ).rejects.toThrow(/timed out/i); + expect(recorded).toHaveLength(1); + }); + + it("aborts a response that exceeds the per-call deadline", async () => { + plan.push({ status: 200, body: { ok: true }, delayMs: 100 }); + await expect( + apiRequest(configFor(), "/api/test", { timeoutMs: 20, maxRetries: 0 }), + ).rejects.toThrow(/timed out/i); + }); +}); diff --git a/packages/cli/__tests__/args.test.ts b/packages/cli/__tests__/args.test.ts new file mode 100644 index 00000000..74b4f071 --- /dev/null +++ b/packages/cli/__tests__/args.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + assertSupportedNodeRuntime, + CliArgumentError, + parseCliArgs, +} from "../src/lib/args.js"; + +describe("strict CLI arguments", () => { + it("parses bounded push values", () => { + expect(parseCliArgs([ + "push", + "--days", + "7", + "--timeout", + "240", + "--non-interactive", + ])).toEqual({ + command: "push", + subcommand: null, + operand: null, + options: { + days: 7, + timeoutMs: 240_000, + nonInteractive: true, + }, + }); + }); + + it.each([ + [["--wat"], /Unknown option/], + [["--days"], /requires a value/], + [["--days", "3x"], /positive integer/], + [["--days", "31"], /between 1 and 30/], + [["--date", "2026-02-29"], /real calendar date/], + [["--date", "2026-07-22", "--days", "2"], /cannot be used together/], + [["status", "--dry-run"], /Push options/], + [["auto", "wat"], /Unsupported auto subcommand/], + [["push", "extra"], /Unexpected argument/], + ])("rejects invalid input %j", (args, expected) => { + expect(() => parseCliArgs(args as string[])).toThrow(expected as RegExp); + }); + + it("parses hook scheduling without treating hooks as a command", () => { + expect(parseCliArgs(["--auto", "hooks"]).options.autoMechanism).toBe("hooks"); + }); + + it("parses device reconciliation commands", () => { + expect(parseCliArgs([ + "devices", + "merge", + "11111111-1111-4111-8111-111111111111", + ])).toMatchObject({ + command: "devices", + subcommand: "merge", + operand: "11111111-1111-4111-8111-111111111111", + }); + }); + + it("rejects Node 18 with a clear startup error", () => { + expect(() => assertSupportedNodeRuntime("18.20.8")).toThrow(CliArgumentError); + expect(() => assertSupportedNodeRuntime("18.20.8")).toThrow(/Node\.js 20 or newer/); + expect(() => assertSupportedNodeRuntime("20.0.0")).not.toThrow(); + }); +}); diff --git a/packages/cli/__tests__/auth.test.ts b/packages/cli/__tests__/auth.test.ts index 74546da0..cba08354 100644 --- a/packages/cli/__tests__/auth.test.ts +++ b/packages/cli/__tests__/auth.test.ts @@ -1,22 +1,45 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { loadConfig, saveConfig, requireAuth } from "../src/lib/auth.js"; +import { + ConfigCorruptError, + loadConfig, + saveConfig, + requireAuth, + updateConfig, +} from "../src/lib/auth.js"; + +let nextFd = 10; +const fdPaths = new Map(); vi.mock("node:fs", () => ({ + chmodSync: vi.fn(), existsSync: vi.fn(), readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), + openSync: vi.fn((path: string) => { + const fd = nextFd++; + fdPaths.set(fd, path); + return fd; + }), + closeSync: vi.fn(), + fsyncSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + statSync: vi.fn(() => ({ mtimeMs: Date.now() })), })); -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs"; const mockExistsSync = vi.mocked(existsSync); const mockReadFileSync = vi.mocked(readFileSync); const mockWriteFileSync = vi.mocked(writeFileSync); const mockMkdirSync = vi.mocked(mkdirSync); +const mockRenameSync = vi.mocked(renameSync); beforeEach(() => { vi.clearAllMocks(); + nextFd = 10; + fdPaths.clear(); }); describe("loadConfig", () => { @@ -80,16 +103,20 @@ describe("loadConfig", () => { expect(config!.ccusage_v20_migration_completed_at).toBe("2026-06-02T00:00:00.000Z"); }); - it("returns null when token is missing from config", () => { + it("throws a clear corruption error when token is missing", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(JSON.stringify({ username: "alice" })); - expect(loadConfig()).toBeNull(); + expect(() => loadConfig()).toThrow(ConfigCorruptError); }); - it("returns null on invalid JSON", () => { + it("throws a clear corruption error on invalid JSON", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue("not json"); - expect(loadConfig()).toBeNull(); + expect(() => loadConfig()).toThrow(ConfigCorruptError); + expect(mockRenameSync).toHaveBeenCalledWith( + expect.stringContaining("config.json"), + expect.stringContaining("config.json.corrupt-"), + ); }); }); @@ -114,10 +141,42 @@ describe("saveConfig", () => { const config = { token: "tok-abc", username: "alice", api_url: "https://straude.com" }; saveConfig(config); expect(mockWriteFileSync).toHaveBeenCalledWith( - expect.stringContaining("config.json"), + expect.any(Number), JSON.stringify(config, null, 2) + "\n", - { encoding: "utf-8", mode: 0o600 }, + "utf-8", ); + expect(mockRenameSync).toHaveBeenCalledWith( + expect.stringContaining("config.json."), + expect.stringContaining("config.json"), + ); + }); +}); + +describe("updateConfig", () => { + it("merges a targeted update with the latest config under the lock", () => { + mockExistsSync.mockImplementation((path) => String(path).endsWith("config.json")); + mockReadFileSync.mockReturnValue(JSON.stringify({ + token: "fresh-token", + username: "alice", + api_url: "https://straude.com", + auto_push: { + enabled: true, + time: "21:00", + scheduler: "launchd", + }, + })); + + const result = updateConfig((current) => ({ + ...current!, + last_push_date: "2026-07-23", + })); + + expect(result).toMatchObject({ + token: "fresh-token", + last_push_date: "2026-07-23", + auto_push: { enabled: true }, + }); + expect(mockRenameSync).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/__tests__/auto-push-logger.test.ts b/packages/cli/__tests__/auto-push-logger.test.ts index 0138558f..7c12c055 100644 --- a/packages/cli/__tests__/auto-push-logger.test.ts +++ b/packages/cli/__tests__/auto-push-logger.test.ts @@ -17,6 +17,12 @@ vi.mock("node:fs", () => ({ size: fileSizes[path] ?? Buffer.byteLength(fileStore[path] ?? "", "utf-8"), })), mkdirSync: vi.fn(), + openSync: vi.fn(() => 10), + readSync: vi.fn((_fd: number, buffer: Buffer, offset: number, length: number, position: number) => { + const content = Buffer.from(fileStore[AUTO_PUSH_LOG_FILE] ?? "", "utf-8"); + return content.copy(buffer, offset, position, position + length); + }), + closeSync: vi.fn(), })); // --------------------------------------------------------------------------- @@ -25,6 +31,7 @@ vi.mock("node:fs", () => ({ import { readLog, rotateLog } from "../src/lib/auto-push-logger.js"; import { AUTO_PUSH_LOG_FILE, AUTO_PUSH_LOG_MAX_BYTES } from "../src/config.js"; +import { readSync } from "node:fs"; // --------------------------------------------------------------------------- // Setup @@ -73,6 +80,18 @@ describe("readLog", () => { fileStore[AUTO_PUSH_LOG_FILE] = "line1\n\nline2\n\n"; expect(readLog()).toEqual(["line1", "line2"]); }); + + it("bounds the bytes read from a very large log", () => { + fileStore[AUTO_PUSH_LOG_FILE] = `${"x".repeat(300_000)}\nlast line\n`; + readLog(1); + expect(readSync).toHaveBeenCalledWith( + expect.any(Number), + expect.any(Buffer), + 0, + 256 * 1024, + expect.any(Number), + ); + }); }); describe("rotateLog", () => { diff --git a/packages/cli/__tests__/background-command.test.ts b/packages/cli/__tests__/background-command.test.ts new file mode 100644 index 00000000..c225f123 --- /dev/null +++ b/packages/cli/__tests__/background-command.test.ts @@ -0,0 +1,51 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + durableBackgroundInvocation, + exactBackgroundCommand, +} from "../src/lib/background-command.js"; + +const originalArgv1 = process.argv[1]; +const directories: string[] = []; + +afterEach(() => { + process.argv[1] = originalArgv1; + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("background CLI command", () => { + it("pins fallback execution to the current Straude version", () => { + process.argv[1] = "/tmp/vitest.mjs"; + expect(durableBackgroundInvocation()).toBeNull(); + expect(exactBackgroundCommand()).toBe( + "npx --yes straude@0.2.0 push --non-interactive", + ); + }); + + it("uses the absolute installed entrypoint when it is durable", () => { + const directory = mkdtempSync(join(tmpdir(), "straude-background-")); + directories.push(directory); + const script = join(directory, "packages", "cli", "dist", "index.js"); + mkdirSync(join(directory, "packages", "cli", "dist"), { recursive: true }); + writeFileSync(script, "#!/usr/bin/env node\n"); + process.argv[1] = script; + + const invocation = durableBackgroundInvocation(); + + expect(invocation).toMatchObject({ + executable: expect.stringMatching(/^[/A-Za-z:]/), + args: [expect.stringMatching(/packages\/cli\/dist\/index\.js$/)], + }); + expect(exactBackgroundCommand()).toContain("'push' '--non-interactive'"); + expect(exactBackgroundCommand()).not.toContain("straude@0.2.0"); + }); +}); diff --git a/packages/cli/__tests__/calendar.test.ts b/packages/cli/__tests__/calendar.test.ts new file mode 100644 index 00000000..14126ae6 --- /dev/null +++ b/packages/cli/__tests__/calendar.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + addCalendarDays, + assertCalendarDate, + calendarDaysBetween, + isCalendarDate, + listCalendarDates, + localCalendarDate, +} from "../src/lib/calendar.js"; + +describe("calendar helpers", () => { + it("rejects malformed and impossible dates", () => { + expect(isCalendarDate("2026-02-29")).toBe(false); + expect(isCalendarDate("2024-02-29")).toBe(true); + expect(isCalendarDate("2026-2-01")).toBe(false); + expect(() => assertCalendarDate("2026-13-01")).toThrow(/real calendar date/); + }); + + it("does calendar arithmetic without DST-length assumptions", () => { + expect(addCalendarDays("2026-03-08", 1)).toBe("2026-03-09"); + expect(calendarDaysBetween("2026-03-07", "2026-03-10")).toBe(3); + expect(listCalendarDates("2026-03-07", "2026-03-10")).toEqual([ + "2026-03-07", + "2026-03-08", + "2026-03-09", + "2026-03-10", + ]); + }); + + it("resolves the calendar date in the supplied IANA timezone", () => { + const instant = new Date("2026-01-01T01:00:00.000Z"); + expect(localCalendarDate(instant, "America/Vancouver")).toBe("2025-12-31"); + expect(localCalendarDate(instant, "Asia/Tokyo")).toBe("2026-01-01"); + }); +}); diff --git a/packages/cli/__tests__/ccusage.test.ts b/packages/cli/__tests__/ccusage.test.ts index 29fffd20..1878ecf0 100644 --- a/packages/cli/__tests__/ccusage.test.ts +++ b/packages/cli/__tests__/ccusage.test.ts @@ -36,7 +36,7 @@ const ALL_BUILT_IN_CCUSAGE_AGENTS = [ ].sort(); function row(overrides: Record = {}) { - return { + const base = { period: "2026-05-13", modelsUsed: ["gpt-5.2-codex"], inputTokens: 750, @@ -52,12 +52,36 @@ function row(overrides: Record = {}) { outputTokens: 125, cacheCreationTokens: 0, cacheReadTokens: 250, + totalTokens: 1125, cost: 0.00310625, }, ], metadata: { agents: ["codex"] }, - ...overrides, + agents: [ + { + agent: "codex", + modelsUsed: ["gpt-5.2-codex"], + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1200, + totalCost: 0.00310625, + modelBreakdowns: [ + { + modelName: "gpt-5.2-codex", + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1125, + cost: 0.00310625, + }, + ], + }, + ], }; + return { ...base, ...overrides }; } function rawOutput(rows: unknown[] = [row()]) { @@ -78,6 +102,27 @@ describe("parseCcusageOutput", () => { expect(parsed.data[0]).toEqual({ date: "2026-05-13", agents: ["codex"], + agentBreakdown: [{ + agent: "codex", + models: ["gpt-5.2-codex"], + inputTokens: 750, + outputTokens: 125, + reasoningOutputTokens: 75, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1200, + costUSD: 0.00310625, + modelBreakdown: [{ + model: "gpt-5.2-codex", + inputTokens: 750, + outputTokens: 125, + reasoningOutputTokens: 75, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1200, + cost_usd: 0.00310625, + }], + }], models: ["gpt-5.2-codex"], inputTokens: 750, outputTokens: 125, @@ -86,7 +131,16 @@ describe("parseCcusageOutput", () => { totalTokens: 1200, costUSD: 0.00310625, reasoningOutputTokens: 75, - modelBreakdown: [{ model: "gpt-5.2-codex", cost_usd: 0.00310625 }], + modelBreakdown: [{ + model: "gpt-5.2-codex", + inputTokens: 750, + outputTokens: 125, + reasoningOutputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1125, + cost_usd: 0.00310625, + }], }); expect(parsed.summary.totalReasoningOutputTokens).toBe(75); expect(parsed.agents).toEqual(["codex"]); @@ -114,6 +168,30 @@ describe("parseCcusageOutput", () => { { modelName: "gpt-5.2-codex", cost: 0.05 }, ], metadata: { agents: ["claude", "codex"] }, + agents: [ + { + agent: "claude", + modelsUsed: ["claude-sonnet-4-5-20250929"], + inputTokens: 600, + outputTokens: 200, + cacheCreationTokens: 100, + cacheReadTokens: 100, + totalTokens: 1000, + totalCost: 0.2, + modelBreakdowns: [{ modelName: "claude-sonnet-4-5-20250929", cost: 0.2 }], + }, + { + agent: "codex", + modelsUsed: ["gpt-5.2-codex"], + inputTokens: 600, + outputTokens: 200, + cacheCreationTokens: 0, + cacheReadTokens: 200, + totalTokens: 1100, + totalCost: 0.05, + modelBreakdowns: [{ modelName: "gpt-5.2-codex", cost: 0.05 }], + }, + ], }), ]), { version: "20.0.16" }); @@ -139,6 +217,22 @@ describe("parseCcusageOutput", () => { { modelName: "gemini-3-pro", cost: 0.00110625 }, ], metadata: { agents: ALL_BUILT_IN_CCUSAGE_AGENTS }, + agents: ALL_BUILT_IN_CCUSAGE_AGENTS.map((agent, index) => ({ + agent, + modelsUsed: index === 0 ? ["gpt-5.6", "gemini-3-pro"] : [], + inputTokens: index === 0 ? 750 : 0, + outputTokens: index === 0 ? 125 : 0, + cacheCreationTokens: 0, + cacheReadTokens: index === 0 ? 250 : 0, + totalTokens: index === 0 ? 1200 : 0, + totalCost: index === 0 ? 0.00310625 : 0, + modelBreakdowns: index === 0 + ? [ + { modelName: "gpt-5.6", cost: 0.002 }, + { modelName: "gemini-3-pro", cost: 0.00110625 }, + ] + : [], + })), }), ]), { version: "20.0.16" }); @@ -172,6 +266,43 @@ describe("parseCcusageOutput", () => { ]))).toThrow(/inputTokens must be non-negative/); }); + it("rejects total token mismatches instead of clamping them", () => { + expect(() => parseCcusageOutput(rawOutput([ + row({ totalTokens: 1 }), + ]))).toThrow(/below its token categories/); + }); + + it("rejects agent totals that disagree with the daily aggregate", () => { + expect(() => parseCcusageOutput(rawOutput([ + row({ + agents: [{ + ...(row().agents as Array>)[0], + inputTokens: 749, + }], + }), + ]))).toThrow(/agents breakdown does not match daily totals/); + }); + + it("rejects model cost differences above half a cent", () => { + expect(() => parseCcusageOutput(rawOutput([ + row({ + totalCost: 0.02, + }), + ]))).toThrow(/cost differs from its model breakdown/); + }); + + it("rejects explicit missing pricing markers", () => { + expect(() => parseCcusageOutput(rawOutput([ + row({ + modelBreakdowns: [{ + modelName: "gpt-5.2-codex", + cost: 0, + missingPricing: true, + }], + }), + ]))).toThrow(/did not produce live pricing/); + }); + it("returns empty output for an empty ccusage daily array", () => { const parsed = parseCcusageOutput(rawOutput([]), { version: "20.0.16" }); expect(parsed.data).toEqual([]); @@ -197,14 +328,30 @@ describe("version and execution", () => { cb(null, rawOutput(), ""); }); - const collected = await collectCcusageUsageAsync("20260513", "20260513", 10_000); + const collected = await collectCcusageUsageAsync("20260513", "20260513", 10_000, { + timezone: "UTC", + }); expect(collected.raw).toBe(rawOutput()); expect(execFileMock).toHaveBeenCalledTimes(1); expect(execFileMock).toHaveBeenCalledWith( "/bundled/ccusage", - ["daily", "--json", "--since", "20260513", "--until", "20260513", "--no-offline"], - expect.objectContaining({ shell: false }), + [ + "daily", + "--json", + "--since", + "20260513", + "--until", + "20260513", + "--timezone", + "UTC", + "--by-agent", + "--no-offline", + ], + expect.objectContaining({ + shell: false, + env: expect.objectContaining({ LOG_LEVEL: "4" }), + }), expect.any(Function), ); expect(collected.collector.pricing_mode).toBe("online"); @@ -218,11 +365,23 @@ describe("version and execution", () => { const collected = await collectCcusageUsageAsync("20260513", "20260513", 10_000, { pricingMode: "offline", + timezone: "UTC", }); expect(execFileMock).toHaveBeenCalledWith( "/bundled/ccusage", - ["daily", "--json", "--since", "20260513", "--until", "20260513", "--offline"], + [ + "daily", + "--json", + "--since", + "20260513", + "--until", + "20260513", + "--timezone", + "UTC", + "--by-agent", + "--offline", + ], expect.objectContaining({ shell: false }), expect.any(Function), ); @@ -233,15 +392,19 @@ describe("version and execution", () => { _setCcusageCommandForTests({ cmd: "/bundled/ccusage", args: [], version: "20.0.15" }); await expect(collectCcusageUsageAsync("20260513", "20260513")).rejects.toThrow( - /requires ccusage >=20\.0\.16/, + /fixture-verified ccusage 20\.0\.16/, ); }); - it("falls back to online pricing when offline pricing is incomplete", async () => { + it("retries live pricing fallback failures at most three times", async () => { execFileMock .mockImplementationOnce((...args: unknown[]) => { const cb = args.at(-1) as (err: Error | null, stdout: string, stderr: string) => void; - cb(null, rawOutput(), "Missing pricing for model gpt-5.2-codex"); + cb(null, rawOutput(), "WARN Failed to fetch LiteLLM pricing (timeout); using embedded pricing."); + }) + .mockImplementationOnce((...args: unknown[]) => { + const cb = args.at(-1) as (err: Error | null, stdout: string, stderr: string) => void; + cb(null, rawOutput(), "WARN Failed to fetch LiteLLM pricing (timeout); using embedded pricing."); }) .mockImplementationOnce((...args: unknown[]) => { const cb = args.at(-1) as (err: Error | null, stdout: string, stderr: string) => void; @@ -249,26 +412,30 @@ describe("version and execution", () => { }); const collected = await collectCcusageUsageAsync("20260513", "20260513", undefined, { - pricingMode: "offline", + pricingMode: "online", + timezone: "UTC", + sleep: () => Promise.resolve(), + random: () => 0, }); - expect(execFileMock).toHaveBeenCalledTimes(2); - expect(execFileMock.mock.calls[0]![1]).toContain("--offline"); - expect(execFileMock.mock.calls[1]![1]).toContain("--no-offline"); + expect(execFileMock).toHaveBeenCalledTimes(3); expect(collected.collector.pricing_mode).toBe("online"); }); - it("fails safely when the selected pricing mode remains incomplete", async () => { - execFileMock.mockImplementationOnce((...args: unknown[]) => { + it("fails safely when live pricing remains incomplete", async () => { + execFileMock.mockImplementation((...args: unknown[]) => { const cb = args.at(-1) as (err: Error | null, stdout: string, stderr: string) => void; cb(null, rawOutput(), "Missing pricing for model gpt-5.2-codex"); }); await expect(collectCcusageUsageAsync("20260513", "20260513", undefined, { - pricingMode: "offline", - allowOnlineFallback: false, + pricingMode: "online", + timezone: "UTC", + sleep: () => Promise.resolve(), + random: () => 0, })).rejects.toThrow( - /fully priced offline cost data/, + /fully priced online cost data/, ); + expect(execFileMock).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/cli/__tests__/commands/auto.test.ts b/packages/cli/__tests__/commands/auto.test.ts index 10c58fb7..9e4dc21f 100644 --- a/packages/cli/__tests__/commands/auto.test.ts +++ b/packages/cli/__tests__/commands/auto.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("../../src/lib/auth.js", () => ({ loadConfig: vi.fn(), saveConfig: vi.fn(), + updateConfig: vi.fn(), })); vi.mock("../../src/lib/scheduler.js", () => ({ @@ -31,7 +32,7 @@ vi.mock("../../src/lib/auto-push-logger.js", () => ({ // --------------------------------------------------------------------------- import { enableAutoPush, disableAutoPush, autoCommand } from "../../src/commands/auto.js"; -import { loadConfig, saveConfig } from "../../src/lib/auth.js"; +import { loadConfig, updateConfig } from "../../src/lib/auth.js"; import { detectScheduler, installScheduler, @@ -47,7 +48,7 @@ import { readLog } from "../../src/lib/auto-push-logger.js"; import type { StraudeConfig } from "../../src/lib/auth.js"; const mockLoadConfig = vi.mocked(loadConfig); -const mockSaveConfig = vi.mocked(saveConfig); +const mockUpdateConfig = vi.mocked(updateConfig); const mockDetectScheduler = vi.mocked(detectScheduler); const mockInstallScheduler = vi.mocked(installScheduler); const mockUninstallScheduler = vi.mocked(uninstallScheduler); @@ -81,6 +82,7 @@ function makeConfig(overrides: Partial = {}): StraudeConfig { beforeEach(() => { vi.clearAllMocks(); mockDetectScheduler.mockReturnValue("launchd"); + mockUpdateConfig.mockImplementation((updater) => updater(null)); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(process, "exit").mockImplementation((code) => { @@ -102,7 +104,7 @@ describe("enableAutoPush — scheduler", () => { enableAutoPush(config); expect(mockInstallScheduler).toHaveBeenCalledWith("21:00", "launchd"); - expect(mockSaveConfig).toHaveBeenCalledWith( + expect(mockUpdateConfig.mock.results[0]!.value).toEqual( expect.objectContaining({ auto_push: expect.objectContaining({ enabled: true, time: "21:00", scheduler: "launchd", mechanism: "scheduler" }), }), @@ -155,6 +157,24 @@ describe("enableAutoPush — scheduler", () => { expect(mockInstallScheduler).toHaveBeenCalledWith("21:00", "cron"); }); + + it("restores the previous scheduler when config persistence fails", () => { + const config = makeConfig({ + auto_push: { + enabled: true, + time: "09:00", + scheduler: "launchd", + mechanism: "scheduler", + }, + }); + mockUpdateConfig.mockImplementation(() => { + throw new Error("disk full"); + }); + + expect(() => enableAutoPush(config, "21:00")).toThrow("disk full"); + expect(mockInstallScheduler).toHaveBeenNthCalledWith(1, "21:00", "launchd"); + expect(mockInstallScheduler).toHaveBeenNthCalledWith(2, "09:00", "launchd"); + }); }); // --------------------------------------------------------------------------- @@ -168,7 +188,7 @@ describe("enableAutoPush — hooks", () => { expect(mockInstallClaudeCodeHook).toHaveBeenCalled(); expect(mockInstallScheduler).not.toHaveBeenCalled(); - expect(mockSaveConfig).toHaveBeenCalledWith( + expect(mockUpdateConfig.mock.results[0]!.value).toEqual( expect.objectContaining({ auto_push: expect.objectContaining({ enabled: true, mechanism: "hooks" }), }), @@ -212,7 +232,7 @@ describe("disableAutoPush", () => { disableAutoPush(config); expect(mockUninstallScheduler).toHaveBeenCalledWith("launchd"); - expect(mockSaveConfig).toHaveBeenCalledWith( + expect(mockUpdateConfig.mock.results[0]!.value).toEqual( expect.not.objectContaining({ auto_push: expect.anything() }), ); expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Auto-push disabled")); diff --git a/packages/cli/__tests__/commands/devices.test.ts b/packages/cli/__tests__/commands/devices.test.ts new file mode 100644 index 00000000..1c3a5712 --- /dev/null +++ b/packages/cli/__tests__/commands/devices.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { apiRequestMock, loadConfigMock } = vi.hoisted(() => ({ + apiRequestMock: vi.fn(), + loadConfigMock: vi.fn(), +})); + +vi.mock("../../src/lib/api.js", async (importOriginal) => ({ + ...await importOriginal(), + apiRequest: apiRequestMock, +})); +vi.mock("../../src/lib/auth.js", () => ({ loadConfig: loadConfigMock })); + +import { devicesCommand } from "../../src/commands/devices.js"; + +const config = { token: "tok", username: "alice", api_url: "https://straude.com" }; +const candidateId = "11111111-1111-4111-8111-111111111111"; + +beforeEach(() => { + vi.clearAllMocks(); + loadConfigMock.mockReturnValue(config); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +describe("devicesCommand", () => { + it("lists proof candidates with explicit resolution commands", async () => { + apiRequestMock.mockResolvedValue({ + candidates: [{ + id: candidateId, + device_id_a: "22222222-2222-4222-8222-222222222222", + device_id_b: "33333333-3333-4333-8333-333333333333", + normalized_hostname: "work-laptop", + overlap_dates: ["2026-07-21", "2026-07-22"], + status: "pending", + created_at: "2026-07-23T00:00:00.000Z", + }], + }); + + expect(await devicesCommand(null, null)).toBe(0); + expect(apiRequestMock).toHaveBeenCalledWith( + config, + "/api/usage/devices", + expect.objectContaining({ timeoutMs: 15_000 }), + ); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining(`devices merge ${candidateId}`)); + }); + + it("submits an explicit keep-separate decision", async () => { + apiRequestMock.mockResolvedValue({ + candidate: { + id: candidateId, + status: "resolved", + decision: "keep_separate", + }, + }); + + expect(await devicesCommand("keep-separate", candidateId)).toBe(0); + const request = apiRequestMock.mock.calls[0]![2]; + expect(JSON.parse(request.body)).toEqual({ + candidate_id: candidateId, + decision: "keep_separate", + }); + }); + + it("returns AUTH_REQUIRED without making a request", async () => { + loadConfigMock.mockReturnValue(null); + expect(await devicesCommand(null, null)).toBe(2); + expect(apiRequestMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/__tests__/commands/login.test.ts b/packages/cli/__tests__/commands/login.test.ts index 1caac53a..c77392d1 100644 --- a/packages/cli/__tests__/commands/login.test.ts +++ b/packages/cli/__tests__/commands/login.test.ts @@ -2,11 +2,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("../../src/lib/api.js", () => ({ apiRequestNoAuth: vi.fn(), + ApiHttpError: class ApiHttpError extends Error { + status: number; + retryAfterMs: number | null; + retryable: boolean; + constructor(message: string, status: number, retryAfterMs: number | null = null) { + super(message); + this.status = status; + this.retryAfterMs = retryAfterMs; + this.retryable = [408, 425, 429, 500, 502, 503, 504].includes(status); + } + }, })); vi.mock("../../src/lib/auth.js", () => ({ loadConfig: vi.fn(() => null), saveConfig: vi.fn(), + updateConfig: vi.fn(), })); vi.mock("../../src/config.js", async (importOriginal) => { @@ -25,30 +37,24 @@ vi.mock("node:child_process", () => ({ })), })); -import { loginCommand } from "../../src/commands/login.js"; -import { apiRequestNoAuth } from "../../src/lib/api.js"; -import { loadConfig, saveConfig } from "../../src/lib/auth.js"; +vi.mock("../../src/lib/prompt.js", () => ({ + isInteractive: vi.fn(() => false), +})); + +import { LoginCommandError, loginCommand } from "../../src/commands/login.js"; +import { ApiHttpError, apiRequestNoAuth } from "../../src/lib/api.js"; +import { loadConfig, updateConfig } from "../../src/lib/auth.js"; const mockApiRequestNoAuth = vi.mocked(apiRequestNoAuth); const mockLoadConfig = vi.mocked(loadConfig); -const mockSaveConfig = vi.mocked(saveConfig); - -class ExitError extends Error { - code: number; - constructor(code: number) { - super(`process.exit(${code})`); - this.code = code; - } -} +const mockUpdateConfig = vi.mocked(updateConfig); beforeEach(() => { vi.clearAllMocks(); + mockUpdateConfig.mockImplementation((updater) => updater(mockLoadConfig())); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(process.stdout, "write").mockImplementation(() => true); - vi.spyOn(process, "exit").mockImplementation((code) => { - throw new ExitError(code as number); - }); }); afterEach(() => { @@ -56,6 +62,13 @@ afterEach(() => { }); describe("loginCommand", () => { + it("fails fast when a background caller requires interactive auth", async () => { + await expect( + loginCommand("https://straude.com", { requireInteractive: true }), + ).rejects.toThrow(/interactive terminal/); + expect(mockApiRequestNoAuth).not.toHaveBeenCalled(); + }); + it("opens browser and prints verify URL", async () => { mockApiRequestNoAuth .mockResolvedValueOnce({ @@ -70,13 +83,16 @@ describe("loginCommand", () => { expect(console.log).toHaveBeenCalledWith( expect.stringContaining("https://straude.com/cli/verify?code=ABCD-EFGH"), ); - expect(mockSaveConfig).toHaveBeenCalledWith({ + expect(mockApiRequestNoAuth).toHaveBeenNthCalledWith( + 1, + "https://straude.com", + "/api/auth/cli/init", + expect.objectContaining({ timeoutMs: 10_000 }), + ); + expect(mockUpdateConfig.mock.results[0]!.value).toEqual({ token: "tok-123", username: "alice", api_url: "https://straude.com", - last_push_date: undefined, - device_id: undefined, - device_name: undefined, }); }); @@ -93,7 +109,7 @@ describe("loginCommand", () => { await loginCommand("https://straude.com"); - expect(mockSaveConfig).toHaveBeenCalledWith( + expect(mockUpdateConfig.mock.results[0]!.value).toEqual( expect.objectContaining({ token: "tok-456", username: "bob" }), ); expect(mockApiRequestNoAuth).toHaveBeenCalledWith( @@ -114,18 +130,33 @@ describe("loginCommand", () => { }) .mockResolvedValueOnce({ status: "expired" }); - await expect(loginCommand("https://straude.com")).rejects.toThrow(ExitError); + await expect(loginCommand("https://straude.com")).rejects.toThrow( + new LoginCommandError("Login code expired. Please try again."), + ); + expect(mockUpdateConfig).not.toHaveBeenCalled(); + }); + + it("stops polling on a permanent HTTP error", async () => { + mockApiRequestNoAuth + .mockResolvedValueOnce({ + code: "ABCD-EFGH", + verify_url: "https://straude.com/cli/verify?code=ABCD-EFGH", + poll_secret: "poll-secret-123", + }) + .mockRejectedValueOnce(new ApiHttpError("invalid poll secret", 400)); - expect(process.exit).toHaveBeenCalledWith(1); - expect(mockSaveConfig).not.toHaveBeenCalled(); + await expect(loginCommand("https://straude.com")).rejects.toThrow( + /invalid poll secret/, + ); + expect(mockApiRequestNoAuth).toHaveBeenCalledTimes(2); }); it("handles init failure", async () => { mockApiRequestNoAuth.mockRejectedValueOnce(new Error("Network error")); - await expect(loginCommand("https://straude.com")).rejects.toThrow(ExitError); - - expect(process.exit).toHaveBeenCalledWith(1); + await expect(loginCommand("https://straude.com")).rejects.toThrow( + /Failed to start login: Network error/, + ); }); it("rejects init responses without poll_secret", async () => { @@ -134,10 +165,10 @@ describe("loginCommand", () => { verify_url: "https://straude.com/cli/verify?code=ABCD-EFGH", }); - await expect(loginCommand("https://straude.com")).rejects.toThrow(ExitError); - - expect(process.exit).toHaveBeenCalledWith(1); - expect(mockSaveConfig).not.toHaveBeenCalled(); + await expect(loginCommand("https://straude.com")).rejects.toThrow( + /server did not return a poll secret/, + ); + expect(mockUpdateConfig).not.toHaveBeenCalled(); }); it("preserves config fields when re-logging into the same account", async () => { @@ -148,6 +179,13 @@ describe("loginCommand", () => { last_push_date: "2026-03-20", device_id: "dev-123", device_name: "my-laptop", + ccusage_v20_migration_completed_at: "2026-03-21T00:00:00.000Z", + auto_push: { + enabled: true, + time: "21:00", + scheduler: "launchd", + mechanism: "scheduler", + }, }); mockApiRequestNoAuth .mockResolvedValueOnce({ @@ -159,13 +197,22 @@ describe("loginCommand", () => { await loginCommand("https://straude.com"); - expect(mockSaveConfig).toHaveBeenCalledWith({ + expect(mockUpdateConfig).toHaveBeenCalled(); + const saved = mockUpdateConfig.mock.results[0]!.value; + expect(saved).toEqual({ token: "new-tok", username: "alice", api_url: "https://straude.com", last_push_date: "2026-03-20", device_id: "dev-123", device_name: "my-laptop", + ccusage_v20_migration_completed_at: "2026-03-21T00:00:00.000Z", + auto_push: { + enabled: true, + time: "21:00", + scheduler: "launchd", + mechanism: "scheduler", + }, }); }); @@ -188,13 +235,10 @@ describe("loginCommand", () => { await loginCommand("https://straude.com"); - expect(mockSaveConfig).toHaveBeenCalledWith({ + expect(mockUpdateConfig.mock.results[0]!.value).toEqual({ token: "new-tok", username: "bob", api_url: "https://straude.com", - last_push_date: undefined, - device_id: undefined, - device_name: undefined, }); }); @@ -217,13 +261,10 @@ describe("loginCommand", () => { await loginCommand("https://other.com"); - expect(mockSaveConfig).toHaveBeenCalledWith({ + expect(mockUpdateConfig.mock.results[0]!.value).toEqual({ token: "new-tok", username: "alice", api_url: "https://other.com", - last_push_date: undefined, - device_id: undefined, - device_name: undefined, }); }); }); diff --git a/packages/cli/__tests__/commands/push.test.ts b/packages/cli/__tests__/commands/push.test.ts index 5671c3d3..ec513fa5 100644 --- a/packages/cli/__tests__/commands/push.test.ts +++ b/packages/cli/__tests__/commands/push.test.ts @@ -1,170 +1,238 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + apiRequestMock, + collectMock, + loadConfigMock, + updateConfigMock, + loginMock, + pendingBatches, + upsertBatchMock, + removeBatchMock, + releaseMock, + acknowledgeQueuedDatesMock, +} = vi.hoisted(() => ({ + apiRequestMock: vi.fn(), + collectMock: vi.fn(), + loadConfigMock: vi.fn(), + updateConfigMock: vi.fn(), + loginMock: vi.fn(), + pendingBatches: [] as unknown[], + upsertBatchMock: vi.fn(), + removeBatchMock: vi.fn(), + releaseMock: vi.fn(), + acknowledgeQueuedDatesMock: vi.fn(), +})); vi.mock("../../src/lib/auth.js", () => ({ - loadConfig: vi.fn(), - updateLastPushDate: vi.fn(), - saveConfig: vi.fn(), + loadConfig: loadConfigMock, + updateConfig: updateConfigMock, })); vi.mock("../../src/commands/login.js", () => ({ - loginCommand: vi.fn(), + loginCommand: loginMock, + NonInteractiveLoginError: class NonInteractiveLoginError extends Error {}, })); -vi.mock("../../src/lib/api.js", () => ({ - apiRequest: vi.fn(), +vi.mock("../../src/lib/api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, apiRequest: apiRequestMock }; +}); + +vi.mock("../../src/lib/ccusage.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + collectCcusageUsageAsync: collectMock, + resolveLocalTimezone: () => "America/Vancouver", + }; +}); + +vi.mock("../../src/lib/machine-id.js", () => ({ + getInstallationId: () => "11111111-1111-4111-8111-111111111111", + getDistinctId: () => "alice", })); -vi.mock("../../src/lib/ccusage.js", () => ({ - CCUSAGE_CLAUDE_COLLECTOR: "ccusage-claude-v20", - CCUSAGE_CODEX_COLLECTOR: "ccusage-codex-v20", - CCUSAGE_DEFAULT_PRICING_MODE: "online", - collectCcusageUsageAsync: vi.fn(), +vi.mock("../../src/lib/prompt.js", () => ({ + isInteractive: () => true, +})); + +vi.mock("../../src/lib/sync-state.js", () => ({ + acquireSyncLease: vi.fn(async () => ({ + queuedDates: [], + acknowledgeQueuedDates: acknowledgeQueuedDatesMock, + release: releaseMock, + })), + loadPendingBatches: vi.fn(() => [...pendingBatches]), + upsertPendingBatch: upsertBatchMock, + removePendingBatch: removeBatchMock, })); vi.mock("../../src/lib/telemetry.js", () => ({ reportUsagePushFailed: vi.fn(), - shutdownTelemetryWithTimeout: vi.fn(() => Promise.resolve(0)), + shutdownTelemetryWithTimeout: vi.fn(async () => 0), TELEMETRY_SHUTDOWN_TIMEOUT_MS: 150, errorMessage: (error: unknown) => error instanceof Error ? error.message : String(error), })); vi.mock("../../src/lib/posthog.js", () => ({ - posthog: { - capture: vi.fn(), - _shutdown: vi.fn(() => Promise.resolve()), - }, + posthog: { capture: vi.fn() }, })); vi.mock("ink", () => ({ - render: vi.fn(() => ({ - waitUntilExit: () => Promise.resolve(), - unmount: vi.fn(), - })), + render: vi.fn(() => ({ waitUntilExit: () => Promise.resolve() })), })); -import { createHash } from "node:crypto"; -import { pushCommand } from "../../src/commands/push.js"; -import { loadConfig, saveConfig, updateLastPushDate } from "../../src/lib/auth.js"; -import { loginCommand } from "../../src/commands/login.js"; -import { apiRequest } from "../../src/lib/api.js"; -import { collectCcusageUsageAsync } from "../../src/lib/ccusage.js"; -import { reportUsagePushFailed, shutdownTelemetryWithTimeout } from "../../src/lib/telemetry.js"; -import { render } from "ink"; - -const mockLoadConfig = vi.mocked(loadConfig); -const mockSaveConfig = vi.mocked(saveConfig); -const mockUpdateLastPushDate = vi.mocked(updateLastPushDate); -const mockLoginCommand = vi.mocked(loginCommand); -const mockApiRequest = vi.mocked(apiRequest); -const mockCollectCcusageUsageAsync = vi.mocked(collectCcusageUsageAsync); -const mockReportUsagePushFailed = vi.mocked(reportUsagePushFailed); -const mockShutdownTelemetry = vi.mocked(shutdownTelemetryWithTimeout); - -const fakeConfig = { - token: "tok", - username: "alice", - api_url: "https://straude.com", - device_id: "device-1", - device_name: "work-laptop", - ccusage_v20_migration_completed_at: "2026-05-01T00:00:00.000Z", -}; - -class ExitError extends Error { - code: number; - constructor(code: number) { - super(`process.exit(${code})`); - this.code = code; - } -} +import { + CLI_EXIT, + pushCommand, +} from "../../src/commands/push.js"; +import { PricingUnavailableError } from "../../src/lib/ccusage.js"; -function compact(date: Date): string { - return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`; -} +const today = "2026-03-13"; +const priorDevice = "22222222-2222-4222-8222-222222222222"; -function todayStr(): string { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; -} - -function daysAgoStr(days: number): string { - const d = new Date(); - d.setDate(d.getDate() - days); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +function config(overrides: Record = {}) { + return { + token: "tok", + username: "alice", + api_url: "https://straude.com", + device_id: priorDevice, + device_name: "work-laptop", + last_push_date: "2026-03-12", + usage_protocol_v2_migration_completed_at: "2026-03-01T00:00:00.000Z", + ...overrides, + }; } -function usageEntry(date = todayStr(), overrides: Record = {}) { +function usageEntry(date = today) { return { date, - agents: ["claude", "codex"], - models: ["claude-sonnet-4-5-20250929", "gpt-5.2-codex"], - inputTokens: 1200, - outputTokens: 400, - reasoningOutputTokens: 100, - cacheCreationTokens: 100, - cacheReadTokens: 300, - totalTokens: 2100, - costUSD: 0.25, - modelBreakdown: [ - { model: "claude-sonnet-4-5-20250929", cost_usd: 0.2 }, - { model: "gpt-5.2-codex", cost_usd: 0.05 }, - ], - ...overrides, + agents: ["codex"], + agentBreakdown: [{ + agent: "codex", + models: ["gpt-5"], + inputTokens: 10, + outputTokens: 3, + reasoningOutputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalTokens: 20, + costUSD: 0.02, + modelBreakdown: [{ + model: "gpt-5", + inputTokens: 10, + outputTokens: 3, + reasoningOutputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalTokens: 20, + cost_usd: 0.02, + }], + }], + models: ["gpt-5"], + inputTokens: 10, + outputTokens: 3, + reasoningOutputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalTokens: 20, + costUSD: 0.02, + modelBreakdown: [{ + model: "gpt-5", + inputTokens: 10, + outputTokens: 3, + reasoningOutputTokens: 2, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalTokens: 20, + cost_usd: 0.02, + }], }; } -function ccusageOutput(entries = [usageEntry()], overrides: Record = {}) { +function collected(entries = [usageEntry()]) { return { data: entries, summary: { - totalInputTokens: entries.reduce((sum, entry) => sum + entry.inputTokens, 0), - totalOutputTokens: entries.reduce((sum, entry) => sum + entry.outputTokens, 0), - totalReasoningOutputTokens: entries.reduce((sum, entry) => sum + (entry.reasoningOutputTokens ?? 0), 0), - totalCacheCreationTokens: entries.reduce((sum, entry) => sum + entry.cacheCreationTokens, 0), - totalCacheReadTokens: entries.reduce((sum, entry) => sum + entry.cacheReadTokens, 0), - totalTokens: entries.reduce((sum, entry) => sum + entry.totalTokens, 0), - totalCostUSD: entries.reduce((sum, entry) => sum + entry.costUSD, 0), + totalInputTokens: 10, + totalOutputTokens: 3, + totalReasoningOutputTokens: 2, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 5, + totalTokens: 20, + totalCostUSD: 0.02, }, - agents: ["claude", "codex"], + agents: ["codex"], collector: { - claude: "ccusage-claude-v20", codex: "ccusage-codex-v20", ccusage_version: "20.0.16", - ccusage_agents: ["claude", "codex"], + ccusage_agents: ["codex"], pricing_mode: "online", }, version: "20.0.16", - raw: JSON.stringify({ daily: entries }), + raw: "{}", stderr: "", - ...overrides, + }; +} + +function outcome( + requestId: string, + date: string, + status: "committed" | "unchanged" | "retryable_error" = "committed", +) { + return { + request_id: requestId, + outcomes: [{ + date, + status, + ...(status !== "retryable_error" + ? { + result: { + usage_id: `usage-${date}`, + post_id: `post-${date}`, + post_url: `https://straude.com/post/${date}`, + action: "created", + }, + } + : {}), + ...(status === "retryable_error" + ? { error: { code: "TRANSIENT", message: "retry" } } + : {}), + }], }; } beforeEach(() => { - vi.useFakeTimers({ now: new Date("2026-03-13T12:00:00Z"), toFake: ["Date"] }); + vi.useFakeTimers({ now: new Date("2026-03-13T20:00:00.000Z"), toFake: ["Date"] }); vi.clearAllMocks(); - mockLoadConfig.mockReturnValue({ ...fakeConfig }); - mockCollectCcusageUsageAsync.mockResolvedValue(ccusageOutput() as never); - mockApiRequest.mockImplementation(async (_config, path) => { - if (path === "/api/cli/dashboard") { - throw new Error("dashboard not mocked"); - } - return { - results: [ - { - date: todayStr(), - usage_id: "u-1", - post_id: "p-1", - post_url: "https://straude.com/post/p-1", - action: "created", - }, - ], - }; + pendingBatches.splice(0); + const initial = config(); + loadConfigMock.mockReturnValue(initial); + updateConfigMock.mockImplementation((updater) => updater(initial)); + collectMock.mockResolvedValue(collected()); + upsertBatchMock.mockImplementation((batch) => { + const index = pendingBatches.findIndex( + (candidate: { request: { request_id: string } }) => + candidate.request.request_id === batch.request.request_id, + ); + if (index === -1) pendingBatches.push(batch); + else pendingBatches[index] = batch; + }); + removeBatchMock.mockImplementation((requestId) => { + const index = pendingBatches.findIndex( + (candidate: { request: { request_id: string } }) => + candidate.request.request_id === requestId, + ); + if (index >= 0) pendingBatches.splice(index, 1); + }); + apiRequestMock.mockImplementation(async (_config, path, options) => { + if (path === "/api/cli/dashboard") throw new Error("dashboard unavailable"); + const body = JSON.parse(options.body); + return outcome(body.request_id, body.entries[0].date); }); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); - vi.spyOn(process, "exit").mockImplementation((code) => { - throw new ExitError(code as number); - }); }); afterEach(() => { @@ -172,293 +240,263 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("pushCommand", () => { - it("submits unified ccusage rows with v20 collector metadata", async () => { - await pushCommand({}); - - expect(mockCollectCcusageUsageAsync).toHaveBeenCalledWith( - expect.any(String), - expect.any(String), - undefined, - { pricingMode: "online" }, - ); - expect(mockApiRequest).toHaveBeenCalledWith( - expect.objectContaining(fakeConfig), - "/api/usage/submit", - expect.objectContaining({ method: "POST" }), - ); - - const submitCall = mockApiRequest.mock.calls.find(([, path]) => path === "/api/usage/submit")!; - const body = JSON.parse((submitCall[2] as { body: string }).body); - expect(body.entries).toHaveLength(1); - expect(body.entries[0].data.reasoningOutputTokens).toBe(100); - expect(body.collector).toEqual({ - claude: "ccusage-claude-v20", - codex: "ccusage-codex-v20", - ccusage_version: "20.0.16", - ccusage_agents: ["claude", "codex"], - pricing_mode: "online", +describe("pushCommand v2", () => { + it("persists and submits a validated per-agent v2 request", async () => { + const exitCode = await pushCommand({}); + + expect(exitCode).toBe(CLI_EXIT.OK); + expect(upsertBatchMock).toHaveBeenCalledBefore(apiRequestMock); + const submitCall = apiRequestMock.mock.calls.find(([, path]) => path === "/api/usage/submit")!; + const body = JSON.parse(submitCall[2].body); + expect(body).toMatchObject({ + protocol_version: 2, + timezone: "America/Vancouver", + installation: { + id: "11111111-1111-4111-8111-111111111111", + previous_device_id: priorDevice, + name: "work-laptop", + }, + collector: { + name: "ccusage", + version: "20.0.16", + pricing_mode: "online", + }, }); - expect(body.device_id).toBe("device-1"); - expect(body.device_name).toBe("work-laptop"); - }); - - it("hashes the ccusage v20 raw payload and collector run metadata", async () => { - const output = ccusageOutput([usageEntry()], { - raw: '{"daily":[{"period":"2026-03-13"}]}', + expect(body.entries[0].agents[0]).toMatchObject({ + agent: "codex", + reasoning_output_tokens: 2, + total_tokens: 20, + model_breakdown: [{ model: "gpt-5", total_tokens: 20 }], + }); + expect(body.entries[0].content_hash).toMatch(/^[a-f0-9]{64}$/); + expect(submitCall[2].headers).toEqual({ + "X-Straude-CLI-Version": "0.2.0", + "X-Straude-Retry-Attempt": "0", }); - mockCollectCcusageUsageAsync.mockResolvedValue(output as never); - - await pushCommand({}); - - const submitCall = mockApiRequest.mock.calls.find(([, path]) => path === "/api/usage/submit")!; - const body = JSON.parse((submitCall[2] as { body: string }).body); - const [since, until] = mockCollectCcusageUsageAsync.mock.calls[0]!; - const concreteHash = createHash("sha256").update(JSON.stringify({ - collector: "ccusage-v20", - version: output.version, - agents: output.agents, - since, - until, - raw: output.raw, - })).digest("hex"); - expect(body.hash).toBe(concreteHash); + expect(removeBatchMock).toHaveBeenCalledWith(body.request_id); + expect(updateConfigMock).toHaveBeenCalled(); + expect(releaseMock).toHaveBeenCalled(); }); - it("respects explicit --days even when the migration backfill marker is missing", async () => { - const today = new Date(); - const twoDaysAgo = new Date(today); - twoDaysAgo.setDate(today.getDate() - 2); - mockLoadConfig.mockReturnValue({ - ...fakeConfig, - ccusage_v20_migration_completed_at: undefined, - last_push_date: daysAgoStr(2), + it("retries only failed dates with the same request id", async () => { + collectMock.mockResolvedValue(collected([ + usageEntry("2026-03-12"), + usageEntry("2026-03-13"), + ])); + let call = 0; + apiRequestMock.mockImplementation(async (_config, path, options) => { + if (path === "/api/cli/dashboard") throw new Error("dashboard unavailable"); + const body = JSON.parse(options.body); + call += 1; + if (call === 1) { + return { + request_id: body.request_id, + outcomes: [ + outcome(body.request_id, "2026-03-12").outcomes[0], + outcome(body.request_id, "2026-03-13", "retryable_error").outcomes[0], + ], + }; + } + return outcome(body.request_id, "2026-03-13", "unchanged"); }); - await pushCommand({ days: 3 }); + const exitCode = await pushCommand({ days: 2 }); - expect(mockCollectCcusageUsageAsync).toHaveBeenCalledWith( - compact(twoDaysAgo), - compact(today), - undefined, - { pricingMode: "online" }, - ); - expect(mockUpdateLastPushDate).toHaveBeenCalledWith(todayStr()); - expect(mockSaveConfig).not.toHaveBeenCalledWith(expect.objectContaining({ - ccusage_v20_migration_completed_at: expect.any(String), - })); + expect(exitCode).toBe(CLI_EXIT.OK); + const submitCalls = apiRequestMock.mock.calls.filter(([, path]) => path === "/api/usage/submit"); + const first = JSON.parse(submitCalls[0]![2].body); + const second = JSON.parse(submitCalls[1]![2].body); + expect(second.request_id).toBe(first.request_id); + expect(second.entries.map((entry: { date: string }) => entry.date)).toEqual(["2026-03-13"]); + expect(pendingBatches).toEqual([]); }); - it("marks the ccusage v20 migration complete after an explicit 30-day backfill", async () => { - const today = new Date(); - const twentyNineDaysAgo = new Date(today); - twentyNineDaysAgo.setDate(today.getDate() - 29); - mockLoadConfig.mockReturnValue({ - ...fakeConfig, - ccusage_v20_migration_completed_at: undefined, - last_push_date: daysAgoStr(2), + it("keeps the committed outbox entry when watermark persistence crashes", async () => { + updateConfigMock.mockImplementation(() => { + throw new Error("injected config fsync failure"); }); - await pushCommand({ days: 30 }); + await expect(pushCommand({})).rejects.toThrow("injected config fsync failure"); - expect(mockCollectCcusageUsageAsync).toHaveBeenCalledWith( - compact(twentyNineDaysAgo), - compact(today), - undefined, - { pricingMode: "online" }, - ); - expect(mockSaveConfig).toHaveBeenLastCalledWith(expect.objectContaining({ - ccusage_v20_migration_completed_at: expect.any(String), - last_push_date: todayStr(), - })); - expect(mockSaveConfig).not.toHaveBeenCalledWith(expect.objectContaining({ - codex_native_repair_completed_at: expect.any(String), - })); + expect(pendingBatches).toHaveLength(1); + expect(removeBatchMock).not.toHaveBeenCalled(); + expect(releaseMock).toHaveBeenCalled(); }); - it("uses exact --date without running migration backfill", async () => { - mockLoadConfig.mockReturnValue({ - ...fakeConfig, - ccusage_v20_migration_completed_at: undefined, + it("retains unresolved partials and returns temporary failure", async () => { + apiRequestMock.mockImplementation(async (_config, path, options) => { + if (path === "/api/cli/dashboard") throw new Error("dashboard unavailable"); + const body = JSON.parse(options.body); + return outcome(body.request_id, body.entries[0].date, "retryable_error"); }); - await pushCommand({ date: "2026-03-12" }); + const exitCode = await pushCommand({}); - expect(mockCollectCcusageUsageAsync).toHaveBeenCalledWith("20260312", "20260312", undefined, { - pricingMode: "online", - }); - expect(mockUpdateLastPushDate).toHaveBeenCalledWith(todayStr()); - expect(mockSaveConfig).not.toHaveBeenCalledWith(expect.objectContaining({ - ccusage_v20_migration_completed_at: expect.any(String), - })); - }); - - it("forwards --timeout to ccusage", async () => { - await pushCommand({ timeoutMs: 300_000 }); - expect(mockCollectCcusageUsageAsync).toHaveBeenCalledWith( - expect.any(String), - expect.any(String), - 300_000, - { pricingMode: "online" }, - ); + expect(exitCode).toBe(CLI_EXIT.TEMPORARY); + expect(apiRequestMock.mock.calls.filter(([, path]) => path === "/api/usage/submit")).toHaveLength(3); + expect(pendingBatches).toHaveLength(1); + expect(updateConfigMock).not.toHaveBeenCalled(); }); - it("filters out-of-window ccusage rows before submit", async () => { - const oldDate = "2026-01-12"; - mockCollectCcusageUsageAsync.mockResolvedValue(ccusageOutput([ - usageEntry(oldDate), - usageEntry(todayStr()), - ]) as never); - - await pushCommand({ days: 30 }); - - const submitCall = mockApiRequest.mock.calls.find(([, path]) => path === "/api/usage/submit")!; - const body = JSON.parse((submitCall[2] as { body: string }).body); - expect(body.entries).toHaveLength(1); - expect(body.entries[0].date).toBe(todayStr()); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining(`skipping 1 date(s) outside the 30-day backfill window: ${oldDate}`), - ); - }); + it("removes permanently rejected dates from the retry outbox", async () => { + collectMock.mockResolvedValue(collected([ + usageEntry("2026-03-12"), + usageEntry("2026-03-13"), + ])); + apiRequestMock.mockImplementation(async (_config, path, options) => { + if (path === "/api/cli/dashboard") throw new Error("dashboard unavailable"); + const body = JSON.parse(options.body); + return { + request_id: body.request_id, + outcomes: [ + outcome(body.request_id, "2026-03-12").outcomes[0], + { + date: "2026-03-13", + status: "permanent_error", + error: { code: "INVALID_USAGE", message: "invalid usage" }, + }, + ], + }; + }); - it("dry-run fetches dashboard but skips submit", async () => { - await pushCommand({ dryRun: true }); + const exitCode = await pushCommand({ days: 2 }); - expect(mockApiRequest).toHaveBeenCalledTimes(1); - expect(mockApiRequest).toHaveBeenCalledWith( - expect.objectContaining(fakeConfig), - "/api/cli/dashboard", + expect(exitCode).toBe(CLI_EXIT.PERMANENT); + expect(pendingBatches).toEqual([]); + expect(updateConfigMock).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("permanently rejected"), ); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining("dry run")); }); - it("waits for and renders the scorecard after submit", async () => { - mockApiRequest.mockImplementation(async (_config, path) => { - if (path === "/api/usage/submit") { - return { - results: [{ - date: todayStr(), - usage_id: "u-1", - post_id: "p-1", - post_url: "https://straude.com/post/p-1", - action: "created", - }], - }; - } - - // Regression: the old 1.5-second dashboard race discarded this response. - await new Promise((resolve) => setTimeout(resolve, 1_600)); + it("keeps only retryable dates and caps their future watermark before a permanent gap", async () => { + loadConfigMock.mockReturnValue(config({ last_push_date: "2026-03-10" })); + collectMock.mockResolvedValue(collected([ + usageEntry("2026-03-11"), + usageEntry("2026-03-12"), + usageEntry("2026-03-13"), + ])); + apiRequestMock.mockImplementation(async (_config, path, options) => { + if (path === "/api/cli/dashboard") throw new Error("dashboard unavailable"); + const body = JSON.parse(options.body); return { - username: "alice", - level: 3, - streak: 5, - daily: [{ date: todayStr(), cost_usd: 12.5 }], - week_cost: 12.5, - prev_week_cost: 8, - leaderboard: null, - model_breakdown: [], - total_output_tokens: 5_000_000, + request_id: body.request_id, + outcomes: body.entries.map((entry: { date: string }) => { + if (entry.date === "2026-03-11") { + return outcome(body.request_id, entry.date).outcomes[0]; + } + if (entry.date === "2026-03-12") { + return { + date: entry.date, + status: "permanent_error", + error: { code: "INVALID_USAGE", message: "invalid usage" }, + }; + } + return outcome(body.request_id, entry.date, "retryable_error").outcomes[0]; + }), }; }); - await pushCommand({}); + const exitCode = await pushCommand({}); - expect(mockApiRequest).toHaveBeenLastCalledWith( - expect.objectContaining(fakeConfig), - "/api/cli/dashboard", - ); - expect(render).toHaveBeenCalledTimes(1); - expect(console.log).not.toHaveBeenCalledWith( - expect.stringContaining("Dashboard took too long"), - ); + expect(exitCode).toBe(CLI_EXIT.PERMANENT); + expect(pendingBatches).toHaveLength(1); + expect((pendingBatches[0] as { + request: { entries: Array<{ date: string }> }; + watermark_date?: string; + })).toMatchObject({ + request: { entries: [{ date: "2026-03-13" }] }, + watermark_date: "2026-03-11", + }); }); - it("reports scan failures and exits before submit", async () => { - const error = new Error("ccusage 20.0.4 is unsupported"); - mockCollectCcusageUsageAsync.mockRejectedValue(error); + it("surfaces structured HTTP 409 identity conflicts through device resolution", async () => { + apiRequestMock.mockImplementation(async (_config, path, options) => { + if (path === "/api/cli/dashboard") throw new Error("dashboard unavailable"); + const body = JSON.parse(options.body); + return { + request_id: body.request_id, + outcomes: [{ + date: body.entries[0].date, + status: "identity_conflict", + error: { + code: "device_reconciliation_required", + message: "Device identity must be resolved", + }, + }], + }; + }); - await expect(pushCommand({})).rejects.toThrow(ExitError); + const exitCode = await pushCommand({}); - expect(mockReportUsagePushFailed).toHaveBeenCalledWith( - expect.objectContaining(fakeConfig), - error, - expect.objectContaining({ - command: "push", - stage: "scan", - pricing_mode: "online", - collection_ms: expect.any(Number), - total_ms: expect.any(Number), - }), + expect(exitCode).toBe(CLI_EXIT.PERMANENT); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("straude devices"), ); - expect(mockShutdownTelemetry).toHaveBeenCalled(); - expect(mockApiRequest).not.toHaveBeenCalled(); + expect(pendingBatches).toHaveLength(1); + const submitCall = apiRequestMock.mock.calls.find( + ([, path]) => path === "/api/usage/submit", + )!; + expect(submitCall[2]).toMatchObject({ + maxRetries: 0, + acceptedStatuses: [400, 409, 503], + }); }); - it("reports submit failures and exits", async () => { - const error = new Error("Server error"); - mockApiRequest.mockRejectedValue(error); + it("renders only the newly collected local payload during dry-run", async () => { + const exitCode = await pushCommand({ dryRun: true }); - await expect(pushCommand({})).rejects.toThrow(ExitError); - - expect(mockReportUsagePushFailed).toHaveBeenCalledWith( - expect.objectContaining(fakeConfig), - error, - expect.objectContaining({ - command: "push", - stage: "submit", - pricing_mode: "online", - collection_ms: expect.any(Number), - submit_ms: expect.any(Number), - total_ms: expect.any(Number), - }), - ); - expect(process.exit).toHaveBeenCalledWith(1); + expect(exitCode).toBe(CLI_EXIT.OK); + expect(apiRequestMock).not.toHaveBeenCalled(); + expect(upsertBatchMock).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("nothing submitted")); }); - it("logs in when config is missing", async () => { - mockLoadConfig - .mockReturnValueOnce(null) - .mockReturnValueOnce({ ...fakeConfig }); - - await pushCommand({}); + it("keeps a committed sync successful when the dashboard is unavailable", async () => { + const exitCode = await pushCommand({}); - expect(mockLoginCommand).toHaveBeenCalledTimes(1); + expect(exitCode).toBe(CLI_EXIT.OK); + expect(console.log).toHaveBeenCalledWith("Usage synced; dashboard unavailable."); }); - it("generates and persists a device id on first push", async () => { - mockLoadConfig.mockReturnValue({ - token: "tok", - username: "alice", - api_url: "https://straude.com", - ccusage_v20_migration_completed_at: "2026-05-01T00:00:00.000Z", - }); + it("returns temporary failure for unavailable live pricing without advancing state", async () => { + collectMock.mockRejectedValue(new PricingUnavailableError("embedded fallback")); - await pushCommand({}); + const exitCode = await pushCommand({}); - const savedConfig = mockSaveConfig.mock.calls[0]![0]; - expect(savedConfig.device_id).toMatch(/^[0-9a-f-]{36}$/i); - expect(savedConfig.device_name).toBeDefined(); - const submitCall = mockApiRequest.mock.calls.find(([, path]) => path === "/api/usage/submit")!; - const body = JSON.parse((submitCall[2] as { body: string }).body); - expect(body.device_id).toBe(savedConfig.device_id); + expect(exitCode).toBe(CLI_EXIT.TEMPORARY); + expect(apiRequestMock).not.toHaveBeenCalled(); + expect(updateConfigMock).not.toHaveBeenCalled(); }); - it("returns without submit when ccusage has no rows", async () => { - mockCollectCcusageUsageAsync.mockResolvedValue(ccusageOutput([], { - agents: [], - collector: { - ccusage_version: "20.0.16", - ccusage_agents: [], - pricing_mode: "online", - }, - raw: '{"daily":[]}', - }) as never); + it("fails fast with AUTH_REQUIRED in background execution", async () => { + loadConfigMock.mockReturnValue(null); - await pushCommand({}); + const exitCode = await pushCommand({ nonInteractive: true }); - expect(mockApiRequest).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining("No usage data found"), - ); + expect(exitCode).toBe(CLI_EXIT.AUTH_REQUIRED); + expect(loginMock).not.toHaveBeenCalled(); + expect(collectMock).not.toHaveBeenCalled(); + }); + + it("advances an empty automatic range and writes the v2 migration marker", async () => { + const legacy = config({ + last_push_date: undefined, + usage_protocol_v2_migration_completed_at: undefined, + ccusage_v20_migration_completed_at: "legacy", + codex_native_repair_completed_at: "legacy", + }); + loadConfigMock.mockReturnValue(legacy); + updateConfigMock.mockImplementation((updater) => updater(legacy)); + collectMock.mockResolvedValue(collected([])); + + const exitCode = await pushCommand({}); + + expect(exitCode).toBe(CLI_EXIT.OK); + const next = updateConfigMock.mock.results[0]!.value; + expect(next.last_push_date).toBe(today); + expect(next.usage_protocol_v2_migration_completed_at).toEqual(expect.any(String)); + expect(next.ccusage_v20_migration_completed_at).toBeUndefined(); + expect(next.codex_native_repair_completed_at).toBeUndefined(); }); }); diff --git a/packages/cli/__tests__/flows/cli-sync-flow.test.ts b/packages/cli/__tests__/flows/cli-sync-flow.test.ts index a56377f4..b11de11a 100644 --- a/packages/cli/__tests__/flows/cli-sync-flow.test.ts +++ b/packages/cli/__tests__/flows/cli-sync-flow.test.ts @@ -19,16 +19,33 @@ vi.mock("../../src/config.js", async (importOriginal) => { }); let configStore: Record = {}; +let nextFd = 10; +const fdPaths = new Map(); vi.mock("node:fs", () => ({ existsSync: vi.fn((path: string) => path in configStore), readFileSync: vi.fn((path: string) => configStore[path] ?? ""), - writeFileSync: vi.fn((path: string, data: string) => { + writeFileSync: vi.fn((pathOrFd: string | number, data: string) => { + const path = typeof pathOrFd === "number" ? fdPaths.get(pathOrFd)! : pathOrFd; configStore[path] = data; }), mkdirSync: vi.fn(), - statSync: vi.fn(() => ({ mode: 0o755 })), + statSync: vi.fn(() => ({ mode: 0o755, mtimeMs: Date.now() })), chmodSync: vi.fn(), + openSync: vi.fn((path: string) => { + const fd = nextFd++; + fdPaths.set(fd, path); + return fd; + }), + closeSync: vi.fn(), + fsyncSync: vi.fn(), + renameSync: vi.fn((from: string, to: string) => { + configStore[to] = configStore[from]!; + delete configStore[from]; + }), + unlinkSync: vi.fn((path: string) => { + delete configStore[path]; + }), })); import { pushCommand } from "../../src/commands/push.js"; @@ -53,9 +70,10 @@ function makeConfig(overrides: Record = {}) { token: "tok-123", username: "alice", api_url: "https://straude.com", - device_id: "device-1", + device_id: "22222222-2222-4222-8222-222222222222", device_name: "work-laptop", ccusage_v20_migration_completed_at: "2026-05-01T00:00:00.000Z", + usage_protocol_v2_migration_completed_at: "2026-03-01T00:00:00.000Z", ...overrides, }; } @@ -85,6 +103,46 @@ function ccusageJson(date = todayStr()) { { modelName: "gpt-5.2-codex", cost: 0.05 }, ], metadata: { agents: ["claude", "codex"] }, + agents: [ + { + agent: "claude", + modelsUsed: ["claude-sonnet-4-5-20250929"], + inputTokens: 600, + outputTokens: 200, + cacheCreationTokens: 100, + cacheReadTokens: 100, + totalTokens: 1000, + totalCost: 0.2, + modelBreakdowns: [{ + modelName: "claude-sonnet-4-5-20250929", + inputTokens: 600, + outputTokens: 200, + cacheCreationTokens: 100, + cacheReadTokens: 100, + totalTokens: 1000, + cost: 0.2, + }], + }, + { + agent: "codex", + modelsUsed: ["gpt-5.2-codex"], + inputTokens: 600, + outputTokens: 200, + cacheCreationTokens: 0, + cacheReadTokens: 200, + totalTokens: 1000, + totalCost: 0.05, + modelBreakdowns: [{ + modelName: "gpt-5.2-codex", + inputTokens: 600, + outputTokens: 200, + cacheCreationTokens: 0, + cacheReadTokens: 200, + totalTokens: 1000, + cost: 0.05, + }], + }, + ], }, ], }); @@ -99,20 +157,26 @@ function mockCcusage(json = ccusageJson()) { } function mockSuccessfulSubmit(date = todayStr()) { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - results: [ - { + mockFetch.mockImplementationOnce(async (_url, options) => { + const request = JSON.parse(options.body); + return { + ok: true, + headers: new Headers(), + json: () => + Promise.resolve({ + request_id: request.request_id, + outcomes: [{ date, - usage_id: "u-1", - post_id: "p-1", - post_url: "https://straude.com/post/p-1", - action: "created", - }, - ], - }), + status: "committed", + result: { + usage_id: "u-1", + post_id: "p-1", + post_url: "https://straude.com/post/p-1", + action: "created", + }, + }], + }), + }; }); mockFetch.mockRejectedValueOnce(new Error("dashboard not mocked")); } @@ -123,6 +187,8 @@ beforeEach(() => { _resetCcusageResolver(); _setCcusageCommandForTests({ cmd: "/bundled/ccusage", args: [], version: TEST_CCUSAGE_VERSION }); configStore = {}; + nextFd = 10; + fdPaths.clear(); mockCcusage(); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); @@ -153,20 +219,24 @@ describe("unified ccusage CLI flow", () => { const [, options] = mockFetch.mock.calls[0]!; const body = JSON.parse(options.body); - expect(body.entries[0].data.reasoningOutputTokens).toBe(0); - expect(body.collector).toEqual({ - claude: "ccusage-claude-v20", - codex: "ccusage-codex-v20", - ccusage_version: TEST_CCUSAGE_VERSION, - ccusage_agents: ["claude", "codex"], + expect(body.protocol_version).toBe(2); + expect(body.entries[0].agents).toHaveLength(2); + expect(body.entries[0].agents[0]).toEqual(expect.objectContaining({ + reasoning_output_tokens: 0, + total_tokens: 1000, + })); + expect(body.collector).toEqual(expect.objectContaining({ + name: "ccusage", + version: TEST_CCUSAGE_VERSION, pricing_mode: "online", - }); + })); expect(readPersistedConfig().last_push_date).toBe(todayStr()); }); it("respects explicit --days before the migration backfill has completed", async () => { seedConfig({ ccusage_v20_migration_completed_at: undefined, + usage_protocol_v2_migration_completed_at: undefined, last_push_date: "2026-03-01", }); mockSuccessfulSubmit(); @@ -182,9 +252,10 @@ describe("unified ccusage CLI flow", () => { expect(readPersistedConfig().ccusage_v20_migration_completed_at).toBeUndefined(); }); - it("marks the migration complete after explicit 30-day backfill", async () => { + it("does not mark the automatic migration complete after explicit 30-day backfill", async () => { seedConfig({ ccusage_v20_migration_completed_at: undefined, + usage_protocol_v2_migration_completed_at: undefined, last_push_date: "2026-03-01", }); mockSuccessfulSubmit(); @@ -197,7 +268,7 @@ describe("unified ccusage CLI flow", () => { const args = dailyCall[1] as string[]; const since = args[args.indexOf("--since") + 1]; expect(since).toBe("20260212"); - expect(readPersistedConfig().ccusage_v20_migration_completed_at).toEqual(expect.any(String)); + expect(readPersistedConfig().usage_protocol_v2_migration_completed_at).toBeUndefined(); }); it("submits usage from ccusage sources beyond Claude and Codex", async () => { @@ -214,8 +285,35 @@ describe("unified ccusage CLI flow", () => { cacheReadTokens: 0, totalTokens: 2, totalCost: 0.01, - modelBreakdowns: [{ modelName: "gemini-pro", cost: 0.01 }], + modelBreakdowns: [{ + modelName: "gemini-pro", + inputTokens: 1, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 2, + cost: 0.01, + }], metadata: { agents: ["gemini"] }, + agents: [{ + agent: "gemini", + modelsUsed: ["gemini-pro"], + inputTokens: 1, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 2, + totalCost: 0.01, + modelBreakdowns: [{ + modelName: "gemini-pro", + inputTokens: 1, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 2, + cost: 0.01, + }], + }], }, ], })); @@ -224,17 +322,16 @@ describe("unified ccusage CLI flow", () => { const [, options] = mockFetch.mock.calls[0]!; const body = JSON.parse(options.body); - expect(body.entries[0].data).toMatchObject({ - agents: ["gemini"], + expect(body.entries[0].agents[0]).toMatchObject({ + agent: "gemini", models: ["gemini-pro"], - totalTokens: 2, - costUSD: 0.01, + total_tokens: 2, + cost_usd: 0.01, }); - expect(body.collector).toEqual({ - ccusage_version: TEST_CCUSAGE_VERSION, - ccusage_agents: ["gemini"], + expect(body.collector).toEqual(expect.objectContaining({ + version: TEST_CCUSAGE_VERSION, pricing_mode: "online", - }); + })); expect(process.exit).not.toHaveBeenCalled(); expect(console.error).not.toHaveBeenCalled(); }); diff --git a/packages/cli/__tests__/hooks.test.ts b/packages/cli/__tests__/hooks.test.ts index 3f9f5283..c1b93e74 100644 --- a/packages/cli/__tests__/hooks.test.ts +++ b/packages/cli/__tests__/hooks.test.ts @@ -5,14 +5,33 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // --------------------------------------------------------------------------- let fileStore: Record = {}; +let nextFd = 10; +const fdPaths = new Map(); vi.mock("node:fs", () => ({ + chmodSync: vi.fn(), existsSync: vi.fn((path: string) => path in fileStore), readFileSync: vi.fn((path: string) => fileStore[path] ?? ""), - writeFileSync: vi.fn((path: string, data: string) => { + writeFileSync: vi.fn((pathOrFd: string | number, data: string) => { + const path = typeof pathOrFd === "number" ? fdPaths.get(pathOrFd)! : pathOrFd; fileStore[path] = data; }), mkdirSync: vi.fn(), + openSync: vi.fn((path: string) => { + const fd = nextFd++; + fdPaths.set(fd, path); + return fd; + }), + closeSync: vi.fn(), + fsyncSync: vi.fn(), + renameSync: vi.fn((from: string, to: string) => { + fileStore[to] = fileStore[from]!; + delete fileStore[from]; + }), + unlinkSync: vi.fn((path: string) => { + delete fileStore[path]; + }), + realpathSync: vi.fn((path: string) => path), })); // --------------------------------------------------------------------------- @@ -25,6 +44,7 @@ import { isClaudeCodeHookInstalled, CLAUDE_SETTINGS_PATH, } from "../src/lib/hooks.js"; +import { renameSync } from "node:fs"; // --------------------------------------------------------------------------- // Setup @@ -33,6 +53,8 @@ import { beforeEach(() => { vi.clearAllMocks(); fileStore = {}; + nextFd = 10; + fdPaths.clear(); }); afterEach(() => { @@ -66,8 +88,14 @@ describe("installClaudeCodeHook", () => { const sessionEnd = hooks.SessionEnd as Array<{ hooks: Array<{ type: string; command: string; async?: boolean }> }>; expect(sessionEnd).toHaveLength(1); expect(sessionEnd[0]!.hooks[0]!.type).toBe("command"); - expect(sessionEnd[0]!.hooks[0]!.command).toBe("straude push"); + expect(sessionEnd[0]!.hooks[0]!.command).toBe( + "npx --yes straude@0.2.0 push --non-interactive", + ); expect(sessionEnd[0]!.hooks[0]!.async).toBe(true); + expect(renameSync).toHaveBeenCalledWith( + expect.stringContaining("settings.json."), + CLAUDE_SETTINGS_PATH, + ); }); it("preserves existing hooks on other events", () => { @@ -99,7 +127,9 @@ describe("installClaudeCodeHook", () => { const sessionEnd = hooks.SessionEnd as Array<{ hooks: Array<{ command: string }> }>; expect(sessionEnd).toHaveLength(2); expect(sessionEnd[0]!.hooks[0]!.command).toBe("other-tool cleanup"); - expect(sessionEnd[1]!.hooks[0]!.command).toBe("straude push"); + expect(sessionEnd[1]!.hooks[0]!.command).toBe( + "npx --yes straude@0.2.0 push --non-interactive", + ); }); it("is idempotent — does not duplicate entry", () => { diff --git a/packages/cli/__tests__/machine-id.test.ts b/packages/cli/__tests__/machine-id.test.ts new file mode 100644 index 00000000..8992fa6f --- /dev/null +++ b/packages/cli/__tests__/machine-id.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { readFileSyncMock } = vi.hoisted(() => ({ + readFileSyncMock: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => true), + readFileSync: readFileSyncMock, + }; +}); + +vi.mock("../src/config.js", () => ({ + CONFIG_DIR: "/tmp/straude-machine-id-test", +})); + +import { + _resetMachineIdForTests, + getInstallationId, + getMachineId, +} from "../src/lib/machine-id.js"; + +describe("machine identity", () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetMachineIdForTests(); + }); + + it("never lets the analytics fallback replace the durable installation id", () => { + readFileSyncMock.mockImplementationOnce(() => { + throw new Error("temporarily unreadable"); + }); + const analyticsId = getMachineId(); + const durableId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + readFileSyncMock.mockReturnValue(`${durableId}\n`); + + expect(getInstallationId()).toBe(durableId); + expect(analyticsId).not.toBe(durableId); + }); + + it("keeps a process-local analytics fallback stable without caching it as installation state", () => { + readFileSyncMock.mockImplementation(() => { + throw new Error("unreadable"); + }); + + const first = getMachineId(); + expect(getMachineId()).toBe(first); + expect(() => getInstallationId()).toThrow(/installation identity/i); + }); +}); diff --git a/packages/cli/__tests__/prompt.test.ts b/packages/cli/__tests__/prompt.test.ts new file mode 100644 index 00000000..53097c49 --- /dev/null +++ b/packages/cli/__tests__/prompt.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + isInteractive, + setInteractiveOverride, +} from "../src/lib/prompt.js"; + +afterEach(() => { + setInteractiveOverride(null); +}); + +describe("interactive override", () => { + it("forces noninteractive behavior for the current process", () => { + setInteractiveOverride(false); + expect(isInteractive()).toBe(false); + }); + + it("can force interactive behavior for callers with an explicit UI", () => { + setInteractiveOverride(true); + expect(isInteractive()).toBe(true); + }); + + it("returns to terminal detection when cleared", () => { + setInteractiveOverride(false); + setInteractiveOverride(null); + expect(isInteractive()).toBe( + Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY), + ); + }); +}); diff --git a/packages/cli/__tests__/resolve-push-date-range.test.ts b/packages/cli/__tests__/resolve-push-date-range.test.ts index 7aa609bc..ca736e91 100644 --- a/packages/cli/__tests__/resolve-push-date-range.test.ts +++ b/packages/cli/__tests__/resolve-push-date-range.test.ts @@ -53,7 +53,7 @@ describe("resolvePushDateRange", () => { expect(r.error).toContain("future date"); }); - it("rejects far-future dates with the backfill-window error", () => { + it("rejects far-future dates as future dates", () => { const r = resolvePushDateRange({ today: dateAt("2026-05-04"), options: { date: "2099-01-01" }, @@ -61,7 +61,7 @@ describe("resolvePushDateRange", () => { }); expect(r.ok).toBe(false); if (r.ok) return; - expect(r.error).toContain("within the last 30 days"); + expect(r.error).toContain("future date"); }); it("accepts a date exactly 30 days back (boundary)", () => { @@ -86,7 +86,7 @@ describe("resolvePushDateRange", () => { }); describe("ccusage migration branch", () => { - it("does not expand a normal sync to 30 days when the migration marker is missing", () => { + it("uses exactly three days when the v2 collector migration is pending", () => { const r = resolvePushDateRange({ today: dateAt("2026-05-04"), options: {}, @@ -95,7 +95,7 @@ describe("resolvePushDateRange", () => { }); expect(r.ok).toBe(true); if (!r.ok) return; - expect(isoDay(r.since)).toBe("2026-05-01"); + expect(isoDay(r.since)).toBe("2026-05-02"); expect(isoDay(r.until)).toBe("2026-05-04"); }); @@ -124,20 +124,20 @@ describe("resolvePushDateRange", () => { expect(isoDay(r.until)).toBe("2026-05-04"); }); - it("caps --days at MAX_BACKFILL_DAYS even if user requests more", () => { + it("rejects --days above MAX_BACKFILL_DAYS", () => { const r = resolvePushDateRange({ today: dateAt("2026-05-04"), options: { days: 90 }, shouldRunMigrationBackfill: false, }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(isoDay(r.since)).toBe("2026-04-05"); // today - 29 + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error).toContain("between 1 and 30"); }); }); describe("smart-sync from last_push_date", () => { - it("includes the last_push_date when it's within DEFAULT_SYNC_DAYS", () => { + it("starts after the committed watermark", () => { const r = resolvePushDateRange({ today: dateAt("2026-05-04"), options: {}, @@ -146,11 +146,11 @@ describe("resolvePushDateRange", () => { }); expect(r.ok).toBe(true); if (!r.ok) return; - expect(isoDay(r.since)).toBe("2026-05-01"); + expect(isoDay(r.since)).toBe("2026-05-02"); expect(isoDay(r.until)).toBe("2026-05-04"); }); - it("caps at DEFAULT_SYNC_DAYS when last_push_date is too far back", () => { + it("processes the next contiguous DEFAULT_SYNC_DAYS when behind", () => { const r = resolvePushDateRange({ today: dateAt("2026-05-04"), options: {}, @@ -159,7 +159,8 @@ describe("resolvePushDateRange", () => { }); expect(r.ok).toBe(true); if (!r.ok) return; - expect(isoDay(r.since)).toBe("2026-04-28"); // today - 6 (DEFAULT_SYNC_DAYS=7, +1) + expect(isoDay(r.since)).toBe("2026-04-16"); + expect(isoDay(r.until)).toBe("2026-04-22"); }); it("re-syncs only today when last_push_date >= today", () => { diff --git a/packages/cli/__tests__/scheduler.test.ts b/packages/cli/__tests__/scheduler.test.ts index 5342f5de..fdce3b8f 100644 --- a/packages/cli/__tests__/scheduler.test.ts +++ b/packages/cli/__tests__/scheduler.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // --------------------------------------------------------------------------- vi.mock("node:child_process", () => ({ - execSync: vi.fn(), + execFileSync: vi.fn(), })); let fileStore: Record = {}; @@ -14,6 +14,7 @@ let fileDeleted: string[] = []; vi.mock("node:fs", () => ({ existsSync: vi.fn((path: string) => path in fileStore), readFileSync: vi.fn((path: string) => fileStore[path] ?? ""), + realpathSync: vi.fn((path: string) => path), writeFileSync: vi.fn((path: string, data: string) => { fileStore[path] = data; }), @@ -29,7 +30,7 @@ vi.mock("node:fs", () => ({ // Imports (after mocks) // --------------------------------------------------------------------------- -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { detectScheduler, @@ -42,7 +43,7 @@ import { } from "../src/lib/scheduler.js"; import { AUTO_PUSH_SCRIPT_FILE, LAUNCHD_PLIST_PATH } from "../src/config.js"; -const mockExecSync = vi.mocked(execSync); +const mockExecFileSync = vi.mocked(execFileSync); const mockWriteFileSync = vi.mocked(writeFileSync); // --------------------------------------------------------------------------- @@ -114,9 +115,33 @@ describe("_buildWrapperScript", () => { const script = _buildWrapperScript(); expect(script).toContain("#!/bin/sh"); expect(script).toContain("Auto-push starting"); - expect(script).toContain("command -v straude"); - expect(script).toContain("bunx straude@latest push"); - expect(script).toContain("npx --yes straude@latest push"); + expect(script).toContain("exec bunx straude@0.2.0 push --non-interactive"); + expect(script).toContain("exec npx --yes straude@0.2.0 push --non-interactive"); + expect(script).not.toContain("straude@latest"); + expect(script).toContain("tail -n 500"); + }); + + it("passes crontab content through stdin without a shell", () => { + let installed = "0 8 * * * echo $(touch /tmp/should-not-run)\n"; + mockExecFileSync.mockImplementation((command, args, options) => { + if (command === "crontab" && args?.[0] === "-l") { + return installed; + } + if (command === "crontab" && args?.[0] === "-") { + installed = String(options?.input ?? ""); + } + return ""; + }); + + installScheduler("09:00", "cron"); + + expect(mockExecFileSync).toHaveBeenCalledWith( + "crontab", + ["-"], + expect.objectContaining({ + input: expect.stringContaining("$(touch /tmp/should-not-run)"), + }), + ); }); }); @@ -147,29 +172,54 @@ describe("installScheduler — launchd", () => { expect(fileStore[LAUNCHD_PLIST_PATH]).toContain("30"); // launchctl load called - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining("launchctl load"), + expect(mockExecFileSync).toHaveBeenCalledWith( + "launchctl", + expect.arrayContaining(["bootstrap", LAUNCHD_PLIST_PATH]), expect.anything(), ); }); - it("is idempotent — launchctl load failure is swallowed", () => { - mockExecSync.mockImplementation(() => { - throw new Error("already loaded"); + it("surfaces launchctl activation failures", () => { + mockExecFileSync.mockImplementation((_command, args) => { + if (args?.[0] === "bootstrap") throw new Error("permission denied"); + return ""; }); - // Should not throw - installScheduler("09:00", "launchd"); - expect(fileStore[LAUNCHD_PLIST_PATH]).toBeDefined(); + expect(() => installScheduler("09:00", "launchd")).toThrow(/launchctl bootstrap/); + expect(fileStore[LAUNCHD_PLIST_PATH]).toBeUndefined(); + }); + + it("restores the prior launchd files and service when replacement fails", () => { + fileStore[AUTO_PUSH_SCRIPT_FILE] = "old wrapper"; + fileStore[LAUNCHD_PLIST_PATH] = "old plist"; + let bootstrapCalls = 0; + mockExecFileSync.mockImplementation((_command, args) => { + if (args?.[0] === "print") return "loaded"; + if (args?.[0] === "bootstrap" && bootstrapCalls++ === 0) { + throw new Error("new service rejected"); + } + return ""; + }); + + expect(() => installScheduler("09:00", "launchd")).toThrow(/launchctl bootstrap/); + + expect(fileStore[AUTO_PUSH_SCRIPT_FILE]).toBe("old wrapper"); + expect(fileStore[LAUNCHD_PLIST_PATH]).toBe("old plist"); + expect(bootstrapCalls).toBe(2); }); }); describe("installScheduler — cron", () => { it("appends tagged entry to crontab", () => { // No existing crontab - mockExecSync.mockImplementation((cmd: string) => { - if (typeof cmd === "string" && cmd.includes("crontab -l")) { - throw new Error("no crontab"); + let installed: string | null = null; + mockExecFileSync.mockImplementation((command, args, options) => { + if (command === "crontab" && args?.[0] === "-l") { + if (installed === null) throw new Error("no crontab"); + return installed; + } + if (command === "crontab" && args?.[0] === "-") { + installed = String(options?.input ?? ""); } return ""; }); @@ -180,35 +230,61 @@ describe("installScheduler — cron", () => { expect(fileStore[AUTO_PUSH_SCRIPT_FILE]).toContain("#!/bin/sh"); // Crontab set with tagged entry - const setCrontabCall = mockExecSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("| crontab -"), + const setCrontabCall = mockExecFileSync.mock.calls.find( + (call) => call[0] === "crontab" && call[1]?.[0] === "-", ); expect(setCrontabCall).toBeDefined(); - const crontabContent = setCrontabCall![0] as string; + const crontabContent = setCrontabCall![2]?.input as string; expect(crontabContent).toContain("0 9 * * *"); expect(crontabContent).toContain("# straude-auto-push"); }); it("replaces existing straude entry", () => { - mockExecSync.mockImplementation((cmd: string) => { - if (typeof cmd === "string" && cmd.includes("crontab -l")) { - return "0 8 * * * some-other-job\n30 21 * * * old-straude-entry # straude-auto-push\n"; + let installed = "0 8 * * * some-other-job\n30 21 * * * old-straude-entry # straude-auto-push\n"; + mockExecFileSync.mockImplementation((command, args, options) => { + if (command === "crontab" && args?.[0] === "-l") { + return installed; + } + if (command === "crontab" && args?.[0] === "-") { + installed = String(options?.input ?? ""); } return ""; }); installScheduler("14:30", "cron"); - const setCrontabCall = mockExecSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("| crontab -"), + const setCrontabCall = mockExecFileSync.mock.calls.find( + (call) => call[0] === "crontab" && call[1]?.[0] === "-", ); - const crontabContent = setCrontabCall![0] as string; + const crontabContent = setCrontabCall![2]?.input as string; // Old entry removed, new one added expect(crontabContent).toContain("30 14 * * *"); expect(crontabContent).toContain("some-other-job"); // Should only have one straude-auto-push tag expect(crontabContent.match(/straude-auto-push/g)?.length).toBe(1); }); + + it("restores the prior crontab and wrapper when activation cannot be verified", () => { + const previousCrontab = "0 8 * * * old-job\n"; + fileStore[AUTO_PUSH_SCRIPT_FILE] = "old wrapper"; + let installed = previousCrontab; + let reads = 0; + mockExecFileSync.mockImplementation((command, args, options) => { + if (command === "crontab" && args?.[0] === "-l") { + reads += 1; + return reads === 1 ? installed : previousCrontab; + } + if (command === "crontab" && args?.[0] === "-") { + installed = String(options?.input ?? ""); + } + return ""; + }); + + expect(() => installScheduler("09:00", "cron")).toThrow(/not active/); + + expect(installed).toBe(previousCrontab); + expect(fileStore[AUTO_PUSH_SCRIPT_FILE]).toBe("old wrapper"); + }); }); describe("uninstallScheduler — launchd", () => { @@ -218,8 +294,9 @@ describe("uninstallScheduler — launchd", () => { uninstallScheduler("launchd"); - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining("launchctl unload"), + expect(mockExecFileSync).toHaveBeenCalledWith( + "launchctl", + expect.arrayContaining(["bootout", LAUNCHD_PLIST_PATH]), expect.anything(), ); expect(fileDeleted).toContain(LAUNCHD_PLIST_PATH); @@ -228,14 +305,14 @@ describe("uninstallScheduler — launchd", () => { it("is a no-op when plist does not exist", () => { uninstallScheduler("launchd"); - expect(mockExecSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); }); }); describe("uninstallScheduler — cron", () => { it("removes tagged entry from crontab", () => { - mockExecSync.mockImplementation((cmd: string) => { - if (typeof cmd === "string" && cmd.includes("crontab -l")) { + mockExecFileSync.mockImplementation((command, args) => { + if (command === "crontab" && args?.[0] === "-l") { return "0 8 * * * some-other-job\n0 21 * * * straude-push # straude-auto-push\n"; } return ""; @@ -244,17 +321,17 @@ describe("uninstallScheduler — cron", () => { uninstallScheduler("cron"); - const setCrontabCall = mockExecSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("| crontab -"), + const setCrontabCall = mockExecFileSync.mock.calls.find( + (call) => call[0] === "crontab" && call[1]?.[0] === "-", ); - const crontabContent = setCrontabCall![0] as string; + const crontabContent = setCrontabCall![2]?.input as string; expect(crontabContent).toContain("some-other-job"); expect(crontabContent).not.toContain("straude-auto-push"); }); it("removes crontab entirely when no entries remain", () => { - mockExecSync.mockImplementation((cmd: string) => { - if (typeof cmd === "string" && cmd.includes("crontab -l")) { + mockExecFileSync.mockImplementation((command, args) => { + if (command === "crontab" && args?.[0] === "-l") { return "0 21 * * * straude-push # straude-auto-push\n"; } return ""; @@ -263,15 +340,16 @@ describe("uninstallScheduler — cron", () => { uninstallScheduler("cron"); - expect(mockExecSync).toHaveBeenCalledWith( - expect.stringContaining("crontab -r"), + expect(mockExecFileSync).toHaveBeenCalledWith( + "crontab", + ["-r"], expect.anything(), ); }); it("is a no-op when no straude entry exists", () => { - mockExecSync.mockImplementation((cmd: string) => { - if (typeof cmd === "string" && cmd.includes("crontab -l")) { + mockExecFileSync.mockImplementation((command, args) => { + if (command === "crontab" && args?.[0] === "-l") { return "0 8 * * * some-other-job\n"; } return ""; @@ -280,8 +358,8 @@ describe("uninstallScheduler — cron", () => { uninstallScheduler("cron"); // Should not write a new crontab - const setCrontabCall = mockExecSync.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("| crontab -"), + const setCrontabCall = mockExecFileSync.mock.calls.find( + (call) => call[0] === "crontab" && call[1]?.[0] === "-", ); expect(setCrontabCall).toBeUndefined(); }); @@ -297,18 +375,26 @@ describe("isSchedulerInstalled", () => { expect(isSchedulerInstalled("launchd")).toBe(false); }); + it("returns false for launchd when the plist exists but the job is not loaded", () => { + fileStore[LAUNCHD_PLIST_PATH] = ""; + mockExecFileSync.mockImplementation(() => { + throw new Error("service not found"); + }); + expect(isSchedulerInstalled("launchd")).toBe(false); + }); + it("returns true for cron when tagged entry exists", () => { - mockExecSync.mockReturnValue("0 21 * * * ... # straude-auto-push\n"); + mockExecFileSync.mockReturnValue("0 21 * * * ... # straude-auto-push\n"); expect(isSchedulerInstalled("cron")).toBe(true); }); it("returns false for cron when no tagged entry", () => { - mockExecSync.mockReturnValue("0 8 * * * other-job\n"); + mockExecFileSync.mockReturnValue("0 8 * * * other-job\n"); expect(isSchedulerInstalled("cron")).toBe(false); }); it("returns false for cron when crontab fails", () => { - mockExecSync.mockImplementation(() => { + mockExecFileSync.mockImplementation(() => { throw new Error("no crontab"); }); expect(isSchedulerInstalled("cron")).toBe(false); diff --git a/packages/cli/__tests__/sync-state.test.ts b/packages/cli/__tests__/sync-state.test.ts new file mode 100644 index 00000000..40f34aa4 --- /dev/null +++ b/packages/cli/__tests__/sync-state.test.ts @@ -0,0 +1,144 @@ +import { randomUUID } from "node:crypto"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + acquireSyncLease, + getStateFileMode, + loadPendingBatches, + removePendingBatch, + syncStatePathsForDirectory, + SyncStateCorruptError, + upsertPendingBatch, + type PendingUsageBatch, +} from "../src/lib/sync-state.js"; + +const directories: string[] = []; + +function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "straude-sync-state-")); + directories.push(directory); + return directory; +} + +function batch(): PendingUsageBatch { + const date = "2026-07-23"; + return { + request: { + protocol_version: 2, + request_id: randomUUID(), + source: "cli", + timezone: "UTC", + installation: { id: randomUUID() }, + collector: { + name: "ccusage", + version: "20.0.16", + pricing_mode: "online", + }, + entries: [{ + date, + content_hash: "a".repeat(64), + agents: [{ + agent: "codex", + models: ["gpt-5"], + input_tokens: 1, + output_tokens: 1, + reasoning_output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 2, + cost_usd: 0.01, + model_breakdown: [{ + model: "gpt-5", + input_tokens: 1, + output_tokens: 1, + reasoning_output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 2, + cost_usd: 0.01, + }], + }], + }], + }, + requested_dates: [date], + range_mode: "incremental", + migration_pending: false, + created_at: "2026-07-23T12:00:00.000Z", + }; +} + +afterEach(async () => { + const { rm } = await import("node:fs/promises"); + await Promise.all(directories.splice(0).map((directory) => ( + rm(directory, { recursive: true, force: true }) + ))); +}); + +describe("durable sync state", () => { + it("atomically round-trips validated outbox batches with owner-only permissions", () => { + const paths = syncStatePathsForDirectory(temporaryDirectory()); + const pending = batch(); + upsertPendingBatch(pending, paths); + + expect(loadPendingBatches(paths)).toEqual([pending]); + expect(getStateFileMode(paths.outbox)).toBe(0o600); + + removePendingBatch(pending.request.request_id, paths); + expect(loadPendingBatches(paths)).toEqual([]); + }); + + it("preserves a corrupt outbox and fails clearly", () => { + const directory = temporaryDirectory(); + const paths = syncStatePathsForDirectory(directory); + writeFileSync(paths.outbox, "{bad json", "utf8"); + + expect(() => loadPendingBatches(paths)).toThrow(SyncStateCorruptError); + expect(existsSync(paths.outbox)).toBe(false); + const preserved = readdirSync(directory).find((name) => name.startsWith("pending-sync.json.corrupt-")); + expect(preserved).toBeDefined(); + expect(readFileSync(join(directory, preserved!), "utf8")).toBe("{bad json"); + }); + + it("coalesces a background duplicate into the queue", async () => { + const paths = syncStatePathsForDirectory(temporaryDirectory()); + const first = await acquireSyncLease({ + dates: ["2026-07-22"], + interactive: false, + paths, + }); + expect(first).not.toBeNull(); + + const duplicate = await acquireSyncLease({ + dates: ["2026-07-23"], + interactive: false, + paths, + }); + expect(duplicate).toBeNull(); + first!.release(); + + const next = await acquireSyncLease({ + dates: ["2026-07-24"], + interactive: false, + paths, + }); + expect(next?.queuedDates).toEqual(["2026-07-23"]); + next?.acknowledgeQueuedDates(["2026-07-23"]); + next?.release(); + + const final = await acquireSyncLease({ + dates: ["2026-07-24"], + interactive: false, + paths, + }); + expect(final?.queuedDates).toEqual([]); + final?.release(); + }); +}); diff --git a/packages/cli/__tests__/telemetry.test.ts b/packages/cli/__tests__/telemetry.test.ts index 5dbba0e9..ca04b7ce 100644 --- a/packages/cli/__tests__/telemetry.test.ts +++ b/packages/cli/__tests__/telemetry.test.ts @@ -4,6 +4,7 @@ vi.mock("../src/lib/posthog.js", () => ({ posthog: { capture: vi.fn(), captureException: vi.fn(), + _shutdown: vi.fn(), }, })); @@ -17,6 +18,7 @@ import { isPushInvocation, reportCliException, reportUsagePushFailed, + shutdownTelemetryWithTimeout, } from "../src/lib/telemetry.js"; const mockCapture = vi.mocked(posthog.capture); @@ -50,8 +52,8 @@ describe("telemetry", () => { distinctId: "alice", event: "usage_push_failed", properties: { - error: "submit failed", error_name: "Error", + error_fingerprint: expect.stringMatching(/^[a-f0-9]{24}$/), command: "push", stage: "submit", }, @@ -59,7 +61,7 @@ describe("telemetry", () => { expect(mockCaptureException).not.toHaveBeenCalled(); }); - it("keeps non-push command crashes in PostHog exceptions", () => { + it("reports non-push crashes without sending raw exception content", () => { const error = new Error("login broke"); reportCliException( { token: "tok", username: "alice", api_url: "https://straude.com" }, @@ -67,10 +69,20 @@ describe("telemetry", () => { { command: "login" }, ); - expect(mockCaptureException).toHaveBeenCalledWith( - error, - "alice", - { command: "login" }, - ); + expect(mockCapture).toHaveBeenCalledWith({ + distinctId: "alice", + event: "cli_exception", + properties: { + error_name: "Error", + error_fingerprint: expect.stringMatching(/^[a-f0-9]{24}$/), + command: "login", + }, + }); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it("treats telemetry shutdown rejection as best-effort", async () => { + vi.mocked(posthog._shutdown).mockRejectedValueOnce(new Error("transport failed")); + await expect(shutdownTelemetryWithTimeout(10)).resolves.toBeGreaterThanOrEqual(0); }); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index cdbb695f..d238a5b7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,25 +1,33 @@ { "name": "straude", - "version": "0.1.30", + "version": "0.2.0", "description": "CLI for pushing AI coding agent usage stats to Straude", + "repository": { + "type": "git", + "url": "git+https://github.com/ohong/straude.git", + "directory": "packages/cli" + }, "main": "dist/index.js", "bin": { "straude": "dist/index.js" }, "files": [ - "dist" + "dist/index.js" ], "type": "module", "engines": { - "node": ">=18" + "node": ">=20" }, "scripts": { - "build": "cd ../shared && bun run build && cd ../cli && tsup", + "build:shared": "bun run --cwd ../shared build", + "build": "bun run build:shared && tsup", "dev": "tsup --watch", - "typecheck": "tsc --noEmit", - "prepublishOnly": "bun run build", - "test": "vitest run", - "test:packaged": "node scripts/packaged-cli-e2e.mjs" + "typecheck": "bun run build:shared && tsc --noEmit", + "prepack": "bun run build", + "test": "bun run build:shared && vitest run", + "test:packaged": "node scripts/packaged-cli-e2e.mjs", + "benchmark": "node scripts/benchmark-cli.mjs", + "benchmark:collector": "node scripts/benchmark-collector.mjs" }, "devDependencies": { "@straude/shared": "workspace:*", @@ -31,7 +39,7 @@ }, "dependencies": { "@pppp606/ink-chart": "^0.2.4", - "ccusage": "^20.0.16", + "ccusage": "20.0.16", "chalk": "^5.6.2", "ink": "^6.8.0", "posthog-node": "^5.29.1", diff --git a/packages/cli/scripts/benchmark-cli.mjs b/packages/cli/scripts/benchmark-cli.mjs new file mode 100644 index 00000000..49b381c5 --- /dev/null +++ b/packages/cli/scripts/benchmark-cli.mjs @@ -0,0 +1,85 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const iterations = Number.parseInt(process.env.STRAUDE_BENCH_ITERATIONS ?? "15", 10); + +if (!Number.isInteger(iterations) || iterations < 3) { + throw new Error("STRAUDE_BENCH_ITERATIONS must be an integer of at least 3"); +} + +function percentile(sorted, value) { + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * value) - 1)]; +} + +async function time(command, args, options) { + const startedAt = performance.now(); + await execFileAsync(command, args, options); + return performance.now() - startedAt; +} + +const root = await mkdtemp(join(tmpdir(), "straude-benchmark-")); +try { + const installDir = join(root, "install"); + const homeDir = join(root, "home"); + await mkdir(installDir, { recursive: true }); + await mkdir(homeDir, { recursive: true }); + await writeFile(join(installDir, "package.json"), JSON.stringify({ private: true })); + + const { stdout } = await execFileAsync( + npm, + ["pack", "--json", "--pack-destination", root], + { cwd: packageDir, maxBuffer: 10 * 1024 * 1024 }, + ); + const jsonStart = stdout.lastIndexOf("\n["); + const [pack] = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); + const tarball = join(root, pack.filename); + await execFileAsync(npm, ["install", "--no-audit", "--no-fund", tarball], { + cwd: installDir, + maxBuffer: 10 * 1024 * 1024, + }); + + const manifest = JSON.parse( + await readFile(join(installDir, "node_modules", "straude", "package.json"), "utf8"), + ); + const cli = join(installDir, "node_modules", "straude", "dist", "index.js"); + const env = { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + STRAUDE_TELEMETRY_DISABLED: "1", + }; + + // Warm filesystem caches and create the isolated first-run marker before + // measuring steady-state process startup. + await execFileAsync(process.execPath, [cli, "--version"], { cwd: installDir, env }); + + const samples = []; + for (let index = 0; index < iterations; index += 1) { + samples.push(await time(process.execPath, [cli, "--version"], { + cwd: installDir, + env, + })); + } + samples.sort((left, right) => left - right); + const result = { + package: `straude@${manifest.version}`, + node: process.version, + platform: `${process.platform}-${process.arch}`, + metric: "warm-cache --version process latency", + iterations, + median_ms: Number(percentile(samples, 0.5).toFixed(1)), + p95_ms: Number(percentile(samples, 0.95).toFixed(1)), + min_ms: Number(samples[0].toFixed(1)), + max_ms: Number(samples.at(-1).toFixed(1)), + }; + console.log(JSON.stringify(result, null, 2)); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/cli/scripts/benchmark-collector.mjs b/packages/cli/scripts/benchmark-collector.mjs new file mode 100644 index 00000000..86a0ba48 --- /dev/null +++ b/packages/cli/scripts/benchmark-collector.mjs @@ -0,0 +1,164 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureDir = join( + packageDir, + "__tests__", + "fixtures", + "ccusage-gpt-5.6", + "codex", + "sessions", +); +const collectorPackagePath = require.resolve("ccusage/package.json"); +const collectorPackage = JSON.parse(await readFile(collectorPackagePath, "utf8")); +const collectorCli = join(dirname(collectorPackagePath), collectorPackage.bin.ccusage); +const ranges = [1, 3, 7, 30]; +const iterations = Number.parseInt( + process.env.STRAUDE_COLLECTOR_BENCH_ITERATIONS ?? "7", + 10, +); +const anchor = "2026-07-09"; + +if (collectorPackage.version !== "20.0.16") { + throw new Error(`Expected ccusage 20.0.16, found ${collectorPackage.version}`); +} +if (!Number.isInteger(iterations) || iterations < 3) { + throw new Error( + "STRAUDE_COLLECTOR_BENCH_ITERATIONS must be an integer of at least 3", + ); +} + +function dateAtOffset(offset) { + const date = new Date(`${anchor}T12:00:00.000Z`); + date.setUTCDate(date.getUTCDate() + offset); + return date.toISOString().slice(0, 10); +} + +function compactDate(date) { + return date.replaceAll("-", ""); +} + +function percentile(sorted, value) { + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * value) - 1)]; +} + +async function runCollector({ codexHome, homeDir, days }) { + const since = dateAtOffset(-(days - 1)); + const startedAt = performance.now(); + const { stdout } = await execFileAsync( + process.execPath, + [ + collectorCli, + "daily", + "--json", + "--since", + compactDate(since), + "--until", + compactDate(anchor), + "--timezone", + "UTC", + "--by-agent", + "--offline", + ], + { + cwd: homeDir, + env: { + ...process.env, + CODEX_HOME: codexHome, + HOME: homeDir, + USERPROFILE: homeDir, + NO_COLOR: "1", + }, + maxBuffer: 20 * 1024 * 1024, + timeout: 240_000, + }, + ); + const result = JSON.parse(stdout); + if (!Array.isArray(result.daily) || result.daily.length !== days) { + throw new Error( + `Expected ${days} collector rows, received ${result.daily?.length ?? "invalid output"}`, + ); + } + if (result.daily.some((entry) => entry.agents?.length !== 1)) { + throw new Error("Collector benchmark lost the per-agent breakdown"); + } + return performance.now() - startedAt; +} + +const root = await mkdtemp(join(tmpdir(), "straude-collector-benchmark-")); +try { + const homeDir = join(root, "home"); + const codexHome = join(root, "codex"); + const sessionsDir = join(codexHome, "sessions"); + await mkdir(homeDir, { recursive: true }); + await mkdir(sessionsDir, { recursive: true }); + + const templates = await Promise.all( + (await readdir(fixtureDir)) + .filter((filename) => filename.endsWith(".jsonl")) + .sort() + .map(async (filename) => ({ + filename, + contents: await readFile(join(fixtureDir, filename), "utf8"), + })), + ); + if (templates.length === 0) { + throw new Error(`No collector fixtures found in ${fixtureDir}`); + } + + for (let offset = -29; offset <= 0; offset += 1) { + const date = dateAtOffset(offset); + await Promise.all( + templates.map(({ filename, contents }) => + writeFile( + join(sessionsDir, `${date}-${filename}`), + contents.replaceAll(anchor, date), + { mode: 0o600 }, + ), + ), + ); + } + + const measurements = []; + for (const days of ranges) { + const coldMs = await runCollector({ codexHome, homeDir, days }); + const warmSamples = []; + for (let iteration = 0; iteration < iterations; iteration += 1) { + warmSamples.push(await runCollector({ codexHome, homeDir, days })); + } + warmSamples.sort((left, right) => left - right); + measurements.push({ + days, + fixture_sessions: days * templates.length, + cold_ms: Number(coldMs.toFixed(1)), + warm_median_ms: Number(percentile(warmSamples, 0.5).toFixed(1)), + warm_p95_ms: Number(percentile(warmSamples, 0.95).toFixed(1)), + }); + } + + console.log( + JSON.stringify( + { + collector: `ccusage@${collectorPackage.version}`, + node: process.version, + platform: `${process.platform}-${process.arch}`, + timezone: "UTC", + pricing: "offline fixture pricing", + warm_iterations: iterations, + measurements, + }, + null, + 2, + ), + ); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/cli/scripts/packaged-cli-e2e.mjs b/packages/cli/scripts/packaged-cli-e2e.mjs index eaa5b7a3..332ddbf0 100644 --- a/packages/cli/scripts/packaged-cli-e2e.mjs +++ b/packages/cli/scripts/packaged-cli-e2e.mjs @@ -1,11 +1,55 @@ import { execFile } from "node:child_process"; -import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + +function readOption(name) { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return value; +} + +async function createTarball(root) { + const { stdout } = await execFileAsync( + npm, + ["pack", "--json", "--pack-destination", root], + { cwd: packageDir, maxBuffer: 10 * 1024 * 1024 }, + ); + const jsonStart = stdout.lastIndexOf("\n["); + const [pack] = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); + const filenames = pack.files.map((file) => file.path).sort(); + if (!filenames.includes("dist/index.js")) { + throw new Error(`Packed CLI is missing dist/index.js: ${filenames.join(", ")}`); + } + if (filenames.some((filename) => filename.endsWith(".map") || filename.endsWith(".tsbuildinfo"))) { + throw new Error(`Packed CLI contains excluded build metadata: ${filenames.join(", ")}`); + } + return join(root, pack.filename); +} + +async function resolveTarball(value) { + const candidate = isAbsolute(value) ? value : resolve(process.cwd(), value); + if (!(await stat(candidate)).isDirectory()) return candidate; + const tarballs = (await readdir(candidate)) + .filter((filename) => filename.endsWith(".tgz")) + .sort(); + if (tarballs.length !== 1) { + throw new Error(`Expected one tarball in ${candidate}, found ${tarballs.length}`); + } + return join(candidate, tarballs[0]); +} + const root = await mkdtemp(join(tmpdir(), "straude-packaged-e2e-")); const installDir = join(root, "install"); const homeDir = join(root, "home"); @@ -15,10 +59,7 @@ let server; try { await mkdir(installDir, { recursive: true }); await mkdir(join(homeDir, ".straude"), { recursive: true }); - await writeFile( - join(installDir, "package.json"), - JSON.stringify({ private: true }), - ); + await writeFile(join(installDir, "package.json"), JSON.stringify({ private: true })); const fixtureSource = new URL("../__tests__/fixtures/ccusage-gpt-5.6/codex", import.meta.url); await cp(fixtureSource, codexHome, { recursive: true }); @@ -35,29 +76,70 @@ try { await writeFile(path, contents.replaceAll("2026-07-09", date)); } - const { stdout: packOutput } = await execFileAsync( - "npm", - ["pack", "--json", "--pack-destination", root], - { cwd: new URL("..", import.meta.url) }, + const suppliedTarball = readOption("--tarball"); + const tarball = suppliedTarball + ? await resolveTarball(suppliedTarball) + : await createTarball(root); + + await execFileAsync(npm, ["install", "--no-audit", "--no-fund", tarball], { + cwd: installDir, + maxBuffer: 10 * 1024 * 1024, + }); + + const installedPackageDir = join(installDir, "node_modules", "straude"); + const packageJson = JSON.parse( + await readFile(join(installedPackageDir, "package.json"), "utf8"), ); - const [{ filename }] = JSON.parse(packOutput); - const tarball = join(root, filename); + if (packageJson.bin?.straude !== "dist/index.js") { + throw new Error(`Packed CLI has an invalid bin entry: ${JSON.stringify(packageJson.bin)}`); + } + if (packageJson.dependencies?.ccusage !== "20.0.16") { + throw new Error(`Packed CLI must pin ccusage 20.0.16, got ${packageJson.dependencies?.ccusage}`); + } + if (packageJson.engines?.node !== ">=20") { + throw new Error(`Packed CLI must require Node >=20, got ${packageJson.engines?.node}`); + } - await execFileAsync("npm", ["install", "--no-audit", "--no-fund", tarball], { + const cli = join(installedPackageDir, "dist", "index.js"); + const childEnvironment = { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + CODEX_HOME: codexHome, + STRAUDE_TELEMETRY_DISABLED: "1", + }; + const versionResult = await execFileAsync(process.execPath, [cli, "--version"], { cwd: installDir, + env: childEnvironment, }); + if (versionResult.stdout.trim() !== `straude v${packageJson.version}`) { + throw new Error(`Packed CLI reported the wrong version: ${versionResult.stdout.trim()}`); + } server = createServer(async (request, response) => { response.setHeader("Content-Type", "application/json"); if (request.url === "/api/usage/submit" && request.method === "POST") { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const submission = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (submission.protocol_version !== 2) { + throw new Error(`Expected usage protocol v2, got ${submission.protocol_version}`); + } + if (submission.collector?.version !== "20.0.16") { + throw new Error(`Expected ccusage 20.0.16, got ${submission.collector?.version}`); + } response.end(JSON.stringify({ - results: [{ - date, - usage_id: "usage-e2e", - post_id: "post-e2e", - post_url: "http://straude.test/post/post-e2e", - action: "created", - }], + request_id: submission.request_id, + outcomes: submission.entries.map((entry) => ({ + date: entry.date, + status: "committed", + result: { + usage_id: "usage-e2e", + post_id: "post-e2e", + post_url: "http://straude.test/post/post-e2e", + action: "created", + }, + })), })); return; } @@ -67,7 +149,7 @@ try { return; } - await new Promise((resolve) => setTimeout(resolve, 1_700)); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 1_700)); response.end(JSON.stringify({ username: "packaged-e2e", level: 7, @@ -80,7 +162,7 @@ try { total_output_tokens: 5_000_000, })); }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Could not determine fixture server address"); @@ -95,20 +177,16 @@ try { }), ); - const packageJson = JSON.parse( - await readFile(new URL("../package.json", import.meta.url), "utf8"), - ); - const cli = join(installDir, "node_modules", ".bin", "straude"); const startedAt = performance.now(); - const { stdout, stderr } = await execFileAsync(cli, ["push", "--date", date], { - cwd: installDir, - env: { - ...process.env, - HOME: homeDir, - CODEX_HOME: codexHome, - STRAUDE_TELEMETRY_DISABLED: "1", + const { stdout, stderr } = await execFileAsync( + process.execPath, + [cli, "push", "--date", date, "--debug"], + { + cwd: installDir, + env: childEnvironment, + maxBuffer: 10 * 1024 * 1024, }, - }); + ); const elapsedMs = Math.round(performance.now() - startedAt); const output = `${stdout}\n${stderr}`; @@ -123,11 +201,13 @@ try { throw new Error(`Packaged CLI returned before the delayed scorecard (${elapsedMs}ms)`); } - console.log(`straude v${packageJson.version} packed-install scorecard passed (${elapsedMs}ms)`); + console.log( + `straude v${packageJson.version} packed-install scorecard passed on Node ${process.version} (${elapsedMs}ms)`, + ); } finally { if (server) { - await new Promise((resolve, reject) => { - server.close((error) => error ? reject(error) : resolve()); + await new Promise((resolveClose, reject) => { + server.close((error) => error ? reject(error) : resolveClose()); }); } await rm(root, { recursive: true, force: true }); diff --git a/packages/cli/src/commands/auto.ts b/packages/cli/src/commands/auto.ts index c0ffc45a..137d60ff 100644 --- a/packages/cli/src/commands/auto.ts +++ b/packages/cli/src/commands/auto.ts @@ -1,4 +1,4 @@ -import { loadConfig, saveConfig } from "../lib/auth.js"; +import { loadConfig, updateConfig } from "../lib/auth.js"; import type { StraudeConfig } from "../lib/auth.js"; import { detectScheduler, @@ -33,6 +33,44 @@ function disableExisting(config: StraudeConfig): void { } } +function installConfiguredAutoPush(autoPush: NonNullable): void { + const mechanism = autoPush.mechanism ?? "scheduler"; + if (mechanism === "hooks") installClaudeCodeHook(); + else installScheduler(autoPush.time, autoPush.scheduler); +} + +function uninstallConfiguredAutoPush(autoPush: NonNullable): void { + const mechanism = autoPush.mechanism ?? "scheduler"; + if (mechanism === "hooks") uninstallClaudeCodeHook(); + else uninstallScheduler(autoPush.scheduler); +} + +function persistAutoPush( + fallback: StraudeConfig, + autoPush: StraudeConfig["auto_push"], +): StraudeConfig { + return updateConfig((current) => { + const next = { ...(current ?? fallback) }; + if (autoPush) next.auto_push = autoPush; + else delete next.auto_push; + return next; + }); +} + +function rollbackAutoPush( + attempted: StraudeConfig["auto_push"], + previous: StraudeConfig["auto_push"], +): void { + if (attempted) { + try { + uninstallConfiguredAutoPush(attempted); + } catch { + // Continue restoring the previous mechanism. + } + } + if (previous?.enabled) installConfiguredAutoPush(previous); +} + export function enableAutoPush( config: StraudeConfig, time?: string, @@ -41,18 +79,23 @@ export function enableAutoPush( const resolvedMechanism = mechanism === "hooks" ? "hooks" : "scheduler"; if (resolvedMechanism === "hooks") { - // Disable existing (scheduler or hooks) before switching + const previous = config.auto_push; disableExisting(config); - installClaudeCodeHook(); - - config.auto_push = { + const next = { enabled: true, time: time ?? AUTO_PUSH_DEFAULT_TIME, scheduler: detectScheduler(), // stored but not used for hooks mechanism: "hooks", - }; - saveConfig(config); + } satisfies NonNullable; + try { + installClaudeCodeHook(); + persistAutoPush(config, next); + config.auto_push = next; + } catch (error) { + rollbackAutoPush(next, previous); + throw error; + } posthog.capture({ distinctId: getDistinctId(config), @@ -75,13 +118,23 @@ export function enableAutoPush( const scheduler = detectScheduler(); - // Disable existing (scheduler or hooks) before switching + const previous = config.auto_push; disableExisting(config); - installScheduler(resolvedTime, scheduler); - - config.auto_push = { enabled: true, time: resolvedTime, scheduler, mechanism: "scheduler" }; - saveConfig(config); + const next = { + enabled: true, + time: resolvedTime, + scheduler, + mechanism: "scheduler", + } satisfies NonNullable; + try { + installScheduler(resolvedTime, scheduler); + persistAutoPush(config, next); + config.auto_push = next; + } catch (error) { + rollbackAutoPush(next, previous); + throw error; + } posthog.capture({ distinctId: getDistinctId(config), @@ -101,10 +154,15 @@ export function disableAutoPush(config: StraudeConfig): void { return; } + const previous = config.auto_push; disableExisting(config); - - delete config.auto_push; - saveConfig(config); + try { + persistAutoPush(config, undefined); + delete config.auto_push; + } catch (error) { + if (previous) installConfiguredAutoPush(previous); + throw error; + } posthog.capture({ distinctId: getDistinctId(config), diff --git a/packages/cli/src/commands/devices.ts b/packages/cli/src/commands/devices.ts new file mode 100644 index 00000000..4286d054 --- /dev/null +++ b/packages/cli/src/commands/devices.ts @@ -0,0 +1,156 @@ +import { apiRequest, ApiHttpError, ApiTimeoutError } from "../lib/api.js"; +import { loadConfig } from "../lib/auth.js"; + +interface UsageDeviceCandidate { + id: string; + device_id_a: string; + device_id_b: string; + normalized_hostname: string; + overlap_dates: string[]; + status: string; + created_at: string; +} + +interface ResolvedCandidate { + id: string; + status: string; + decision: "merge" | "keep_separate"; + canonical_device_id?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCandidates(value: unknown): UsageDeviceCandidate[] { + if (!isRecord(value) || !Array.isArray(value.candidates)) { + throw new Error("Invalid device-candidate response."); + } + return value.candidates.map((candidate) => { + if ( + !isRecord(candidate) + || typeof candidate.id !== "string" + || typeof candidate.device_id_a !== "string" + || typeof candidate.device_id_b !== "string" + || typeof candidate.normalized_hostname !== "string" + || !Array.isArray(candidate.overlap_dates) + || candidate.overlap_dates.some((date) => typeof date !== "string") + || typeof candidate.status !== "string" + || typeof candidate.created_at !== "string" + ) { + throw new Error("Invalid device candidate."); + } + return { + id: candidate.id, + device_id_a: candidate.device_id_a, + device_id_b: candidate.device_id_b, + normalized_hostname: candidate.normalized_hostname, + overlap_dates: candidate.overlap_dates as string[], + status: candidate.status, + created_at: candidate.created_at, + }; + }); +} + +function parseResolved(value: unknown): ResolvedCandidate { + if (!isRecord(value) || !isRecord(value.candidate)) { + throw new Error("Invalid device-resolution response."); + } + const candidate = value.candidate; + if ( + typeof candidate.id !== "string" + || typeof candidate.status !== "string" + || (candidate.decision !== "merge" && candidate.decision !== "keep_separate") + || ( + candidate.canonical_device_id !== undefined + && typeof candidate.canonical_device_id !== "string" + ) + ) { + throw new Error("Invalid resolved device candidate."); + } + return { + id: candidate.id, + status: candidate.status, + decision: candidate.decision, + ...(typeof candidate.canonical_device_id === "string" + ? { canonical_device_id: candidate.canonical_device_id } + : {}), + }; +} + +function classifyError(error: unknown): number { + if (error instanceof ApiHttpError) { + if (error.status === 401) return 2; + return error.retryable ? 75 : 1; + } + if (error instanceof ApiTimeoutError || error instanceof TypeError) return 75; + return 1; +} + +export async function devicesCommand( + subcommand: string | null, + candidateId: string | null, +): Promise { + const config = loadConfig(); + if (!config) { + console.error("AUTH_REQUIRED: Run `straude login` before managing devices."); + return 2; + } + + try { + if (subcommand === null) { + const response = await apiRequest( + config, + "/api/usage/devices", + { timeoutMs: 15_000, maxRetries: 2 }, + ); + const candidates = parseCandidates(response); + if (candidates.length === 0) { + console.log("No unresolved device identity candidates."); + return 0; + } + console.log("Unresolved device identity candidates:"); + for (const candidate of candidates) { + console.log( + `${candidate.id} ${candidate.normalized_hostname} ${candidate.overlap_dates.length} matching days`, + ); + console.log(` merge: straude devices merge ${candidate.id}`); + console.log(` keep separate: straude devices keep-separate ${candidate.id}`); + } + return 0; + } + + if (!candidateId) { + console.error(`devices ${subcommand} requires a candidate UUID.`); + return 1; + } + const decision = subcommand === "merge" ? "merge" : "keep_separate"; + const response = await apiRequest( + config, + "/api/usage/devices/resolve", + { + method: "POST", + body: JSON.stringify({ candidate_id: candidateId, decision }), + timeoutMs: 15_000, + maxRetries: 2, + }, + ); + const resolved = parseResolved(response); + if (resolved.decision === "merge") { + console.log( + `Merged device candidate ${resolved.id}${resolved.canonical_device_id ? ` into ${resolved.canonical_device_id}` : ""}.`, + ); + } else { + console.log(`Kept device candidate ${resolved.id} separate.`); + } + return 0; + } catch (error) { + const code = classifyError(error); + console.error( + code === 2 + ? "AUTH_REQUIRED: Run `straude login` before managing devices." + : `Failed to manage devices: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return code; + } +} diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index c819d1af..5897e33f 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -1,9 +1,10 @@ import { spawn } from "node:child_process"; import { CONFIG_FILE, DEFAULT_API_URL, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "../config.js"; -import { loadConfig, saveConfig } from "../lib/auth.js"; -import { apiRequestNoAuth } from "../lib/api.js"; +import { updateConfig } from "../lib/auth.js"; +import { ApiHttpError, apiRequestNoAuth } from "../lib/api.js"; import { posthog } from "../lib/posthog.js"; import { getDistinctId, getMachineId } from "../lib/machine-id.js"; +import { isInteractive } from "../lib/prompt.js"; interface CliInitResponse { code: string; @@ -62,7 +63,44 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export async function loginCommand(apiUrlOverride?: string): Promise { +export interface LoginOptions { + /** Reject before opening a browser when invoked by a background process. */ + requireInteractive?: boolean; + /** Useful for remote terminals where the URL must be opened manually. */ + openBrowser?: boolean; +} + +export class NonInteractiveLoginError extends Error { + constructor() { + super( + "Authentication requires an interactive terminal. Run `straude login` " + + "in a terminal before using auto-push or CI.", + ); + this.name = "NonInteractiveLoginError"; + } +} + +export class LoginCommandError extends Error { + constructor(message: string) { + super(message); + this.name = "LoginCommandError"; + } +} + +export function assertInteractiveLogin(): void { + if (!isInteractive()) throw new NonInteractiveLoginError(); +} + +function pollDelayMs(failures: number, retryAfterMs: number | null): number { + if (retryAfterMs != null) return Math.min(retryAfterMs, 10_000); + return Math.min(POLL_INTERVAL_MS * 2 ** Math.min(failures, 3), 10_000); +} + +export async function loginCommand( + apiUrlOverride?: string, + options: LoginOptions = {}, +): Promise { + if (options.requireInteractive) assertInteractiveLogin(); const apiUrl = apiUrlOverride ?? DEFAULT_API_URL; console.log("Opening browser for authentication..."); @@ -71,54 +109,80 @@ export async function loginCommand(apiUrlOverride?: string): Promise { try { initRes = await apiRequestNoAuth(apiUrl, "/api/auth/cli/init", { method: "POST", + timeoutMs: 10_000, + maxRetries: 2, }); } catch (err) { - console.error(`Failed to start login: ${(err as Error).message}`); - process.exit(1); + throw new LoginCommandError(`Failed to start login: ${(err as Error).message}`); } const { code, verify_url, poll_secret } = initRes; if (!poll_secret) { - console.error("Failed to start login: server did not return a poll secret. Please update Straude and try again."); - process.exit(1); + throw new LoginCommandError( + "Failed to start login: server did not return a poll secret. " + + "Please update Straude and try again.", + ); } - openBrowser(verify_url); + if (options.openBrowser !== false) openBrowser(verify_url); console.log(`\nIf the browser didn't open, visit:\n ${verify_url}\n`); console.log("Confirm in the browser, then keep this terminal open — Straude will continue syncing here."); process.stdout.write("Waiting for confirmation..."); const startTime = Date.now(); + const deadlineAt = startTime + POLL_TIMEOUT_MS; + let nextDelayMs = POLL_INTERVAL_MS; - while (Date.now() - startTime < POLL_TIMEOUT_MS) { - await sleep(POLL_INTERVAL_MS); + while (Date.now() < deadlineAt) { + await sleep(Math.min(nextDelayMs, Math.max(0, deadlineAt - Date.now()))); + if (Date.now() >= deadlineAt) break; let pollRes: CliPollResponse; try { pollRes = await apiRequestNoAuth(apiUrl, "/api/auth/cli/poll", { method: "POST", body: JSON.stringify({ code, poll_secret }), + timeoutMs: 10_000, + deadlineAt, + maxRetries: 0, }); - } catch { - // Network errors during polling are transient, keep trying + nextDelayMs = POLL_INTERVAL_MS; + } catch (error) { + if (error instanceof ApiHttpError && !error.retryable) { + process.stdout.write(" failed\n\n"); + throw new LoginCommandError( + `Login failed while waiting for confirmation: ${error.message}`, + ); + } + nextDelayMs = pollDelayMs( + Math.max(1, Math.round(nextDelayMs / POLL_INTERVAL_MS)), + error instanceof ApiHttpError ? error.retryAfterMs : null, + ); continue; } if (pollRes.status === "completed" && pollRes.token) { process.stdout.write(" done\n\n"); - const existing = loadConfig(); - const sameIdentity = - existing != null && - existing.api_url === apiUrl && - existing.username === (pollRes.username ?? ""); - saveConfig({ - token: pollRes.token, - username: pollRes.username ?? "", - api_url: apiUrl, - last_push_date: sameIdentity ? existing.last_push_date : undefined, - device_id: sameIdentity ? existing.device_id : undefined, - device_name: sameIdentity ? existing.device_name : undefined, + let sameIdentity = false; + updateConfig((existing) => { + sameIdentity = + existing != null && + existing.api_url === apiUrl && + existing.username === (pollRes.username ?? ""); + if (sameIdentity) { + return { + ...existing, + token: pollRes.token!, + username: pollRes.username ?? "", + api_url: apiUrl, + }; + } + return { + token: pollRes.token!, + username: pollRes.username ?? "", + api_url: apiUrl, + }; }); const username = pollRes.username ?? ""; @@ -142,8 +206,7 @@ export async function loginCommand(apiUrlOverride?: string): Promise { if (pollRes.status === "expired") { process.stdout.write(" expired\n\n"); - console.error("Login code expired. Please try again."); - process.exit(1); + throw new LoginCommandError("Login code expired. Please try again."); } // Still pending, continue polling @@ -151,6 +214,5 @@ export async function loginCommand(apiUrlOverride?: string): Promise { } process.stdout.write(" timed out\n\n"); - console.error("Login timed out. Please try again."); - process.exit(1); + throw new LoginCommandError("Login timed out. Please try again."); } diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts old mode 100755 new mode 100644 index 7e271a43..3cc7c240 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -1,72 +1,80 @@ import { createHash, randomUUID } from "node:crypto"; import { hostname } from "node:os"; import { performance } from "node:perf_hooks"; -import { loadConfig, updateLastPushDate, saveConfig } from "../lib/auth.js"; -import type { StraudeConfig } from "../lib/auth.js"; -import { loginCommand } from "./login.js"; -import { apiRequest } from "../lib/api.js"; import { - CCUSAGE_CLAUDE_COLLECTOR, - CCUSAGE_CODEX_COLLECTOR, + canonicalizeUsageEntryV2, + parseUsageSubmitResponseV2, + parseUsageSubmitV2, + type AgentUsageComponent, + type UsageEntryV2, + type UsageOutcomeV2, + type UsageSubmitRequestV2, + type UsageSubmitResultV2, +} from "@straude/shared/usage-protocol"; +import { MAX_BACKFILL_DAYS, DEFAULT_SYNC_DAYS, CLI_VERSION } from "../config.js"; +import { apiRequest, ApiHttpError, ApiTimeoutError } from "../lib/api.js"; +import { loadConfig, updateConfig, type StraudeConfig } from "../lib/auth.js"; +import { + addCalendarDays, + assertCalendarDate, + calendarDateToLocalDate, + calendarDaysBetween, + compactCalendarDate, + listCalendarDates, + localDateToCalendarDate, +} from "../lib/calendar.js"; +import { CCUSAGE_DEFAULT_PRICING_MODE, + PricingUnavailableError, collectCcusageUsageAsync, + resolveLocalTimezone, + type CcusageAgentEntry, + type CcusageCollectorMeta, + type CcusageDailyEntry, } from "../lib/ccusage.js"; -import type { CcusageDailyEntry, CcusageCollectorMeta } from "../lib/ccusage.js"; -import { MAX_BACKFILL_DAYS, DEFAULT_SYNC_DAYS } from "../config.js"; -import { Spinner } from "../lib/spinner.js"; -import type { DashboardData as DashboardResponse } from "../components/PushSummary.js"; +import { getDistinctId, getInstallationId } from "../lib/machine-id.js"; +import { isInteractive } from "../lib/prompt.js"; import { posthog } from "../lib/posthog.js"; -import { getDistinctId } from "../lib/machine-id.js"; +import { Spinner } from "../lib/spinner.js"; +import { + acquireSyncLease, + loadPendingBatches, + removePendingBatch, + upsertPendingBatch, + type PendingRangeMode, + type PendingUsageBatch, +} from "../lib/sync-state.js"; import { TELEMETRY_SHUTDOWN_TIMEOUT_MS, errorMessage, reportUsagePushFailed, shutdownTelemetryWithTimeout, } from "../lib/telemetry.js"; +import { NonInteractiveLoginError, loginCommand } from "./login.js"; +import type { DashboardData as DashboardResponse } from "../components/PushSummary.js"; -interface UsageSubmitRequest { - entries: Array<{ - date: string; - data: CcusageDailyEntry; - }>; - hash?: string; - collector?: { - claude?: typeof CCUSAGE_CLAUDE_COLLECTOR; - codex?: typeof CCUSAGE_CODEX_COLLECTOR; - ccusage_version?: CcusageCollectorMeta["ccusage_version"]; - ccusage_agents?: CcusageCollectorMeta["ccusage_agents"]; - pricing_mode?: CcusageCollectorMeta["pricing_mode"]; - }; - source: "cli" | "web"; - device_id?: string; - device_name?: string; -} +const FIRST_OR_MIGRATION_SYNC_DAYS = 3; +const SUBMIT_DEADLINE_MS = 15_000; +const DASHBOARD_DEADLINE_MS = 3_000; +const PROTOCOL_RETRY_ATTEMPTS = 3; +const MIGRATION_ID = "ccusage-by-agent-v2"; -interface UsageSubmitResponse { - results: Array<{ - date: string; - usage_id: string; - post_id: string; - post_url: string; - action: "created" | "updated"; - previous_cost?: number; - daily_total?: number; - device_count?: number; - }>; -} +export const CLI_EXIT = { + OK: 0, + PERMANENT: 1, + AUTH_REQUIRED: 2, + TEMPORARY: 75, +} as const; -interface PushOptions { +export interface PushOptions { date?: string; days?: number; dryRun?: boolean; timeoutMs?: number; + nonInteractive?: boolean; } -type PushRangeMode = - | "explicit_date" - | "explicit_days" - | "incremental" - | "first_sync"; +type PushRangeMode = PendingRangeMode; interface PushTimings { auth_ms?: number; @@ -75,259 +83,589 @@ interface PushTimings { dashboard_ms?: number; } -interface DashboardRenderResult { - rendered: boolean; - durationMs: number; +interface DateRangeResolutionSuccess { + ok: true; + since: Date; + until: Date; + mode: PushRangeMode; } -function formatDate(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} +export type DateRangeResolution = + | DateRangeResolutionSuccess + | { ok: false; error: string }; -function formatDateCompact(d: Date): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}${m}${day}`; +interface SubmitBatchResult { + complete: boolean; + exitCode: number; + results: DatedUsageResult[]; + identityConflict: boolean; + retryCount: number; } -function parseDate(dateStr: string): Date { - const [y, m, d] = dateStr.split("-").map(Number); - return new Date(y!, m! - 1, d); +type DatedUsageResult = UsageSubmitResultV2 & { date: string }; + +function formatTokens(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${Math.round(value / 1_000)}k`; + return String(value); } -function daysBetween(a: Date, b: Date): number { - const msPerDay = 86_400_000; - return Math.round(Math.abs(a.getTime() - b.getTime()) / msPerDay); +function formatCost(value: number): string { + return `$${value.toFixed(2)}`; } -function daysBetweenStrings(dateStrA: string, dateStrB: string): number { - const [ay, am, ad] = dateStrA.split("-").map(Number); - const [by, bm, bd] = dateStrB.split("-").map(Number); - const a = new Date(ay!, am! - 1, ad!); - const b = new Date(by!, bm! - 1, bd!); - const msPerDay = 86_400_000; - return Math.round((b.getTime() - a.getTime()) / msPerDay); +function elapsedMs(start: number): number { + return Math.round(performance.now() - start); } -/** - * Mirrors the server's backfill-window check (apps/web/app/api/usage/submit/ - * route.ts). Pre-filtering on the client keeps a single edge-case row from - * failing the whole submit with HTTP 400. - */ -export function isWithinBackfillWindow(dateStr: string): boolean { - const now = Date.now(); - const target = new Date(dateStr).getTime(); - if (Number.isNaN(target)) return false; - const diffDays = (now - target) / 86_400_000; - return diffDays >= -1 && diffDays <= MAX_BACKFILL_DAYS; +function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return count === 1 ? singular : plural; } -export type DateRangeResolution = - | { ok: true; since: Date; until: Date; mode: PushRangeMode } - | { ok: false; error: string }; +function dateFromInput(value: string): Date { + return calendarDateToLocalDate(assertCalendarDate(value)); +} -/** - * Pure resolver for the date range a push should cover. Extracted from - * `pushCommand` so each branch (explicit --date, ccusage v20 migration, --days, - * smart-sync from last_push_date, fresh install) can be unit-tested without - * mocking ccusage / the API / the filesystem. - */ export function resolvePushDateRange(args: { today: Date; options: { date?: string; days?: number }; lastPushDate?: string; shouldRunMigrationBackfill: boolean; }): DateRangeResolution { - const { today, options, lastPushDate } = args; - const todayStr = formatDate(today); + const { options, shouldRunMigrationBackfill } = args; + const today = localDateToCalendarDate(args.today); - if (options.date) { - const target = parseDate(options.date); - if (daysBetween(today, target) > MAX_BACKFILL_DAYS) { + if (options.date !== undefined) { + let target: string; + try { + target = assertCalendarDate(options.date); + } catch (error) { + return { ok: false, error: errorMessage(error) }; + } + const age = calendarDaysBetween(target, today); + if (age < 0) return { ok: false, error: "Cannot push usage for a future date." }; + if (age > MAX_BACKFILL_DAYS) { return { ok: false, error: `Date must be within the last ${MAX_BACKFILL_DAYS} days.` }; } - if (target > today) { - return { ok: false, error: "Cannot push usage for a future date." }; + const date = dateFromInput(target); + return { ok: true, since: date, until: date, mode: "explicit_date" }; + } + + if (options.days !== undefined) { + if ( + !Number.isSafeInteger(options.days) + || options.days < 1 + || options.days > MAX_BACKFILL_DAYS + ) { + return { + ok: false, + error: `Days must be an integer between 1 and ${MAX_BACKFILL_DAYS}.`, + }; } - return { ok: true, since: target, until: target, mode: "explicit_date" }; + return { + ok: true, + since: dateFromInput(addCalendarDays(today, -options.days + 1)), + until: dateFromInput(today), + mode: "explicit_days", + }; } - if (options.days) { - const days = Math.min(options.days, MAX_BACKFILL_DAYS); - const since = new Date(today); - since.setDate(since.getDate() - days + 1); - return { ok: true, since, until: today, mode: "explicit_days" }; + if (!args.lastPushDate) { + return { + ok: true, + since: dateFromInput(addCalendarDays(today, -FIRST_OR_MIGRATION_SYNC_DAYS + 1)), + until: dateFromInput(today), + mode: "first_sync", + }; } - if (lastPushDate) { - if (lastPushDate >= todayStr) { - return { ok: true, since: new Date(today), until: new Date(today), mode: "incremental" }; - } - const gap = daysBetweenStrings(lastPushDate, todayStr); - if (gap > DEFAULT_SYNC_DAYS) { - const since = new Date(today); - since.setDate(since.getDate() - DEFAULT_SYNC_DAYS + 1); - return { ok: true, since, until: today, mode: "incremental" }; - } - return { ok: true, since: parseDate(lastPushDate), until: today, mode: "incremental" }; + if (shouldRunMigrationBackfill) { + return { + ok: true, + since: dateFromInput(addCalendarDays(today, -FIRST_OR_MIGRATION_SYNC_DAYS + 1)), + until: dateFromInput(today), + mode: "migration", + }; } - // Never pushed before — backfill last 3 days by default - const FIRST_RUN_BACKFILL_DAYS = 3; - const since = new Date(today); - since.setDate(since.getDate() - FIRST_RUN_BACKFILL_DAYS + 1); - return { ok: true, since, until: today, mode: "first_sync" }; + try { + assertCalendarDate(args.lastPushDate, "last push date"); + } catch (error) { + return { ok: false, error: errorMessage(error) }; + } + if (args.lastPushDate >= today) { + const date = dateFromInput(today); + return { ok: true, since: date, until: date, mode: "incremental" }; + } + + const since = addCalendarDays(args.lastPushDate, 1); + const until = calendarDaysBetween(since, today) >= DEFAULT_SYNC_DAYS + ? addCalendarDays(since, DEFAULT_SYNC_DAYS - 1) + : today; + return { + ok: true, + since: dateFromInput(since), + until: dateFromInput(until), + mode: "incremental", + }; } -function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${Math.round(n / 1_000)}k`; - return String(n); +export function isWithinBackfillWindow(date: string, today = new Date()): boolean { + try { + assertCalendarDate(date); + const age = calendarDaysBetween(date, localDateToCalendarDate(today)); + return age >= 0 && age <= MAX_BACKFILL_DAYS; + } catch { + return false; + } } -function formatCost(n: number): string { - return `$${n.toFixed(2)}`; +function toWireAgent(agent: CcusageAgentEntry): AgentUsageComponent { + return { + agent: agent.agent, + models: [...agent.models].sort(), + input_tokens: agent.inputTokens, + output_tokens: agent.outputTokens, + reasoning_output_tokens: agent.reasoningOutputTokens, + cache_creation_tokens: agent.cacheCreationTokens, + cache_read_tokens: agent.cacheReadTokens, + total_tokens: agent.totalTokens, + cost_usd: agent.costUSD, + model_breakdown: agent.modelBreakdown.map((model) => ({ + model: model.model, + input_tokens: model.inputTokens, + output_tokens: model.outputTokens, + reasoning_output_tokens: model.reasoningOutputTokens, + cache_creation_tokens: model.cacheCreationTokens, + cache_read_tokens: model.cacheReadTokens, + total_tokens: model.totalTokens, + cost_usd: model.cost_usd, + })), + }; } -function elapsedMs(start: number): number { - return Math.round(performance.now() - start); +function createUsageEntry( + entry: CcusageDailyEntry, + migration: boolean, +): UsageEntryV2 { + const withoutHash: UsageEntryV2 = { + date: entry.date, + content_hash: "0".repeat(64), + agents: entry.agentBreakdown.map(toWireAgent), + ...(migration + ? { + authoritative_correction: true, + migration_id: MIGRATION_ID, + } + : {}), + }; + return { + ...withoutHash, + content_hash: createHash("sha256") + .update(canonicalizeUsageEntryV2(withoutHash)) + .digest("hex"), + }; } -function inclusiveDayCount(since: Date, until: Date): number { - return daysBetween(since, until) + 1; +function createRequest(args: { + config: StraudeConfig; + timezone: string; + collector: CcusageCollectorMeta; + entries: CcusageDailyEntry[]; + migration: boolean; +}): UsageSubmitRequestV2 { + const installationId = getInstallationId(); + const previousDeviceId = !args.config.previous_device_id_migrated_at + && args.config.device_id !== installationId + ? args.config.device_id + : undefined; + const request: UsageSubmitRequestV2 = { + protocol_version: 2, + request_id: randomUUID(), + source: "cli", + timezone: args.timezone, + installation: { + id: installationId, + ...(previousDeviceId ? { previous_device_id: previousDeviceId } : {}), + name: args.config.device_name ?? hostname(), + }, + collector: { + name: "ccusage", + version: args.collector.ccusage_version, + pricing_mode: args.collector.pricing_mode, + metadata: { + agents: args.collector.ccusage_agents, + ...(args.collector.claude ? { claude: args.collector.claude } : {}), + ...(args.collector.codex ? { codex: args.collector.codex } : {}), + }, + }, + entries: args.entries.map((entry) => createUsageEntry(entry, args.migration)), + }; + const parsed = parseUsageSubmitV2(request); + if (!parsed.ok) { + throw new Error( + `Refusing to persist an invalid usage request (${parsed.error.code} at ${parsed.error.path ?? "request"}): ${parsed.error.message}`, + ); + } + return parsed.value; } -function pluralize(count: number, singular: string, plural = `${singular}s`): string { - return count === 1 ? singular : plural; +function classifySubmitError(error: unknown, interactive: boolean): number { + if (error instanceof ApiHttpError) { + if (error.status === 401 && !interactive) return CLI_EXIT.AUTH_REQUIRED; + if (error.retryable) return CLI_EXIT.TEMPORARY; + return CLI_EXIT.PERMANENT; + } + if ( + error instanceof ApiTimeoutError + || error instanceof TypeError + || (error instanceof Error && [ + "ECONNRESET", + "ECONNREFUSED", + "EAI_AGAIN", + "ENETUNREACH", + ].includes((error as NodeJS.ErrnoException).code ?? "")) + ) { + return CLI_EXIT.TEMPORARY; + } + return CLI_EXIT.PERMANENT; } -function pushTelemetryProperties(args: { - timings: PushTimings; - totalStartedAt: number; - rangeMode: PushRangeMode; - firstRun: boolean; - authFlowStarted: boolean; - migrationPending: boolean; - fullBackfillCompleted: boolean; - pricingMode?: CcusageCollectorMeta["pricing_mode"]; - ccusageVersion?: string; - ccusageAgents?: CcusageCollectorMeta["ccusage_agents"]; - dashboardRendered?: boolean; -}): Record { - return { - first_run: args.firstRun, - auth_flow_started: args.authFlowStarted, - backfill_mode: args.rangeMode, - migration_backfill_pending: args.migrationPending, - full_backfill_completed: args.fullBackfillCompleted, - pricing_mode: args.pricingMode, - ccusage_version: args.ccusageVersion, - ccusage_agents: args.ccusageAgents, - dashboard_rendered: args.dashboardRendered, - telemetry_shutdown_timeout_ms: TELEMETRY_SHUTDOWN_TIMEOUT_MS, - total_ms: elapsedMs(args.totalStartedAt), - ...args.timings, - }; +function outcomeExitCode(outcomes: UsageOutcomeV2[]): number { + if (outcomes.some((outcome) => ( + outcome.status === "permanent_error" || outcome.status === "identity_conflict" + ))) { + return CLI_EXIT.PERMANENT; + } + if (outcomes.some((outcome) => outcome.status === "retryable_error")) { + return CLI_EXIT.TEMPORARY; + } + return CLI_EXIT.OK; } -async function renderDashboard( +function sleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +async function submitBatch( config: StraudeConfig, - results: UsageSubmitResponse["results"] | undefined, -): Promise { - const startedAt = performance.now(); - try { - const dashboard = await apiRequest(config, "/api/cli/dashboard"); - const { render } = await import("ink"); - const { createElement } = await import("react"); - const { PushSummary } = await import("../components/PushSummary.js"); + batch: PendingUsageBatch, + interactive: boolean, +): Promise { + const submitDeadline = Date.now() + SUBMIT_DEADLINE_MS; + const terminal = new Map(); + let retryable = [...batch.request.entries]; + let remaining = [...batch.request.entries]; + let lastAttempt = 0; + + for (let attempt = 0; attempt < PROTOCOL_RETRY_ATTEMPTS && retryable.length > 0; attempt += 1) { + lastAttempt = attempt; + const request: UsageSubmitRequestV2 = { + ...batch.request, + entries: retryable, + }; + try { + const rawResponse = await apiRequest( + config, + "/api/usage/submit", + { + method: "POST", + body: JSON.stringify(request), + headers: { + "X-Straude-CLI-Version": CLI_VERSION, + "X-Straude-Retry-Attempt": String(attempt), + }, + timeoutMs: Math.max(1, submitDeadline - Date.now()), + deadlineAt: submitDeadline, + maxRetries: 0, + acceptedStatuses: [400, 409, 503], + }, + ); + const parsed = parseUsageSubmitResponseV2(rawResponse); + if (!parsed.ok) { + throw new Error( + `Invalid v2 usage response (${parsed.error.code}): ${parsed.error.message}`, + ); + } + if (parsed.value.request_id !== batch.request.request_id) { + throw new Error("Usage response request_id did not match the submitted request."); + } + const submittedDates = new Set(retryable.map((entry) => entry.date)); + const outcomeDates = new Set(parsed.value.outcomes.map((outcome) => outcome.date)); + if ( + outcomeDates.size !== submittedDates.size + || [...submittedDates].some((date) => !outcomeDates.has(date)) + ) { + throw new Error("Usage response did not include exactly one outcome per submitted date."); + } - const { waitUntilExit } = render( - createElement(PushSummary, { - dashboard, - results, - }), - ); - await waitUntilExit(); + for (const outcome of parsed.value.outcomes) terminal.set(outcome.date, outcome); + const successfulDates = new Set( + [...terminal.values()] + .filter((outcome) => outcome.status === "committed" || outcome.status === "unchanged") + .map((outcome) => outcome.date), + ); + remaining = batch.request.entries.filter((entry) => !successfulDates.has(entry.date)); + const queued = batch.request.entries.filter((entry) => { + const status = terminal.get(entry.date)?.status; + return status === "retryable_error" || status === "identity_conflict"; + }); + const permanentDates = new Set( + [...terminal.values()] + .filter((outcome) => outcome.status === "permanent_error") + .map((outcome) => outcome.date), + ); + if (queued.length > 0) { + const firstPermanentIndex = batch.requested_dates.findIndex( + (date) => permanentDates.has(date), + ); + const originalWatermarkIndex = batch.watermark_date + ? batch.requested_dates.indexOf(batch.watermark_date) + : -1; + const safeWatermarkIndex = firstPermanentIndex === -1 + ? originalWatermarkIndex + : Math.min(originalWatermarkIndex, firstPermanentIndex - 1); + const { watermark_date: _watermark, ...batchWithoutWatermark } = batch; + upsertPendingBatch({ + ...batchWithoutWatermark, + request: { ...batch.request, entries: queued }, + ...(safeWatermarkIndex >= 0 + ? { watermark_date: batch.requested_dates[safeWatermarkIndex] } + : {}), + migration_pending: batch.migration_pending && permanentDates.size === 0, + }); + } else if (permanentDates.size > 0) { + removePendingBatch(batch.request.request_id); + } + retryable = queued.filter((entry) => terminal.get(entry.date)?.status === "retryable_error"); + + if (retryable.length > 0 && attempt + 1 < PROTOCOL_RETRY_ATTEMPTS) { + const retryAfter = Math.max( + 0, + ...retryable.map((entry) => terminal.get(entry.date)?.error?.retry_after_ms ?? 0), + ); + const delay = retryAfter > 0 ? retryAfter : Math.random() * 500 * 2 ** attempt; + if (Date.now() + delay >= submitDeadline) break; + await sleep(delay); + } + } catch (error) { + const exitCode = classifySubmitError(error, interactive); + if (exitCode === CLI_EXIT.TEMPORARY && attempt + 1 < PROTOCOL_RETRY_ATTEMPTS) { + const retryAfter = error instanceof ApiHttpError + ? error.retryAfterMs ?? 0 + : 0; + const delay = retryAfter > 0 + ? retryAfter + : Math.random() * 500 * 2 ** attempt; + if (Date.now() + delay < submitDeadline) { + await sleep(delay); + continue; + } + } + reportUsagePushFailed(config, error, { + command: "push", + stage: "submit", + request_id: batch.request.request_id, + retry_count: attempt, + error_code: error instanceof ApiHttpError ? `HTTP_${error.status}` : "SUBMIT_FAILED", + }); + return { + complete: false, + exitCode, + results: [], + identityConflict: false, + retryCount: attempt, + }; + } + } + + const outcomes = remaining + .map((entry) => terminal.get(entry.date)) + .filter((outcome): outcome is UsageOutcomeV2 => outcome !== undefined); + const exitCode = outcomeExitCode(outcomes); + const results = [...terminal.values()] + .flatMap((outcome): DatedUsageResult[] => ( + outcome.result ? [{ date: outcome.date, ...outcome.result }] : [] + )); + if (remaining.length === 0) { return { - rendered: true, - durationMs: elapsedMs(startedAt), + complete: true, + exitCode: CLI_EXIT.OK, + results, + identityConflict: false, + retryCount: lastAttempt, }; - } catch { + } + return { + complete: false, + exitCode: exitCode === CLI_EXIT.OK ? CLI_EXIT.TEMPORARY : exitCode, + results, + identityConflict: outcomes.some((outcome) => outcome.status === "identity_conflict"), + retryCount: lastAttempt, + }; +} + +function advanceCompletedBatch(batch: PendingUsageBatch): StraudeConfig { + return updateConfig((current) => { + if (!current) throw new Error("Authentication disappeared while syncing."); + const { + codex_native_repair_completed_at: _obsoleteRepair, + codex_native_last_token_usage_repair_completed_at: _obsoleteLastTokenRepair, + ccusage_v20_migration_completed_at: _obsoleteV20Migration, + ...preserved + } = current; + const automatic = batch.range_mode === "incremental" + || batch.range_mode === "first_sync" + || batch.range_mode === "migration"; + const lastDate = batch.watermark_date; return { - rendered: false, - durationMs: elapsedMs(startedAt), + ...preserved, + ...(automatic && lastDate ? { last_push_date: lastDate } : {}), + ...(batch.migration_pending + ? { usage_protocol_v2_migration_completed_at: new Date().toISOString() } + : {}), + ...(batch.request.installation.previous_device_id + ? { previous_device_id_migrated_at: new Date().toISOString() } + : {}), }; - } + }); } -function printSubmitSuccess(args: { - entries: CcusageDailyEntry[]; - results: UsageSubmitResponse["results"]; - totalCost: number; - totalTokens: number; - migrationPending: boolean; - fullBackfillCompleted: boolean; -}): void { - const { entries, results, totalCost, totalTokens, migrationPending, fullBackfillCompleted } = args; - const created = results.filter((r) => r.action === "created").length; - const updated = results.filter((r) => r.action === "updated").length; - const primaryResult = results[0]; +function printDryRun(entries: CcusageDailyEntry[]): void { + for (const entry of entries) { + console.log(` ${entry.date}:`); + console.log(` Cost: ${formatCost(entry.costUSD)}`); + console.log( + ` Tokens: ${formatTokens(entry.totalTokens)} (input: ${formatTokens(entry.inputTokens)}, output: ${formatTokens(entry.outputTokens)})`, + ); + for (const agent of entry.agentBreakdown) { + console.log( + ` ${agent.agent}: ${formatCost(agent.costUSD)}, ${formatTokens(agent.totalTokens)} tokens`, + ); + } + } + console.log("\n(dry run, nothing submitted)"); +} +function printSubmitSuccess( + entries: CcusageDailyEntry[], + results: DatedUsageResult[], +): void { + const totalCost = entries.reduce((sum, entry) => sum + entry.costUSD, 0); + const totalTokens = entries.reduce((sum, entry) => sum + entry.totalTokens, 0); + const created = results.filter((result) => result.action === "created").length; + const updated = results.filter((result) => result.action === "updated").length; console.log(""); console.log( `Synced ${entries.length} ${pluralize(entries.length, "day")} (${formatCost(totalCost)}, ${formatTokens(totalTokens)} tokens).`, ); - if (created > 0 || updated > 0) { - console.log(`Posted ${created}, updated ${updated}.`); - } - if (primaryResult) { - console.log(`View it: ${primaryResult.post_url}${primaryResult.post_url.includes("?") ? "&" : "?"}edit=1`); + if (created > 0 || updated > 0) console.log(`Posted ${created}, updated ${updated}.`); + const primary = results[0]; + if (primary) { + console.log(`View it: ${primary.post_url}${primary.post_url.includes("?") ? "&" : "?"}edit=1`); } - if (migrationPending && !fullBackfillCompleted) { - console.log(`Optional: backfill your last ${MAX_BACKFILL_DAYS} days with \`straude push --days ${MAX_BACKFILL_DAYS}\`.`); +} + +async function renderDashboard( + config: StraudeConfig, + results: DatedUsageResult[], +): Promise<{ rendered: boolean; durationMs: number }> { + const startedAt = performance.now(); + try { + const dashboard = await apiRequest( + config, + "/api/cli/dashboard", + { timeoutMs: DASHBOARD_DEADLINE_MS, maxRetries: 0 }, + ); + const { render } = await import("ink"); + const { createElement } = await import("react"); + const { PushSummary } = await import("../components/PushSummary.js"); + const { waitUntilExit } = render(createElement(PushSummary, { dashboard, results })); + await waitUntilExit(); + return { rendered: true, durationMs: elapsedMs(startedAt) }; + } catch (error) { + console.log("Usage synced; dashboard unavailable."); + posthog.capture({ + distinctId: getDistinctId(config), + event: "dashboard_degraded", + properties: { + error_code: error instanceof ApiTimeoutError ? "DASHBOARD_TIMEOUT" : "DASHBOARD_UNAVAILABLE", + duration_ms: elapsedMs(startedAt), + }, + }); + return { rendered: false, durationMs: elapsedMs(startedAt) }; } } -export async function pushCommand(options: PushOptions, apiUrlOverride?: string): Promise { - const totalStartedAt = performance.now(); - const timings: PushTimings = {}; - let authFlowStarted = false; +function telemetryProperties(args: { + timings: PushTimings; + totalStartedAt: number; + rangeMode: PushRangeMode; + requestId?: string; + pricingMode?: string; + ccusageVersion?: string; + retryCount?: number; + pricingRetryCount?: number; +}): Record { + return { + range_mode: args.rangeMode, + request_id: args.requestId, + pricing_mode: args.pricingMode, + ccusage_version: args.ccusageVersion, + retry_count: args.retryCount, + pricing_retry_count: args.pricingRetryCount, + telemetry_shutdown_timeout_ms: TELEMETRY_SHUTDOWN_TIMEOUT_MS, + total_ms: elapsedMs(args.totalStartedAt), + ...args.timings, + }; +} + +async function authenticate( + apiUrlOverride: string | undefined, + nonInteractive: boolean, + timings: PushTimings, +): Promise { let config = loadConfig(); + if (config) return apiUrlOverride ? { ...config, api_url: apiUrlOverride } : config; + if (nonInteractive) { + console.error("AUTH_REQUIRED: Run `straude login` in an interactive terminal."); + return null; + } - // Login if needed - if (!config) { - authFlowStarted = true; - const authStartedAt = performance.now(); + const startedAt = performance.now(); + try { console.log("After authentication, Straude will continue into your first sync here."); - await loginCommand(apiUrlOverride); - timings.auth_ms = elapsedMs(authStartedAt); - config = loadConfig(); - if (!config) { - console.error("Login failed."); - process.exit(1); + await loginCommand(apiUrlOverride, { requireInteractive: true }); + } catch (error) { + if (error instanceof NonInteractiveLoginError) { + console.error(`AUTH_REQUIRED: ${error.message}`); + return null; } + throw error; + } finally { + timings.auth_ms = elapsedMs(startedAt); } - - // --api-url flag overrides the stored config URL - if (apiUrlOverride) { - config = { ...config, api_url: apiUrlOverride }; + config = loadConfig(); + if (!config) { + console.error("Authentication completed without a saved Straude config."); + return null; } + return apiUrlOverride ? { ...config, api_url: apiUrlOverride } : config; +} - // Ensure device_id exists — generate on first push - if (!config.device_id) { - config.device_id = randomUUID(); - config.device_name = hostname(); - saveConfig(config); - } +export async function pushCommand( + options: PushOptions, + apiUrlOverride?: string, +): Promise { + const totalStartedAt = performance.now(); + const timings: PushTimings = {}; + const nonInteractive = options.nonInteractive === true || !isInteractive(); + const config = await authenticate(apiUrlOverride, nonInteractive, timings); + if (!config) return CLI_EXIT.AUTH_REQUIRED; + const timezone = resolveLocalTimezone(); const today = new Date(); - const migrationPending = !config.ccusage_v20_migration_completed_at; - const firstRun = !config.last_push_date; - + const migrationPending = !config.usage_protocol_v2_migration_completed_at; const resolution = resolvePushDateRange({ today, options: { date: options.date, days: options.days }, @@ -336,232 +674,226 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) }); if (!resolution.ok) { console.error(resolution.error); - process.exit(1); + return CLI_EXIT.PERMANENT; } - const sinceDate = resolution.since; - const untilDate = resolution.until; - const rangeMode = resolution.mode; - const fullBackfillRequested = inclusiveDayCount(sinceDate, untilDate) >= MAX_BACKFILL_DAYS; - - const sinceStr = formatDateCompact(sinceDate); - const untilStr = formatDateCompact(untilDate); - console.log( - sinceDate.getTime() === untilDate.getTime() - ? `Pushing usage for ${formatDate(sinceDate)}...` - : `Pushing usage for ${formatDate(sinceDate)} to ${formatDate(untilDate)}...`, - ); + const since = localDateToCalendarDate(resolution.since); + const until = localDateToCalendarDate(resolution.until); + let requestedDates = listCalendarDates(since, until); + const automaticWatermarkDate = ( + resolution.mode === "incremental" + || resolution.mode === "first_sync" + || resolution.mode === "migration" + ) + ? requestedDates.at(-1) + : undefined; + const isMigrationBatch = resolution.mode === "first_sync" + || resolution.mode === "migration"; + const lease = await acquireSyncLease({ + dates: requestedDates, + interactive: !nonInteractive, + }); + if (!lease) { + if (nonInteractive) { + console.log("Sync already running; requested dates were safely queued."); + return CLI_EXIT.OK; + } + console.error("Another sync is still running after 30 seconds. Retry later."); + return CLI_EXIT.TEMPORARY; + } - const scanSpinner = new Spinner("scan"); - scanSpinner.start(); - let ccusage: Awaited>; - const collectionStartedAt = performance.now(); try { - ccusage = await collectCcusageUsageAsync(sinceStr, untilStr, options.timeoutMs, { - pricingMode: CCUSAGE_DEFAULT_PRICING_MODE, - }); - timings.collection_ms = elapsedMs(collectionStartedAt); - scanSpinner.stop(); - } catch (err) { - timings.collection_ms = elapsedMs(collectionStartedAt); - scanSpinner.stop(); - reportUsagePushFailed(config, err, { - command: "push", - stage: "scan", - ...pushTelemetryProperties({ - timings, - totalStartedAt, - rangeMode, - firstRun, - authFlowStarted, - migrationPending, - fullBackfillCompleted: false, - pricingMode: CCUSAGE_DEFAULT_PRICING_MODE, - }), - }); - await shutdownTelemetryWithTimeout(); - console.error(`\nFailed to collect usage: ${errorMessage(err)}`); - process.exit(1); - } + const queuedDatesToConsume = resolution.mode === "first_sync" || resolution.mode === "migration" + ? lease.queuedDates.filter((date) => requestedDates.includes(date)) + : lease.queuedDates; + if (queuedDatesToConsume.length > 0) { + const allDates = [...new Set([...requestedDates, ...queuedDatesToConsume])].sort(); + requestedDates = listCalendarDates(allDates[0]!, allDates.at(-1)!); + if (calendarDaysBetween(requestedDates[0]!, requestedDates.at(-1)!) >= MAX_BACKFILL_DAYS) { + console.error("Queued sync dates exceed the 30-day backfill window."); + return CLI_EXIT.PERMANENT; + } + } - // Drop entries the server would reject as out-of-window. Pre-filtering keeps - // a single edge-case row from failing the whole batch with HTTP 400. - const droppedDates: string[] = []; - const entries = ccusage.data.filter((entry) => { - if (isWithinBackfillWindow(entry.date)) return true; - droppedDates.push(entry.date); - return false; - }); - if (droppedDates.length > 0) { + for (const pending of loadPendingBatches()) { + const pendingResult = await submitBatch(config, pending, !nonInteractive); + if (!pendingResult.complete) { + console.error( + pendingResult.identityConflict + ? "Device identity conflict. Run `straude devices` in an interactive terminal to resolve it." + : pendingResult.exitCode === CLI_EXIT.AUTH_REQUIRED + ? "AUTH_REQUIRED: Run `straude login` in an interactive terminal." + : "A prior usage batch remains unsynced and will be retried without recollecting.", + ); + return pendingResult.exitCode; + } + advanceCompletedBatch(pending); + removePendingBatch(pending.request.request_id); + } + + const effectiveSince = requestedDates[0]!; + const effectiveUntil = requestedDates.at(-1)!; console.log( - `Note: skipping ${droppedDates.length} date(s) outside the ${MAX_BACKFILL_DAYS}-day backfill window: ${droppedDates.join(", ")}`, + effectiveSince === effectiveUntil + ? `Pushing usage for ${effectiveSince}...` + : `Pushing usage for ${effectiveSince} to ${effectiveUntil}...`, ); - } - if (entries.length === 0) { - console.log("No usage data found for the specified period."); - return; - } - - if (options.dryRun) { - // Dry run: fetch full dashboard from API (skip submit only) + const spinner = new Spinner("scan"); + spinner.start(); + const collectionStartedAt = performance.now(); + let collected: Awaited>; try { - const dashboard = await apiRequest(config, "/api/cli/dashboard"); - const { render } = await import("ink"); - const { createElement } = await import("react"); - const { PushSummary } = await import("../components/PushSummary.js"); - - const { waitUntilExit } = render( - createElement(PushSummary, { dashboard }), + collected = await collectCcusageUsageAsync( + compactCalendarDate(effectiveSince), + compactCalendarDate(effectiveUntil), + options.timeoutMs, + { + pricingMode: CCUSAGE_DEFAULT_PRICING_MODE, + timezone, + }, ); - await waitUntilExit(); - } catch { - // Fallback: plain text if API or Ink fails - for (const entry of entries) { - console.log(` ${entry.date}:`); - console.log(` Cost: ${formatCost(entry.costUSD)}`); - console.log( - ` Tokens: ${formatTokens(entry.totalTokens)} (input: ${formatTokens(entry.inputTokens)}, output: ${formatTokens(entry.outputTokens)})`, - ); - console.log(` Models: ${entry.models.join(", ")}`); - } + timings.collection_ms = elapsedMs(collectionStartedAt); + } catch (error) { + timings.collection_ms = elapsedMs(collectionStartedAt); + const exitCode = error instanceof PricingUnavailableError + ? CLI_EXIT.TEMPORARY + : CLI_EXIT.PERMANENT; + reportUsagePushFailed(config, error, { + command: "push", + stage: "scan", + error_code: error instanceof PricingUnavailableError + ? "PRICING_UNAVAILABLE" + : "COLLECTOR_INVALID", + ...telemetryProperties({ + timings, + totalStartedAt, + rangeMode: resolution.mode, + pricingMode: CCUSAGE_DEFAULT_PRICING_MODE, + }), + }); + await shutdownTelemetryWithTimeout(); + console.error(`\nFailed to collect usage: ${errorMessage(error)}`); + return exitCode; + } finally { + spinner.stop(); } - console.log("\n(dry run — nothing submitted)"); - return; - } - const hashInput = JSON.stringify({ - collector: "ccusage-v20", - version: ccusage.version, - agents: ccusage.agents, - since: sinceStr, - until: untilStr, - raw: ccusage.raw, - }); - const hash = createHash("sha256").update(hashInput).digest("hex"); + const requested = new Set(requestedDates); + const entries = collected.data.filter((entry) => requested.has(entry.date)); + const unexpectedDates = collected.data + .filter((entry) => !requested.has(entry.date)) + .map((entry) => entry.date); + if (unexpectedDates.length > 0) { + console.error(`Collector returned dates outside the requested range: ${unexpectedDates.join(", ")}`); + return CLI_EXIT.PERMANENT; + } - const body: UsageSubmitRequest = { - entries: entries.map((entry) => ({ - date: entry.date, - data: entry, - })), - hash, - collector: ccusage.agents.length > 0 ? ccusage.collector : undefined, - source: "cli", - device_id: config.device_id, - device_name: config.device_name, - }; + if (options.dryRun) { + printDryRun(entries); + return CLI_EXIT.OK; + } - const syncSpinner = new Spinner("sync"); - syncSpinner.start(); - let response: UsageSubmitResponse; - const submitStartedAt = performance.now(); - try { - response = await apiRequest(config, "/api/usage/submit", { - method: "POST", - body: JSON.stringify(body), - }); - timings.submit_ms = elapsedMs(submitStartedAt); - syncSpinner.stop(); - } catch (err) { - timings.submit_ms = elapsedMs(submitStartedAt); - syncSpinner.stop(); - reportUsagePushFailed(config, err, { - command: "push", - stage: "submit", - ...pushTelemetryProperties({ - timings, - totalStartedAt, - rangeMode, - firstRun, - authFlowStarted, - migrationPending, - fullBackfillCompleted: false, - pricingMode: ccusage.collector.pricing_mode, - ccusageVersion: ccusage.version, - ccusageAgents: ccusage.agents, - }), - }); - await shutdownTelemetryWithTimeout(); - console.error(`\nFailed to submit: ${errorMessage(err)}`); - process.exit(1); - } + if (entries.length === 0) { + const emptyBatch: PendingUsageBatch = { + request: { + protocol_version: 2, + request_id: randomUUID(), + source: "cli", + timezone, + installation: { id: getInstallationId(), name: config.device_name ?? hostname() }, + collector: { + name: "ccusage", + version: collected.version, + pricing_mode: collected.collector.pricing_mode, + }, + entries: [], + }, + requested_dates: requestedDates, + ...(automaticWatermarkDate ? { watermark_date: automaticWatermarkDate } : {}), + range_mode: resolution.mode, + migration_pending: isMigrationBatch, + created_at: new Date().toISOString(), + }; + advanceCompletedBatch(emptyBatch); + lease.acknowledgeQueuedDates(queuedDatesToConsume); + console.log("No usage data found for the specified period."); + return CLI_EXIT.OK; + } - const totalCost = entries.reduce((sum, e) => sum + e.costUSD, 0); - const totalTokens = entries.reduce((sum, e) => sum + e.totalTokens, 0); - const fullBackfillCompleted = migrationPending && fullBackfillRequested; - - printSubmitSuccess({ - entries, - results: response.results, - totalCost, - totalTokens, - migrationPending, - fullBackfillCompleted, - }); + const request = createRequest({ + config, + timezone, + collector: collected.collector, + entries, + migration: resolution.mode === "first_sync" || resolution.mode === "migration", + }); + const batch: PendingUsageBatch = { + request, + requested_dates: requestedDates, + ...(automaticWatermarkDate ? { watermark_date: automaticWatermarkDate } : {}), + range_mode: resolution.mode, + migration_pending: isMigrationBatch, + created_at: new Date().toISOString(), + }; + upsertPendingBatch(batch); - // Show per-entry delta feedback before dashboard - for (const result of response.results) { - if (result.action === "updated" && result.previous_cost != null && result.daily_total != null) { - const delta = result.daily_total - result.previous_cost; - if (Math.abs(delta) < 0.005) { - // No meaningful change — explain why - const deviceHint = result.device_count && result.device_count > 1 - ? ` (${result.device_count} devices)` - : ""; - console.log( - `${result.date}: $${result.daily_total.toFixed(2)}${deviceHint} — no new usage detected on this device`, - ); - } + const syncSpinner = new Spinner("sync"); + syncSpinner.start(); + const submitStartedAt = performance.now(); + let submitted: SubmitBatchResult; + try { + submitted = await submitBatch(config, batch, !nonInteractive); + timings.submit_ms = elapsedMs(submitStartedAt); + } finally { + syncSpinner.stop(); + } + if (!submitted.complete) { + console.error( + submitted.identityConflict + ? "Device identity conflict. Run `straude devices` in an interactive terminal to resolve it." + : submitted.exitCode === CLI_EXIT.AUTH_REQUIRED + ? "AUTH_REQUIRED: Run `straude login` in an interactive terminal." + : submitted.exitCode === CLI_EXIT.PERMANENT + ? "Usage was partially synced, but a date was permanently rejected. Fix the reported data or configuration before retrying." + : "Usage was only partially synced. Committed dates were preserved; remaining dates will retry from the durable outbox.", + ); + await shutdownTelemetryWithTimeout(); + return submitted.exitCode; } - } - // Track last pushed date for incremental sync - const latestDate = entries.reduce( - (latest, e) => (e.date > latest ? e.date : latest), - entries[0]!.date, - ); - if (fullBackfillCompleted) { - const stamp = new Date().toISOString(); - config.ccusage_v20_migration_completed_at = stamp; - config.last_push_date = latestDate; - saveConfig(config); - } else { - updateLastPushDate(latestDate); + const updatedConfig = advanceCompletedBatch(batch); + removePendingBatch(batch.request.request_id); + lease.acknowledgeQueuedDates(queuedDatesToConsume); + printSubmitSuccess(entries, submitted.results); + const dashboard = await renderDashboard(updatedConfig, submitted.results); + timings.dashboard_ms = dashboard.durationMs; + + posthog.capture({ + distinctId: getDistinctId(updatedConfig), + event: "usage_pushed", + properties: { + protocol_version: 2, + days_pushed: entries.length, + dates_created: submitted.results.filter((result) => result.action === "created").length, + dates_updated: submitted.results.filter((result) => result.action === "updated").length, + total_cost_usd: entries.reduce((sum, entry) => sum + entry.costUSD, 0), + total_tokens: entries.reduce((sum, entry) => sum + entry.totalTokens, 0), + dashboard_rendered: dashboard.rendered, + ...telemetryProperties({ + timings, + totalStartedAt, + rangeMode: resolution.mode, + requestId: request.request_id, + pricingMode: collected.collector.pricing_mode, + ccusageVersion: collected.version, + retryCount: submitted.retryCount, + pricingRetryCount: collected.pricingRetryCount ?? 0, + }), + }, + }); + return CLI_EXIT.OK; + } finally { + lease.release(); } - - const datesCreated = response.results.filter((r) => r.action === "created").length; - const datesUpdated = response.results.filter((r) => r.action === "updated").length; - - const dashboard = await renderDashboard( - config, - response.results, - ); - timings.dashboard_ms = dashboard.durationMs; - - posthog.capture({ - distinctId: getDistinctId(config), - event: "usage_pushed", - properties: { - days_pushed: entries.length, - dates_created: datesCreated, - dates_updated: datesUpdated, - total_cost_usd: Math.round(totalCost * 100) / 100, - total_tokens: totalTokens, - dry_run: false, - ...pushTelemetryProperties({ - timings, - totalStartedAt, - rangeMode, - firstRun, - authFlowStarted, - migrationPending, - fullBackfillCompleted, - pricingMode: ccusage.collector.pricing_mode, - ccusageVersion: ccusage.version, - ccusageAgents: ccusage.agents, - dashboardRendered: dashboard.rendered, - }), - }, - }); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bb8edbbc..7d0562dd 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import { loginCommand } from "./commands/login.js"; import { pushCommand } from "./commands/push.js"; import { statusCommand } from "./commands/status.js"; import { autoCommand, enableAutoPush, disableAutoPush } from "./commands/auto.js"; +import { devicesCommand } from "./commands/devices.js"; import { loadConfig } from "./lib/auth.js"; import { setAuthRefreshStrategy } from "./lib/api.js"; import { CLI_VERSION } from "./config.js"; @@ -18,6 +19,12 @@ import { reportUsagePushFailed, shutdownTelemetryWithTimeout, } from "./lib/telemetry.js"; +import { + assertSupportedNodeRuntime, + CliArgumentError, + parseCliArgs, +} from "./lib/args.js"; +import { setInteractiveOverride } from "./lib/prompt.js"; // On 401, transparently re-run the browser login flow and let api.ts retry // the failed request. apiRequest gates this on isInteractive() so auto-push @@ -55,6 +62,7 @@ Commands: push Push usage data to Straude status Show your current stats auto Show auto-push status or logs + devices List or resolve installation identity conflicts Push options: --date YYYY-MM-DD Push a specific date (within last 30 days) @@ -66,6 +74,7 @@ Push options: --auto --time HH:MM Set auto-push time (default: 21:00) --no-auto Disable auto-push --debug Print extra diagnostic detail (also: STRAUDE_DEBUG=1) + --non-interactive Never open a browser or wait for login Examples: npx straude@latest @@ -79,65 +88,15 @@ Examples: straude status `.trim(); -function parseArgs(args: string[]): { command: string | null; subcommand: string | null; options: Record } { - const options: Record = {}; - let command: string | null = null; - let subcommand: string | null = null; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]!; - if (arg === "--dry-run") { - options.dryRun = true; - } else if (arg === "--auto") { - options.auto = true; - // Peek at next arg for mechanism (e.g., "hooks") - if (i + 1 < args.length && args[i + 1] === "hooks") { - options.autoMechanism = args[++i]!; - } - } else if (arg === "--no-auto") { - options.noAuto = true; - } else if (arg === "--time" && i + 1 < args.length) { - options.time = args[++i]!; - } else if (arg === "--date" && i + 1 < args.length) { - options.date = args[++i]!; - } else if (arg === "--days" && i + 1 < args.length) { - options.days = args[++i]!; - } else if (arg === "--timeout" && i + 1 < args.length) { - options.timeout = args[++i]!; - } else if (arg === "--api-url" && i + 1 < args.length) { - options.apiUrl = args[++i]!; - } else if (arg === "--help" || arg === "-h") { - options.help = true; - } else if (arg === "--version" || arg === "-v") { - options.version = true; - } else if (arg === "--debug") { - options.debug = true; - } else if (!arg.startsWith("-") && !command) { - command = arg; - } else if (!arg.startsWith("-") && command && !subcommand) { - subcommand = arg; - } - } - - return { command, subcommand, options }; -} - -function parseTimeout(value: string): number { - const seconds = parseInt(value, 10); - if (isNaN(seconds) || seconds <= 0) { - console.error(`Invalid --timeout value: ${value} (must be a positive integer)`); - process.exit(1); - } - return seconds * 1000; -} - let activeCommand: string | null = null; async function main(): Promise { + assertSupportedNodeRuntime(); const args = process.argv.slice(2); - const { command, subcommand, options } = parseArgs(args); + const { command, subcommand, operand, options } = parseCliArgs(args); activeCommand = command; + if (options.nonInteractive) setInteractiveOverride(false); if (options.debug) { setDebug(true); } @@ -182,24 +141,27 @@ async function main(): Promise { }); } - const apiUrl = options.apiUrl as string | undefined; + const apiUrl = options.apiUrl; if (!command || command === "push") { - await pushCommand( + exitCode = await pushCommand( { - date: options.date as string | undefined, - days: options.days ? parseInt(options.days as string, 10) : undefined, + date: options.date, + days: options.days, dryRun: options.dryRun === true, - timeoutMs: options.timeout ? parseTimeout(options.timeout as string) : undefined, + timeoutMs: options.timeoutMs, + nonInteractive: options.nonInteractive === true, }, apiUrl, ); + process.exitCode = exitCode; + if (exitCode !== 0) return; // Handle --auto / --no-auto after successful push if (options.auto) { const config = loadConfig(); if (config) { - enableAutoPush(config, options.time as string | undefined, options.autoMechanism as string | undefined); + enableAutoPush(config, options.time, options.autoMechanism); } } else if (options.noAuto) { const config = loadConfig(); @@ -220,6 +182,10 @@ async function main(): Promise { case "auto": autoCommand(subcommand); break; + case "devices": + exitCode = await devicesCommand(subcommand, operand); + process.exitCode = exitCode; + break; default: console.error(`Unknown command: ${command}\n`); console.log(HELP); @@ -233,7 +199,12 @@ main() .catch((err: unknown) => { exitCode = 1; process.exitCode = 1; - const config = loadConfig(); + let config = null; + try { + config = loadConfig(); + } catch { + // Preserve the original error. + } if (isPushInvocation(activeCommand)) { reportUsagePushFailed(config, err, { command: activeCommand ?? "push", @@ -244,6 +215,7 @@ main() command: activeCommand ?? "unknown", }); } - console.error(`Error: ${errorMessage(err)}`); + const prefix = err instanceof CliArgumentError ? `${err.code}: ` : "Error: "; + console.error(`${prefix}${errorMessage(err)}`); }) .finally(() => shutdownTelemetryWithTimeout().then(() => process.exit(exitCode))); diff --git a/packages/cli/src/lib/api.ts b/packages/cli/src/lib/api.ts index adf939fd..71cc892c 100644 --- a/packages/cli/src/lib/api.ts +++ b/packages/cli/src/lib/api.ts @@ -1,19 +1,24 @@ import type { StraudeConfig } from "./auth.js"; -import { saveConfig } from "./auth.js"; +import { updateConfig } from "./auth.js"; import { isInteractive } from "./prompt.js"; -export interface ApiError { - error: string; - status: number; -} - export const REFRESHED_TOKEN_HEADER = "x-straude-refreshed-token"; +export const DEFAULT_API_TIMEOUT_MS = 15_000; +export const DEFAULT_API_RETRIES = 2; + +const MAX_RETRY_DELAY_MS = 5_000; + +export interface ApiRequestOptions extends RequestInit { + /** Total wall-clock budget for the request, including retries and backoff. */ + timeoutMs?: number; + /** Absolute epoch deadline. The earlier of this and timeoutMs wins. */ + deadlineAt?: number; + /** Defaults to two for GET/HEAD and zero for mutation requests. */ + maxRetries?: number; + /** Non-2xx statuses whose JSON body is part of the caller's typed protocol. */ + acceptedStatuses?: readonly number[]; +} -/** - * Pluggable strategy for re-authenticating when the server returns 401. - * Registered at startup from index.ts so api.ts doesn't take a hard dependency - * on the login command (which would be circular). - */ type AuthRefreshStrategy = (apiUrl: string) => Promise; let authRefreshStrategy: AuthRefreshStrategy | null = null; @@ -22,116 +27,311 @@ export function setAuthRefreshStrategy(fn: AuthRefreshStrategy | null): void { authRefreshStrategy = fn; } -class SessionExpiredError extends Error { +export class ApiHttpError extends Error { + readonly status: number; + readonly retryAfterMs: number | null; + + constructor(message: string, status: number, retryAfterMs: number | null = null) { + super(message); + this.name = "ApiHttpError"; + this.status = status; + this.retryAfterMs = retryAfterMs; + } + + get retryable(): boolean { + return this.status === 408 || + this.status === 425 || + this.status === 429 || + (this.status >= 500 && this.status <= 599); + } +} + +export class ApiTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Request timed out after ${timeoutMs}ms.`); + this.name = "ApiTimeoutError"; + } +} + +class SessionExpiredError extends ApiHttpError { constructor() { - super("Session expired or invalid. Run `npx straude@latest login` to re-authenticate."); + super( + "Session expired or invalid. Run `npx straude@latest login` to re-authenticate.", + 401, + ); this.name = "SessionExpiredError"; } } +interface PreparedRequest { + fetchOptions: RequestInit; + deadlineAt: number; + timeoutMs: number; + maxRetries: number; + acceptedStatuses: ReadonlySet; +} + +function prepareRequest(options: ApiRequestOptions): PreparedRequest { + const { + timeoutMs = DEFAULT_API_TIMEOUT_MS, + deadlineAt: requestedDeadline, + maxRetries, + acceptedStatuses = [], + ...fetchOptions + } = options; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error("API timeout must be a positive finite number."); + } + const timeoutDeadline = Date.now() + timeoutMs; + const deadlineAt = requestedDeadline == null + ? timeoutDeadline + : Math.min(timeoutDeadline, requestedDeadline); + const method = (fetchOptions.method ?? "GET").toUpperCase(); + const resolvedRetries = maxRetries ?? (method === "GET" || method === "HEAD" + ? DEFAULT_API_RETRIES + : 0); + if (!Number.isInteger(resolvedRetries) || resolvedRetries < 0) { + throw new Error("API maxRetries must be a non-negative integer."); + } + if (acceptedStatuses.some((status) => ( + !Number.isInteger(status) || status < 100 || status > 599 + ))) { + throw new Error("Accepted API statuses must be HTTP status integers."); + } + return { + fetchOptions, + deadlineAt, + timeoutMs, + maxRetries: resolvedRetries, + acceptedStatuses: new Set(acceptedStatuses), + }; +} + +function retryAfterMs(response: Response): number | null { + const raw = response.headers.get("retry-after"); + if (!raw) return null; + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; + const date = Date.parse(raw); + return Number.isNaN(date) ? null : Math.max(0, date - Date.now()); +} + +function backoffMs(attempt: number, requested: number | null): number { + if (requested != null) return requested; + const ceiling = Math.min(250 * 2 ** attempt, MAX_RETRY_DELAY_MS); + return Math.floor(Math.random() * (ceiling + 1)); +} + +function sleep(ms: number, deadlineAt: number): Promise { + const remaining = deadlineAt - Date.now(); + if (remaining <= 0 || ms >= remaining) { + return Promise.reject(new ApiTimeoutError(Math.max(0, remaining))); + } + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableNetworkError(error: unknown): boolean { + return error instanceof TypeError || + (error instanceof Error && + ["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ENETUNREACH"].includes( + (error as NodeJS.ErrnoException).code ?? "", + )); +} + +async function fetchAttempt( + url: string, + options: RequestInit, + deadlineAt: number, + timeoutMs: number, +): Promise { + const remaining = deadlineAt - Date.now(); + if (remaining <= 0) throw new ApiTimeoutError(timeoutMs); + + const controller = new AbortController(); + const callerSignal = options.signal; + const abortFromCaller = (): void => controller.abort(callerSignal?.reason); + if (callerSignal?.aborted) abortFromCaller(); + else callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); + + const timer = setTimeout(() => controller.abort(), remaining); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted && !callerSignal?.aborted) { + throw new ApiTimeoutError(timeoutMs); + } + throw error; + } finally { + clearTimeout(timer); + callerSignal?.removeEventListener("abort", abortFromCaller); + } +} + +async function readJson( + response: Response, + deadlineAt: number, + timeoutMs: number, +): Promise { + const remaining = deadlineAt - Date.now(); + if (remaining <= 0) { + await response.body?.cancel().catch(() => {}); + throw new ApiTimeoutError(timeoutMs); + } + let timer: ReturnType | undefined; + try { + return await Promise.race([ + response.json(), + new Promise((_, reject) => { + timer = setTimeout(() => { + void response.body?.cancel().catch(() => {}); + reject(new ApiTimeoutError(timeoutMs)); + }, remaining); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function errorForResponse( + response: Response, + path: string, + prepared: PreparedRequest, +): Promise { + let message = `HTTP ${response.status}`; + try { + const body = await readJson( + response, + prepared.deadlineAt, + prepared.timeoutMs, + ) as { error?: string }; + if (body.error) message = body.error; + } catch (error) { + if (error instanceof ApiTimeoutError) throw error; + // Preserve the status fallback when the error body is not JSON. + } + if (response.status === 401) return new SessionExpiredError(); + if (response.status === 404) { + return new ApiHttpError( + `Endpoint not found (${path}). Try updating the CLI: bunx straude@latest`, + response.status, + ); + } + return new ApiHttpError(message, response.status, retryAfterMs(response)); +} + +async function requestWithRetries( + url: string, + path: string, + prepared: PreparedRequest, +): Promise { + let attempt = 0; + while (true) { + try { + const response = await fetchAttempt( + url, + prepared.fetchOptions, + prepared.deadlineAt, + prepared.timeoutMs, + ); + if (response.ok || prepared.acceptedStatuses.has(response.status)) { + return response; + } + const error = await errorForResponse(response, path, prepared); + if ( + !(error instanceof ApiHttpError) || + !error.retryable || + attempt >= prepared.maxRetries + ) { + throw error; + } + await sleep(backoffMs(attempt, error.retryAfterMs), prepared.deadlineAt); + } catch (error) { + if ( + error instanceof ApiHttpError || + error instanceof ApiTimeoutError || + !isRetryableNetworkError(error) || + attempt >= prepared.maxRetries + ) { + throw error; + } + await sleep(backoffMs(attempt, null), prepared.deadlineAt); + } + attempt += 1; + } +} + async function doRequest( config: StraudeConfig, path: string, - options: RequestInit, + options: ApiRequestOptions, ): Promise { - const url = `${config.api_url}${path}`; + const prepared = prepareRequest(options); const headers: Record = { "Content-Type": "application/json", Authorization: `Bearer ${config.token}`, - ...(options.headers as Record | undefined), + ...(prepared.fetchOptions.headers as Record | undefined), }; + prepared.fetchOptions = { ...prepared.fetchOptions, headers }; - const res = await fetch(url, { ...options, headers }); - - if (!res.ok) { - let message = `HTTP ${res.status}`; - try { - const body = (await res.json()) as { error?: string }; - if (body.error) message = body.error; - } catch { - // ignore parse errors - } - if (res.status === 401) { - throw new SessionExpiredError(); - } - if (res.status === 404) { - throw new Error(`Endpoint not found (${path}). Try updating the CLI: bunx straude@latest`); - } - throw new Error(message); - } + const response = await requestWithRetries( + `${config.api_url}${path}`, + path, + prepared, + ); - // Sliding-window token refresh: when the server decides our JWT is getting - // stale it returns a fresh one in a header. Persist it so the next CLI run - // (and the next request in this same run) uses the new token. Mutating the - // caller's config in place avoids threading the new token through every - // call site. - const refreshed = res.headers?.get?.(REFRESHED_TOKEN_HEADER) ?? null; + const refreshed = response.headers.get(REFRESHED_TOKEN_HEADER); if (refreshed) { config.token = refreshed; try { - saveConfig(config); + updateConfig((current) => current + ? { ...current, token: refreshed } + : { ...config, token: refreshed }); } catch (error) { - // Read-only home directory: keep the new token in memory but don't - // crash the request — the user just won't get rotation persisted. - // Surface anything else (disk full, etc.) so it isn't silently swallowed. const code = (error as NodeJS.ErrnoException).code; - if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS") { - throw error; - } + if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS") throw error; } } - return res.json() as Promise; + return readJson(response, prepared.deadlineAt, prepared.timeoutMs) as Promise; } export async function apiRequest( config: StraudeConfig, path: string, - options: RequestInit = {}, + options: ApiRequestOptions = {}, ): Promise { try { return await doRequest(config, path, options); - } catch (err) { + } catch (error) { if ( - err instanceof SessionExpiredError && + error instanceof SessionExpiredError && authRefreshStrategy && isInteractive() ) { const fresh = await authRefreshStrategy(config.api_url); - if (!fresh) throw err; - // Update the caller's config in place so any subsequent calls in the - // same flow (e.g. the dashboard fetch after submit) see the new token. + if (!fresh) throw error; config.token = fresh.token; config.username = fresh.username; - return await doRequest(config, path, options); + return doRequest(config, path, { ...options, maxRetries: 0 }); } - throw err; + throw error; } } export async function apiRequestNoAuth( apiUrl: string, path: string, - options: RequestInit = {}, + options: ApiRequestOptions = {}, ): Promise { - const url = `${apiUrl}${path}`; - const headers: Record = { - "Content-Type": "application/json", - ...(options.headers as Record | undefined), + const prepared = prepareRequest(options); + prepared.fetchOptions = { + ...prepared.fetchOptions, + headers: { + "Content-Type": "application/json", + ...(prepared.fetchOptions.headers as Record | undefined), + }, }; - - const res = await fetch(url, { ...options, headers }); - - if (!res.ok) { - let message = `HTTP ${res.status}`; - try { - const body = (await res.json()) as { error?: string }; - if (body.error) message = body.error; - } catch { - // ignore parse errors - } - throw new Error(message); - } - - return res.json() as Promise; + const response = await requestWithRetries(`${apiUrl}${path}`, path, prepared); + return readJson(response, prepared.deadlineAt, prepared.timeoutMs) as Promise; } diff --git a/packages/cli/src/lib/args.ts b/packages/cli/src/lib/args.ts new file mode 100644 index 00000000..5e470026 --- /dev/null +++ b/packages/cli/src/lib/args.ts @@ -0,0 +1,226 @@ +import { MAX_BACKFILL_DAYS } from "../config.js"; +import { assertCalendarDate } from "./calendar.js"; + +const MAX_TIMEOUT_SECONDS = 3_600; +const COMMANDS = new Set(["push", "login", "status", "auto", "devices", "help"]); +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const VALUE_FLAGS = new Set(["--date", "--days", "--timeout", "--time", "--api-url"]); + +export interface ParsedCliOptions { + dryRun?: true; + auto?: true; + noAuto?: true; + autoMechanism?: "hooks"; + time?: string; + date?: string; + days?: number; + timeoutMs?: number; + apiUrl?: string; + help?: true; + version?: true; + debug?: true; + nonInteractive?: true; +} + +export interface ParsedCliArgs { + command: string | null; + subcommand: string | null; + operand: string | null; + options: ParsedCliOptions; +} + +export class CliArgumentError extends Error { + readonly code = "ARG_INVALID"; + + constructor(message: string) { + super(message); + this.name = "CliArgumentError"; + } +} + +function positiveInteger(value: string, flag: string): number { + if (!/^\d+$/.test(value)) { + throw new CliArgumentError(`${flag} must be a positive integer.`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new CliArgumentError(`${flag} must be a positive integer.`); + } + return parsed; +} + +function requireValue(args: string[], index: number, flag: string): string { + const value = args[index + 1]; + if (value === undefined || value.startsWith("-")) { + throw new CliArgumentError(`${flag} requires a value.`); + } + return value; +} + +function markSeen(seen: Set, flag: string): void { + if (seen.has(flag)) throw new CliArgumentError(`${flag} may only be specified once.`); + seen.add(flag); +} + +export function parseCliArgs(args: string[]): ParsedCliArgs { + const options: ParsedCliOptions = {}; + const seen = new Set(); + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--") { + positional.push(...args.slice(index + 1)); + break; + } + if (!argument.startsWith("-")) { + positional.push(argument); + continue; + } + if (argument.startsWith("--") && argument.includes("=")) { + throw new CliArgumentError(`Use a space after ${argument.slice(0, argument.indexOf("="))}; --flag=value is unsupported.`); + } + markSeen(seen, argument); + + if (VALUE_FLAGS.has(argument)) { + const value = requireValue(args, index, argument); + index += 1; + switch (argument) { + case "--date": + try { + options.date = assertCalendarDate(value); + } catch { + throw new CliArgumentError("--date must be a real calendar date in YYYY-MM-DD format."); + } + break; + case "--days": { + const days = positiveInteger(value, "--days"); + if (days > MAX_BACKFILL_DAYS) { + throw new CliArgumentError(`--days must be between 1 and ${MAX_BACKFILL_DAYS}.`); + } + options.days = days; + break; + } + case "--timeout": { + const seconds = positiveInteger(value, "--timeout"); + if (seconds > MAX_TIMEOUT_SECONDS) { + throw new CliArgumentError(`--timeout must be between 1 and ${MAX_TIMEOUT_SECONDS} seconds.`); + } + options.timeoutMs = seconds * 1_000; + break; + } + case "--time": + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) { + throw new CliArgumentError("--time must use 24-hour HH:MM format."); + } + options.time = value; + break; + case "--api-url": + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(); + options.apiUrl = url.toString().replace(/\/$/, ""); + } catch { + throw new CliArgumentError("--api-url must be an HTTP or HTTPS URL."); + } + break; + } + continue; + } + + switch (argument) { + case "--dry-run": + options.dryRun = true; + break; + case "--auto": + options.auto = true; + break; + case "--no-auto": + options.noAuto = true; + break; + case "--help": + case "-h": + options.help = true; + break; + case "--version": + case "-v": + options.version = true; + break; + case "--debug": + options.debug = true; + break; + case "--non-interactive": + options.nonInteractive = true; + break; + default: + throw new CliArgumentError(`Unknown option: ${argument}`); + } + } + + let command: string | null = positional[0] ?? null; + let subcommand: string | null = positional[1] ?? null; + let operand: string | null = positional[2] ?? null; + if (positional.length > 3) { + throw new CliArgumentError(`Unexpected argument: ${positional[3]}`); + } + if (command === "hooks" && options.auto) { + options.autoMechanism = "hooks"; + command = null; + subcommand = null; + operand = null; + } + if (command && !COMMANDS.has(command)) { + throw new CliArgumentError(`Unknown command: ${command}`); + } + if (command === "auto" && subcommand !== null && subcommand !== "logs") { + throw new CliArgumentError(`Unsupported auto subcommand: ${subcommand}`); + } + if (command === "devices") { + if (subcommand === null && operand !== null) { + throw new CliArgumentError(`Unexpected devices argument: ${operand}`); + } + if (subcommand !== null && subcommand !== "merge" && subcommand !== "keep-separate") { + throw new CliArgumentError(`Unsupported devices subcommand: ${subcommand}`); + } + if (subcommand !== null && (operand === null || !UUID_PATTERN.test(operand))) { + throw new CliArgumentError(`devices ${subcommand} requires a candidate UUID.`); + } + } else { + if (operand !== null) { + throw new CliArgumentError(`Unexpected argument for ${command ?? "push"}: ${operand}`); + } + if (command !== "auto" && subcommand !== null) { + throw new CliArgumentError(`Unexpected argument for ${command ?? "push"}: ${subcommand}`); + } + } + if (options.date && options.days !== undefined) { + throw new CliArgumentError("--date and --days cannot be used together."); + } + if (options.auto && options.noAuto) { + throw new CliArgumentError("--auto and --no-auto cannot be used together."); + } + + const resolvedCommand = command ?? "push"; + const pushOnly = options.date !== undefined + || options.days !== undefined + || options.timeoutMs !== undefined + || options.dryRun === true + || options.auto === true + || options.noAuto === true + || options.time !== undefined; + if (pushOnly && resolvedCommand !== "push") { + throw new CliArgumentError("Push options can only be used with the push command."); + } + return { command, subcommand, operand, options }; +} + +export function assertSupportedNodeRuntime(version = process.versions.node): void { + const match = /^(\d+)\./.exec(version); + const major = match ? Number(match[1]) : Number.NaN; + if (!Number.isSafeInteger(major) || major < 20) { + throw new CliArgumentError( + `Straude requires Node.js 20 or newer (detected ${version}). Update Node.js and retry.`, + ); + } +} diff --git a/packages/cli/src/lib/auth.ts b/packages/cli/src/lib/auth.ts index b78d218a..e53d9c25 100644 --- a/packages/cli/src/lib/auth.ts +++ b/packages/cli/src/lib/auth.ts @@ -1,6 +1,24 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { randomUUID } from "node:crypto"; import { CONFIG_DIR, CONFIG_FILE, DEFAULT_API_URL } from "../config.js"; +const CONFIG_LOCK_FILE = `${CONFIG_FILE}.lock`; +const CONFIG_LOCK_TIMEOUT_MS = 2_000; +const CONFIG_LOCK_STALE_MS = 30_000; +const LOCK_RETRY_MS = 25; + export interface AutoPushConfig { enabled: boolean; time: string; // "HH:MM" @@ -14,6 +32,8 @@ export interface StraudeConfig { api_url: string; last_push_date?: string; ccusage_v20_migration_completed_at?: string; + usage_protocol_v2_migration_completed_at?: string; + previous_device_id_migrated_at?: string; codex_native_repair_completed_at?: string; // Set after the one-time 30-day backfill that re-collects Codex sessions with // the last_token_usage accounting fix. Distinct from the older repair flag @@ -24,44 +44,212 @@ export interface StraudeConfig { auto_push?: AutoPushConfig; } +export class ConfigCorruptError extends Error { + readonly preservedPath?: string; + + constructor(message: string, options?: ErrorOptions & { preservedPath?: string }) { + super( + `Straude config is corrupt (${CONFIG_FILE}): ${message}. ` + + (options?.preservedPath + ? `The original was preserved at ${options.preservedPath}.` + : "Move the file aside and run `straude login` again."), + options, + ); + this.name = "ConfigCorruptError"; + this.preservedPath = options?.preservedPath; + } +} + +function parseConfig(raw: string): StraudeConfig { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new ConfigCorruptError("invalid JSON", { cause: error }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ConfigCorruptError("expected a JSON object"); + } + const value = parsed as Record; + if (typeof value.token !== "string" || value.token.length === 0) { + throw new ConfigCorruptError("missing authentication token"); + } + + return { + token: value.token, + username: typeof value.username === "string" ? value.username : "", + api_url: typeof value.api_url === "string" ? value.api_url : DEFAULT_API_URL, + last_push_date: typeof value.last_push_date === "string" ? value.last_push_date : undefined, + ccusage_v20_migration_completed_at: + typeof value.ccusage_v20_migration_completed_at === "string" + ? value.ccusage_v20_migration_completed_at + : undefined, + usage_protocol_v2_migration_completed_at: + typeof value.usage_protocol_v2_migration_completed_at === "string" + ? value.usage_protocol_v2_migration_completed_at + : undefined, + previous_device_id_migrated_at: + typeof value.previous_device_id_migrated_at === "string" + ? value.previous_device_id_migrated_at + : undefined, + codex_native_repair_completed_at: + typeof value.codex_native_repair_completed_at === "string" + ? value.codex_native_repair_completed_at + : undefined, + codex_native_last_token_usage_repair_completed_at: + typeof value.codex_native_last_token_usage_repair_completed_at === "string" + ? value.codex_native_last_token_usage_repair_completed_at + : undefined, + device_id: typeof value.device_id === "string" ? value.device_id : undefined, + device_name: typeof value.device_name === "string" ? value.device_name : undefined, + auto_push: value.auto_push as AutoPushConfig | undefined, + }; +} + export function loadConfig(): StraudeConfig | null { if (!existsSync(CONFIG_FILE)) return null; + let raw: string; + try { + raw = readFileSync(CONFIG_FILE, "utf-8"); + } catch (error) { + throw new ConfigCorruptError("could not be read", { cause: error }); + } try { - const raw = readFileSync(CONFIG_FILE, "utf-8"); - const parsed = JSON.parse(raw); - if (!parsed.token) return null; - return { - token: parsed.token, - username: parsed.username ?? "", - api_url: parsed.api_url ?? DEFAULT_API_URL, - last_push_date: parsed.last_push_date ?? undefined, - ccusage_v20_migration_completed_at: parsed.ccusage_v20_migration_completed_at ?? undefined, - codex_native_repair_completed_at: parsed.codex_native_repair_completed_at ?? undefined, - codex_native_last_token_usage_repair_completed_at: - parsed.codex_native_last_token_usage_repair_completed_at - ?? undefined, - device_id: parsed.device_id ?? undefined, - device_name: parsed.device_name ?? undefined, - auto_push: parsed.auto_push ?? undefined, - }; - } catch { - return null; + return parseConfig(raw); + } catch (error) { + if (!(error instanceof ConfigCorruptError)) throw error; + const suffix = new Date().toISOString().replaceAll(/[:.]/g, "-"); + const preservedPath = `${CONFIG_FILE}.corrupt-${suffix}`; + try { + renameSync(CONFIG_FILE, preservedPath); + } catch (preserveError) { + throw new ConfigCorruptError( + "invalid content and the original could not be preserved", + { cause: preserveError }, + ); + } + throw new ConfigCorruptError("invalid content", { + cause: error, + preservedPath, + }); } } -export function saveConfig(config: StraudeConfig): void { +function ensureConfigDir(): void { if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); } - // mode 0o600: only the owner can read/write (protects the auth token) - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 }); +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function acquireConfigLock(): number { + ensureConfigDir(); + const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS; + while (true) { + try { + return openSync(CONFIG_LOCK_FILE, "wx", 0o600); + } catch (error) { + const fsError = error as NodeJS.ErrnoException; + if (fsError.code !== "EEXIST") throw error; + + try { + if (Date.now() - statSync(CONFIG_LOCK_FILE).mtimeMs > CONFIG_LOCK_STALE_MS) { + unlinkSync(CONFIG_LOCK_FILE); + continue; + } + } catch (statError) { + if ((statError as NodeJS.ErrnoException).code !== "ENOENT") throw statError; + continue; + } + + if (Date.now() >= deadline) { + throw new Error("Another Straude process is updating the config. Please retry."); + } + sleepSync(LOCK_RETRY_MS); + } + } +} + +function atomicWriteConfig(config: StraudeConfig): void { + ensureConfigDir(); + const temporary = `${CONFIG_FILE}.${process.pid}.${randomUUID()}.tmp`; + let fd: number | undefined; + try { + fd = openSync(temporary, "wx", 0o600); + writeFileSync(fd, JSON.stringify(config, null, 2) + "\n", "utf-8"); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + renameSync(temporary, CONFIG_FILE); + chmodSync(CONFIG_FILE, 0o600); + try { + const directory = openSync(CONFIG_DIR, "r"); + try { + fsyncSync(directory); + } finally { + closeSync(directory); + } + } catch { + // Windows and some filesystems do not support fsync on directories. + } + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // Preserve the original write error. + } + } + try { + unlinkSync(temporary); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + // The destination rename already succeeded or the original error is + // more useful than a temporary-file cleanup failure. + } + } + } +} + +function withConfigLock(operation: () => T): T { + const lockFd = acquireConfigLock(); + try { + return operation(); + } finally { + closeSync(lockFd); + try { + unlinkSync(CONFIG_LOCK_FILE); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + +export function saveConfig(config: StraudeConfig): void { + withConfigLock(() => atomicWriteConfig(config)); +} + +export function updateConfig( + updater: (current: StraudeConfig | null) => StraudeConfig, +): StraudeConfig { + return withConfigLock(() => { + const current = loadConfig(); + const next = updater(current); + atomicWriteConfig(next); + return next; + }); } export function updateLastPushDate(date: string): void { - const config = loadConfig(); - if (!config) return; - config.last_push_date = date; - saveConfig(config); + updateConfig((config) => { + if (!config) { + throw new Error("Cannot update the last push date before authentication."); + } + return { ...config, last_push_date: date }; + }); } export function requireAuth(): StraudeConfig { diff --git a/packages/cli/src/lib/auto-push-logger.ts b/packages/cli/src/lib/auto-push-logger.ts index c44e7ea7..627e6cdc 100644 --- a/packages/cli/src/lib/auto-push-logger.ts +++ b/packages/cli/src/lib/auto-push-logger.ts @@ -1,14 +1,44 @@ -import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs"; +import { + closeSync, + existsSync, + openSync, + readFileSync, + readSync, + statSync, + writeFileSync, +} from "node:fs"; import { AUTO_PUSH_LOG_FILE, AUTO_PUSH_LOG_MAX_BYTES, AUTO_PUSH_LOG_KEEP_LINES } from "../config.js"; +const MAX_TAIL_READ_BYTES = 256 * 1024; + export function readLog(lines: number = 50): string[] { if (!existsSync(AUTO_PUSH_LOG_FILE)) return []; + if (!Number.isInteger(lines) || lines <= 0) return []; + + let fd: number | undefined; try { - const content = readFileSync(AUTO_PUSH_LOG_FILE, "utf-8"); + const size = statSync(AUTO_PUSH_LOG_FILE).size; + const bytesToRead = Math.min(size, MAX_TAIL_READ_BYTES); + const buffer = Buffer.alloc(bytesToRead); + fd = openSync(AUTO_PUSH_LOG_FILE, "r"); + const bytesRead = readSync(fd, buffer, 0, bytesToRead, size - bytesToRead); + let content = buffer.subarray(0, bytesRead).toString("utf-8"); + if (size > bytesToRead) { + const firstNewline = content.indexOf("\n"); + content = firstNewline === -1 ? "" : content.slice(firstNewline + 1); + } const allLines = content.split("\n").filter((l) => l.length > 0); return allLines.slice(-lines); } catch { return []; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // Reading logs is best-effort. + } + } } } diff --git a/packages/cli/src/lib/background-command.ts b/packages/cli/src/lib/background-command.ts new file mode 100644 index 00000000..bedc87d5 --- /dev/null +++ b/packages/cli/src/lib/background-command.ts @@ -0,0 +1,38 @@ +import { existsSync, realpathSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { CLI_VERSION } from "../config.js"; + +export interface BackgroundInvocation { + executable: string; + args: string[]; +} + +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function durableBackgroundInvocation(): BackgroundInvocation | null { + const script = process.argv[1]; + if (!script || !isAbsolute(script) || !existsSync(script)) return null; + const normalized = script.replaceAll("\\", "/"); + if ( + !/(?:\/node_modules\/straude|\/packages\/cli)\/dist\/index\.js$/.test(normalized) + || /\/(?:_npx|\.bun\/install\/cache|bunx-)\//.test(normalized) + ) { + return null; + } + return { + executable: realpathSync(process.execPath), + args: [realpathSync(script)], + }; +} + +export function exactBackgroundCommand(): string { + const durable = durableBackgroundInvocation(); + if (durable) { + return [durable.executable, ...durable.args, "push", "--non-interactive"] + .map(shellQuote) + .join(" "); + } + return `npx --yes straude@${CLI_VERSION} push --non-interactive`; +} diff --git a/packages/cli/src/lib/calendar.ts b/packages/cli/src/lib/calendar.ts new file mode 100644 index 00000000..128b7261 --- /dev/null +++ b/packages/cli/src/lib/calendar.ts @@ -0,0 +1,93 @@ +const CALENDAR_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const DAY_MS = 86_400_000; + +interface CalendarParts { + year: number; + month: number; + day: number; +} + +function parseParts(value: string): CalendarParts | null { + const match = CALENDAR_DATE_RE.exec(value); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const utc = new Date(Date.UTC(year, month - 1, day)); + if ( + utc.getUTCFullYear() !== year + || utc.getUTCMonth() !== month - 1 + || utc.getUTCDate() !== day + ) { + return null; + } + return { year, month, day }; +} + +function ordinal(value: string): number { + const parts = parseParts(value); + if (!parts) throw new Error(`Invalid calendar date: ${value}`); + return Date.UTC(parts.year, parts.month - 1, parts.day) / DAY_MS; +} + +export function isCalendarDate(value: string): boolean { + return parseParts(value) !== null; +} + +export function assertCalendarDate(value: string, label = "date"): string { + if (!isCalendarDate(value)) { + throw new Error(`Invalid ${label}: ${value} (expected a real calendar date in YYYY-MM-DD format).`); + } + return value; +} + +export function addCalendarDays(value: string, days: number): string { + if (!Number.isInteger(days)) throw new Error("Calendar day offset must be an integer."); + const next = new Date((ordinal(value) + days) * DAY_MS); + return [ + next.getUTCFullYear(), + String(next.getUTCMonth() + 1).padStart(2, "0"), + String(next.getUTCDate()).padStart(2, "0"), + ].join("-"); +} + +export function calendarDaysBetween(start: string, end: string): number { + return ordinal(end) - ordinal(start); +} + +export function listCalendarDates(start: string, end: string): string[] { + const days = calendarDaysBetween(start, end); + if (days < 0) throw new Error(`Calendar range ends before it starts: ${start} to ${end}.`); + return Array.from({ length: days + 1 }, (_, index) => addCalendarDays(start, index)); +} + +export function compactCalendarDate(value: string): string { + assertCalendarDate(value); + return value.replaceAll("-", ""); +} + +export function localCalendarDate(now: Date, timezone: string): string { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(now); + const values = new Map(parts.map((part) => [part.type, part.value])); + const value = `${values.get("year")}-${values.get("month")}-${values.get("day")}`; + return assertCalendarDate(value, "local date"); +} + +export function calendarDateToLocalDate(value: string): Date { + const parts = parseParts(assertCalendarDate(value)); + if (!parts) throw new Error(`Invalid calendar date: ${value}`); + return new Date(parts.year, parts.month - 1, parts.day); +} + +export function localDateToCalendarDate(value: Date): string { + return [ + value.getFullYear(), + String(value.getMonth() + 1).padStart(2, "0"), + String(value.getDate()).padStart(2, "0"), + ].join("-"); +} diff --git a/packages/cli/src/lib/ccusage.ts b/packages/cli/src/lib/ccusage.ts index 614b96a7..d064d765 100644 --- a/packages/cli/src/lib/ccusage.ts +++ b/packages/cli/src/lib/ccusage.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { DEFAULT_SUBPROCESS_TIMEOUT_MS } from "../config.js"; export const CCUSAGE_MIN_VERSION = "20.0.16"; +export const CCUSAGE_VERIFIED_VERSION = "20.0.16"; export const CCUSAGE_CLAUDE_COLLECTOR = "ccusage-claude-v20" as const; export const CCUSAGE_CODEX_COLLECTOR = "ccusage-codex-v20" as const; export const CCUSAGE_DEFAULT_PRICING_MODE = "online" as const; @@ -11,7 +12,11 @@ export const CCUSAGE_DEFAULT_PRICING_MODE = "online" as const; export type CcusagePricingMode = "offline" | "online"; const MAX_CCUSAGE_BUFFER = 20 * 1024 * 1024; +const MODEL_COST_TOLERANCE_USD = 0.005; +const PRICING_RECOVERY_BUDGET_MS = 60_000; +const PRICING_RETRY_DELAYS_MS = [1_000, 3_000] as const; const MISSING_PRICING_RE = /(missing|unavailable|unknown|could not fetch|failed to fetch).{0,80}(pricing|price|cost)|pricing.{0,80}(missing|unavailable|unknown)|cost excludes/i; +const EMBEDDED_PRICING_FALLBACK_RE = /failed to (?:fetch|parse) litellm pricing.*using embedded pricing/i; export type CcusageAgent = string; @@ -42,13 +47,33 @@ let forcedCommandForTests: ResolvedCcusageCommand | undefined; /** Per-model cost entry for breakdown tracking. */ export interface ModelBreakdownEntry { model: string; + inputTokens: number; + outputTokens: number; + reasoningOutputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; cost_usd: number; } +export interface CcusageAgentEntry { + agent: CcusageAgent; + models: string[]; + inputTokens: number; + outputTokens: number; + reasoningOutputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + costUSD: number; + modelBreakdown: ModelBreakdownEntry[]; +} + /** Normalized entry used throughout the CLI and sent to the API. */ export interface CcusageDailyEntry { date: string; agents: CcusageAgent[]; + agentBreakdown: CcusageAgentEntry[]; models: string[]; inputTokens: number; outputTokens: number; @@ -84,6 +109,7 @@ export interface CcusageOutput { version: string; raw: string; stderr: string; + pricingRetryCount?: number; } interface ParseOptions { @@ -94,7 +120,10 @@ interface ParseOptions { interface CollectOptions { pricingMode?: CcusagePricingMode; - allowOnlineFallback?: boolean; + timezone?: string; + sleep?: (delayMs: number) => Promise; + random?: () => number; + pricingRecoveryBudgetMs?: number; } interface CcusageRawEntry { @@ -110,6 +139,7 @@ interface CcusageRawEntry { totalCost?: unknown; costUSD?: unknown; metadata?: unknown; + agents?: unknown; } interface CcusageRawModelBreakdown { @@ -117,6 +147,32 @@ interface CcusageRawModelBreakdown { model?: unknown; cost?: unknown; cost_usd?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; + reasoningOutputTokens?: unknown; + cacheCreationTokens?: unknown; + cacheReadTokens?: unknown; + totalTokens?: unknown; + missingPricing?: unknown; +} + +interface CcusageRawAgent { + agent?: unknown; + modelsUsed?: unknown; + modelBreakdowns?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; + cacheCreationTokens?: unknown; + cacheReadTokens?: unknown; + totalTokens?: unknown; + totalCost?: unknown; +} + +export class PricingUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "PricingUnavailableError"; + } } function packageNameForPlatform(platform = process.platform, arch = process.arch): string | undefined { @@ -212,9 +268,9 @@ function compareSemver(a: string, b: string): number { } function assertSupportedVersion(version: string): void { - if (compareSemver(version, CCUSAGE_MIN_VERSION) < 0) { + if (compareSemver(version, CCUSAGE_VERIFIED_VERSION) !== 0) { throw new Error( - `ccusage ${version} is unsupported. Straude requires ccusage >=${CCUSAGE_MIN_VERSION} for accurate Codex accounting.`, + `ccusage ${version} is unsupported. Straude requires the fixture-verified ccusage ${CCUSAGE_VERIFIED_VERSION}. Reinstall Straude and retry.`, ); } } @@ -239,6 +295,10 @@ function execCcusageAsync(args: string[], timeoutMs?: number): Promise { if (!err) { resolve({ stdout, stderr }); @@ -266,8 +326,8 @@ function hasMissingPricingWarning(stderr: string): boolean { } function rejectMissingPricing(stderr: string, pricingMode: CcusagePricingMode): void { - if (hasMissingPricingWarning(stderr)) { - throw new Error( + if (hasMissingPricingWarning(stderr) || EMBEDDED_PRICING_FALLBACK_RE.test(stderr)) { + throw new PricingUnavailableError( `ccusage did not produce fully priced ${pricingMode} cost data: ${stderr.trim()}`, ); } @@ -291,7 +351,11 @@ function asStringArray(value: unknown, field: string, date: string): string[] { if (!Array.isArray(value)) { throw new Error(`Invalid ccusage row for ${date}: ${field} must be an array.`); } - return value.filter((item): item is string => typeof item === "string" && item.length > 0); + const strings = value.filter((item): item is string => typeof item === "string" && item.length > 0); + if (strings.length !== value.length || new Set(strings).size !== strings.length) { + throw new Error(`Invalid ccusage row for ${date}: ${field} must contain unique non-empty strings.`); + } + return strings; } function parseAgents(row: CcusageRawEntry, date: string): CcusageAgent[] { @@ -303,8 +367,11 @@ function parseAgents(row: CcusageRawEntry, date: string): CcusageAgent[] { if (rawAgents.length === 0 || rawAgents.some((agent) => typeof agent !== "string")) { throw new Error(`Invalid ccusage row for ${date}: metadata.agents must contain agent names.`); } + if (new Set(rawAgents).size !== rawAgents.length) { + throw new Error(`Invalid ccusage row for ${date}: metadata.agents contains duplicate agents.`); + } - return [...new Set(rawAgents)].sort(); + return [...rawAgents].sort(); } function parseModelBreakdown(value: unknown, date: string): ModelBreakdownEntry[] | undefined { @@ -323,12 +390,224 @@ function parseModelBreakdown(value: unknown, date: string): ModelBreakdownEntry[ throw new Error(`Invalid ccusage row for ${date}: modelBreakdowns[${index}].modelName is required.`); } const cost = asFiniteNumber(raw.cost ?? raw.cost_usd, `modelBreakdowns[${index}].cost`, date); - return { model, cost_usd: cost }; + if (raw.missingPricing === true) { + throw new PricingUnavailableError( + `ccusage did not produce live pricing for ${model} on ${date}.`, + ); + } + const inputTokens = asFiniteNumber( + raw.inputTokens ?? 0, + `modelBreakdowns[${index}].inputTokens`, + date, + ); + const outputTokens = asFiniteNumber( + raw.outputTokens ?? 0, + `modelBreakdowns[${index}].outputTokens`, + date, + ); + const cacheCreationTokens = asFiniteNumber( + raw.cacheCreationTokens ?? 0, + `modelBreakdowns[${index}].cacheCreationTokens`, + date, + ); + const cacheReadTokens = asFiniteNumber( + raw.cacheReadTokens ?? 0, + `modelBreakdowns[${index}].cacheReadTokens`, + date, + ); + const baseTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalTokens = asFiniteNumber( + raw.totalTokens ?? baseTokens, + `modelBreakdowns[${index}].totalTokens`, + date, + ); + if (totalTokens < baseTokens) { + throw new Error( + `Invalid ccusage row for ${date}: modelBreakdowns[${index}].totalTokens is below its token categories.`, + ); + } + const reasoningOutputTokens = raw.reasoningOutputTokens == null + ? totalTokens - baseTokens + : asFiniteNumber( + raw.reasoningOutputTokens, + `modelBreakdowns[${index}].reasoningOutputTokens`, + date, + ); + if (baseTokens + reasoningOutputTokens !== totalTokens) { + throw new Error( + `Invalid ccusage row for ${date}: modelBreakdowns[${index}] token categories do not equal totalTokens.`, + ); + } + return { + model, + inputTokens, + outputTokens, + reasoningOutputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + cost_usd: cost, + }; }); + const models = breakdown.map((item) => item.model); + if (new Set(models).size !== models.length) { + throw new Error(`Invalid ccusage row for ${date}: modelBreakdowns contains duplicate models.`); + } return breakdown.length > 0 ? breakdown : undefined; } +function assertTokenTotal( + values: { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + }, + date: string, + field: string, +): number { + const base = values.inputTokens + + values.outputTokens + + values.cacheCreationTokens + + values.cacheReadTokens; + if (values.totalTokens < base) { + throw new Error(`Invalid ccusage row for ${date}: ${field}.totalTokens is below its token categories.`); + } + return values.totalTokens - base; +} + +function assertCostMatches( + expected: number, + breakdown: ModelBreakdownEntry[], + date: string, + field: string, +): void { + const breakdownCost = breakdown.reduce((sum, model) => sum + model.cost_usd, 0); + if (Math.abs(expected - breakdownCost) > MODEL_COST_TOLERANCE_USD) { + throw new Error( + `Invalid ccusage row for ${date}: ${field} cost differs from its model breakdown by more than $${MODEL_COST_TOLERANCE_USD.toFixed(3)}.`, + ); + } +} + +function allocateReasoningTokens( + breakdown: ModelBreakdownEntry[], + reasoningTokens: number, +): ModelBreakdownEntry[] { + // ccusage v20 includes per-agent reasoning in totalTokens but omits its + // private per-model extra_total_tokens field from JSON. Preserve the exact + // agent total by apportioning that known residual by model output volume. + const alreadyAllocated = breakdown.reduce( + (sum, model) => sum + model.reasoningOutputTokens, + 0, + ); + const residual = reasoningTokens - alreadyAllocated; + if (residual < 0) { + throw new Error("Model reasoning tokens exceed the enclosing agent total."); + } + if (residual === 0 || breakdown.length === 0) return breakdown; + + const weights = breakdown.map((model) => ( + model.outputTokens > 0 ? model.outputTokens : Math.max(model.totalTokens, 1) + )); + const weightTotal = weights.reduce((sum, weight) => sum + weight, 0); + const allocations = weights.map((weight) => Math.floor((residual * weight) / weightTotal)); + let remainder = residual - allocations.reduce((sum, value) => sum + value, 0); + const ranked = weights + .map((weight, index) => ({ + index, + remainder: (residual * weight) % weightTotal, + })) + .sort((left, right) => ( + right.remainder - left.remainder + || breakdown[left.index]!.model.localeCompare(breakdown[right.index]!.model) + )); + for (const candidate of ranked) { + if (remainder === 0) break; + allocations[candidate.index]! += 1; + remainder -= 1; + } + + return breakdown.map((model, index) => { + const added = allocations[index]!; + return { + ...model, + reasoningOutputTokens: model.reasoningOutputTokens + added, + totalTokens: model.totalTokens + added, + }; + }); +} + +function parseAgentBreakdown(value: unknown, date: string): CcusageAgentEntry[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`Invalid ccusage row for ${date}: agents breakdown is required; run ccusage with --by-agent.`); + } + + const agents = value.map((item, index): CcusageAgentEntry => { + if (!isRecord(item)) { + throw new Error(`Invalid ccusage row for ${date}: agents[${index}] must be an object.`); + } + const raw = item as CcusageRawAgent; + if (typeof raw.agent !== "string" || raw.agent.length === 0) { + throw new Error(`Invalid ccusage row for ${date}: agents[${index}].agent is required.`); + } + const inputTokens = asFiniteNumber(raw.inputTokens, `agents[${index}].inputTokens`, date); + const outputTokens = asFiniteNumber(raw.outputTokens, `agents[${index}].outputTokens`, date); + const cacheCreationTokens = asFiniteNumber( + raw.cacheCreationTokens, + `agents[${index}].cacheCreationTokens`, + date, + ); + const cacheReadTokens = asFiniteNumber( + raw.cacheReadTokens, + `agents[${index}].cacheReadTokens`, + date, + ); + const totalTokens = asFiniteNumber(raw.totalTokens, `agents[${index}].totalTokens`, date); + const costUSD = asFiniteNumber(raw.totalCost, `agents[${index}].totalCost`, date); + const parsedModelBreakdown = parseModelBreakdown(raw.modelBreakdowns, date) ?? []; + if (costUSD > 0 && parsedModelBreakdown.length === 0) { + throw new Error(`Invalid ccusage row for ${date}: agents[${index}] priced usage requires modelBreakdowns.`); + } + const reasoningOutputTokens = assertTokenTotal({ + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + }, date, `agents[${index}]`); + assertCostMatches(costUSD, parsedModelBreakdown, date, `agents[${index}]`); + const modelBreakdown = allocateReasoningTokens( + parsedModelBreakdown, + reasoningOutputTokens, + ); + + const models = asStringArray(raw.modelsUsed, `agents[${index}].modelsUsed`, date); + const allModels = new Set(models); + for (const model of modelBreakdown) allModels.add(model.model); + return { + agent: raw.agent, + models: [...allModels].sort(), + inputTokens, + outputTokens, + reasoningOutputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + costUSD, + modelBreakdown, + }; + }); + + const names = agents.map((agent) => agent.agent); + if (new Set(names).size !== names.length) { + throw new Error(`Invalid ccusage row for ${date}: agents breakdown contains duplicate agents.`); + } + return agents.sort((a, b) => a.agent.localeCompare(b.agent)); +} + function normalizeRawDaily(raw: unknown): CcusageRawEntry[] { if (Array.isArray(raw)) return raw as CcusageRawEntry[]; if (isRecord(raw) && Array.isArray(raw.daily)) return raw.daily as CcusageRawEntry[]; @@ -393,6 +672,14 @@ export function parseCcusageOutput(raw: string, options: ParseOptions = {}): Ccu const rowAgents = parseAgents(row, date); rowAgents.forEach((agent) => seenAgents.add(agent)); + const agentBreakdown = parseAgentBreakdown(row.agents, date); + const breakdownNames = agentBreakdown.map((agent) => agent.agent); + if ( + rowAgents.length !== breakdownNames.length + || rowAgents.some((agent) => !breakdownNames.includes(agent)) + ) { + throw new Error(`Invalid ccusage row for ${date}: metadata.agents does not match agents breakdown.`); + } const inputTokens = asFiniteNumber(row.inputTokens, "inputTokens", date); const outputTokens = asFiniteNumber(row.outputTokens, "outputTokens", date); @@ -406,17 +693,50 @@ export function parseCcusageOutput(raw: string, options: ParseOptions = {}): Ccu if (costUSD > 0 && (!modelBreakdown || modelBreakdown.length === 0)) { throw new Error(`Invalid ccusage row for ${date}: priced rows must include modelBreakdowns.`); } + const reasoningOutputTokens = assertTokenTotal({ + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + }, date, "daily"); + assertCostMatches(costUSD, modelBreakdown ?? [], date, "daily"); + + const agentTotals = agentBreakdown.reduce((totals, agent) => ({ + inputTokens: totals.inputTokens + agent.inputTokens, + outputTokens: totals.outputTokens + agent.outputTokens, + reasoningOutputTokens: totals.reasoningOutputTokens + agent.reasoningOutputTokens, + cacheCreationTokens: totals.cacheCreationTokens + agent.cacheCreationTokens, + cacheReadTokens: totals.cacheReadTokens + agent.cacheReadTokens, + totalTokens: totals.totalTokens + agent.totalTokens, + costUSD: totals.costUSD + agent.costUSD, + }), { + inputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + }); + if ( + agentTotals.inputTokens !== inputTokens + || agentTotals.outputTokens !== outputTokens + || agentTotals.reasoningOutputTokens !== reasoningOutputTokens + || agentTotals.cacheCreationTokens !== cacheCreationTokens + || agentTotals.cacheReadTokens !== cacheReadTokens + || agentTotals.totalTokens !== totalTokens + || Math.abs(agentTotals.costUSD - costUSD) > MODEL_COST_TOLERANCE_USD + ) { + throw new Error(`Invalid ccusage row for ${date}: agents breakdown does not match daily totals.`); + } const modelNames = new Set(models); for (const breakdown of modelBreakdown ?? []) modelNames.add(breakdown.model); - const reasoningOutputTokens = Math.max( - totalTokens - inputTokens - outputTokens - cacheCreationTokens - cacheReadTokens, - 0, - ); - return [{ date, agents: rowAgents, + agentBreakdown, models: [...modelNames], inputTokens, outputTokens, @@ -430,6 +750,11 @@ export function parseCcusageOutput(raw: string, options: ParseOptions = {}): Ccu }); data.sort((a, b) => a.date.localeCompare(b.date)); + for (let index = 1; index < data.length; index += 1) { + if (data[index - 1]!.date === data[index]!.date) { + throw new Error(`Invalid ccusage output: duplicate date ${data[index]!.date}.`); + } + } const agents = [...seenAgents].sort(); const version = options.version ?? "unknown"; @@ -450,6 +775,7 @@ function argsForPricingMode( sinceDate: string, untilDate: string, pricingMode: CcusagePricingMode, + timezone: string, ): string[] { return [ "daily", @@ -458,10 +784,30 @@ function argsForPricingMode( sinceDate, "--until", untilDate, + "--timezone", + timezone, + "--by-agent", pricingMode === "offline" ? "--offline" : "--no-offline", ]; } +export function resolveLocalTimezone(): string { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (typeof timezone !== "string" || timezone.length === 0) { + throw new Error("Unable to resolve the local IANA timezone."); + } + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); + } catch { + throw new Error(`Unsupported local IANA timezone: ${timezone}`); + } + return timezone; +} + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + export async function collectCcusageUsageAsync( sinceDate: string, untilDate: string, @@ -471,34 +817,51 @@ export async function collectCcusageUsageAsync( const { version } = resolveInstalledCcusageCommand(); assertSupportedVersion(version); const pricingMode = options.pricingMode ?? CCUSAGE_DEFAULT_PRICING_MODE; - - const result = await execCcusageAsync( - argsForPricingMode(sinceDate, untilDate, pricingMode), - timeoutMs, - ); - if ( - pricingMode === "offline" && - options.allowOnlineFallback !== false && - hasMissingPricingWarning(result.stderr) - ) { - const fallback = await execCcusageAsync( - argsForPricingMode(sinceDate, untilDate, "online"), - timeoutMs, - ); - rejectMissingPricing(fallback.stderr, "online"); - - return parseCcusageOutput(fallback.stdout, { - version, - stderr: fallback.stderr, - pricingMode: "online", - }); + const timezone = options.timezone ?? resolveLocalTimezone(); + const startedAt = Date.now(); + const recoveryBudgetMs = options.pricingRecoveryBudgetMs ?? PRICING_RECOVERY_BUDGET_MS; + const outerDeadline = startedAt + (timeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS); + const wait = options.sleep ?? sleep; + const random = options.random ?? Math.random; + let pricingDeadline: number | undefined; + for (let attempt = 0; attempt < PRICING_RETRY_DELAYS_MS.length + 1; attempt += 1) { + const deadline = pricingDeadline ?? outerDeadline; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + if (pricingDeadline !== undefined) { + throw new PricingUnavailableError( + `Live pricing did not recover inside the ${Math.round(recoveryBudgetMs / 1_000)}-second budget.`, + ); + } + throw new Error("ccusage exceeded the configured local scan deadline."); + } + try { + const result = await execCcusageAsync( + argsForPricingMode(sinceDate, untilDate, pricingMode, timezone), + Math.min(timeoutMs ?? DEFAULT_SUBPROCESS_TIMEOUT_MS, remaining), + ); + rejectMissingPricing(result.stderr, pricingMode); + const parsed = parseCcusageOutput(result.stdout, { + version, + stderr: result.stderr, + pricingMode, + }); + return { ...parsed, pricingRetryCount: attempt }; + } catch (error) { + if (!(error instanceof PricingUnavailableError) || attempt >= PRICING_RETRY_DELAYS_MS.length) { + throw error; + } + pricingDeadline ??= Math.min(Date.now() + recoveryBudgetMs, outerDeadline); + const cap = PRICING_RETRY_DELAYS_MS[attempt]!; + const delay = Math.floor(Math.max(0, Math.min(1, random())) * cap); + if (Date.now() + delay >= pricingDeadline) { + throw new PricingUnavailableError( + `Live pricing did not recover inside the ${Math.round(recoveryBudgetMs / 1_000)}-second budget.`, + ); + } + await wait(delay); + } } - rejectMissingPricing(result.stderr, pricingMode); - - return parseCcusageOutput(result.stdout, { - version, - stderr: result.stderr, - pricingMode, - }); + throw new PricingUnavailableError("Live pricing collection did not produce a result."); } diff --git a/packages/cli/src/lib/hooks.ts b/packages/cli/src/lib/hooks.ts index edef86b4..785b8a7a 100644 --- a/packages/cli/src/lib/hooks.ts +++ b/packages/cli/src/lib/hooks.ts @@ -1,11 +1,21 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { exactBackgroundCommand } from "./background-command.js"; export const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); -const STRAUDE_HOOK_COMMAND = "straude push"; - interface HookEntry { type: string; command: string; @@ -20,7 +30,18 @@ interface HookGroup { function isStraudeHook(group: HookGroup): boolean { return group.hooks.some( - (h) => h.type === "command" && h.command.includes(STRAUDE_HOOK_COMMAND), + (hook) => hook.type === "command" + && hook.command.includes("straude") + && /\bpush\b/.test(hook.command), + ); +} + +function hasExactStraudeHook(group: HookGroup, command: string): boolean { + return group.hooks.some( + (hook) => hook.type === "command" + && hook.command === command + && hook.timeout === 300 + && hook.async === true, ); } @@ -36,15 +57,48 @@ function readSettings(): Record { } function writeSettings(settings: Record): void { - writeFileSync( - CLAUDE_SETTINGS_PATH, - JSON.stringify(settings, null, 2) + "\n", - "utf-8", - ); + const temporary = `${CLAUDE_SETTINGS_PATH}.${process.pid}.${randomUUID()}.tmp`; + let fd: number | undefined; + try { + fd = openSync(temporary, "wx", 0o600); + writeFileSync(fd, JSON.stringify(settings, null, 2) + "\n", "utf-8"); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + renameSync(temporary, CLAUDE_SETTINGS_PATH); + chmodSync(CLAUDE_SETTINGS_PATH, 0o600); + try { + const directory = openSync(dirname(CLAUDE_SETTINGS_PATH), "r"); + try { + fsyncSync(directory); + } finally { + closeSync(directory); + } + } catch { + // Windows and some filesystems do not support fsync on directories. + } + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // Preserve the original write failure. + } + } + try { + unlinkSync(temporary); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + // A cleanup failure must not hide a successful atomic rename. + } + } + } } export function installClaudeCodeHook(): void { const settings = readSettings(); + const original = structuredClone(settings); + const hookCommand = exactBackgroundCommand(); // Ensure hooks object exists if (!settings.hooks || typeof settings.hooks !== "object") { @@ -58,8 +112,27 @@ export function installClaudeCodeHook(): void { } const sessionEnd = hooks.SessionEnd as HookGroup[]; - // Check if straude hook already exists (idempotent) - if (sessionEnd.some(isStraudeHook)) { + // Upgrade older hooks in place so background runs never trigger login. + const existing = sessionEnd.flatMap((group) => group.hooks) + .find((hook) => hook.type === "command" + && hook.command.includes("straude") + && /\bpush\b/.test(hook.command)); + if (existing) { + if ( + existing.command === hookCommand + && existing.timeout === 300 + && existing.async === true + ) { + return; + } + existing.command = hookCommand; + existing.timeout = 300; + existing.async = true; + writeSettings(settings); + if (!isExactClaudeCodeHookInstalled(hookCommand)) { + writeSettings(original); + throw new Error("Claude Code did not retain the upgraded Straude SessionEnd hook."); + } return; } @@ -68,14 +141,18 @@ export function installClaudeCodeHook(): void { hooks: [ { type: "command", - command: STRAUDE_HOOK_COMMAND, - timeout: 120, + command: hookCommand, + timeout: 300, async: true, }, ], }); writeSettings(settings); + if (!isExactClaudeCodeHookInstalled(hookCommand)) { + writeSettings(original); + throw new Error("Claude Code did not retain the Straude SessionEnd hook."); + } } export function uninstallClaudeCodeHook(): void { @@ -111,3 +188,16 @@ export function isClaudeCodeHookInstalled(): boolean { return false; } } + +function isExactClaudeCodeHookInstalled(command: string): boolean { + if (!existsSync(CLAUDE_SETTINGS_PATH)) return false; + try { + const settings = readSettings(); + const hooks = settings.hooks as Record | undefined; + if (!hooks || !Array.isArray(hooks.SessionEnd)) return false; + return (hooks.SessionEnd as HookGroup[]) + .some((group) => hasExactStraudeHook(group, command)); + } catch { + return false; + } +} diff --git a/packages/cli/src/lib/machine-id.ts b/packages/cli/src/lib/machine-id.ts index 56955901..67b08a31 100644 --- a/packages/cli/src/lib/machine-id.ts +++ b/packages/cli/src/lib/machine-id.ts @@ -1,11 +1,76 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { CONFIG_DIR } from "../config.js"; const MACHINE_ID_FILE = join(CONFIG_DIR, "machine_id"); -let cached: string | null = null; +let cachedInstallationId: string | null = null; +let cachedAnalyticsFallback: string | null = null; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class InstallationIdentityError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(`${message} (${MACHINE_ID_FILE}).`, options); + this.name = "InstallationIdentityError"; + } +} + +function readDurableId(): string { + const id = readFileSync(MACHINE_ID_FILE, "utf8").trim(); + if (!UUID_RE.test(id)) { + throw new InstallationIdentityError( + "Straude's installation identity is corrupt; restore the file or resolve the device before syncing", + ); + } + return id; +} + +function createDurableId(): string { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + const id = randomUUID(); + let descriptor: number | undefined; + try { + descriptor = openSync(MACHINE_ID_FILE, "wx", 0o600); + writeFileSync(descriptor, `${id}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + chmodSync(MACHINE_ID_FILE, 0o600); + return id; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return readDurableId(); + throw new InstallationIdentityError( + "Straude could not persist a durable installation identity", + { cause: error }, + ); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +export function getInstallationId(): string { + if (cachedInstallationId) return cachedInstallationId; + try { + cachedInstallationId = existsSync(MACHINE_ID_FILE) ? readDurableId() : createDurableId(); + return cachedInstallationId; + } catch (error) { + if (error instanceof InstallationIdentityError) throw error; + throw new InstallationIdentityError( + "Straude could not load its durable installation identity", + { cause: error }, + ); + } +} /** * Returns a stable, anonymous per-machine UUID stored at ~/.straude/machine_id. @@ -13,32 +78,23 @@ let cached: string | null = null; * so anonymous CLI events aren't all collapsed into a single distinct_id. */ export function getMachineId(): string { - if (cached) return cached; try { - if (existsSync(MACHINE_ID_FILE)) { - const id = readFileSync(MACHINE_ID_FILE, "utf-8").trim(); - if (id) { - cached = id; - return id; - } - } - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); - } - const id = randomUUID(); - writeFileSync(MACHINE_ID_FILE, id, { encoding: "utf-8", mode: 0o600 }); - cached = id; - return id; + return getInstallationId(); } catch { // If we can't read/write the file, fall back to a process-local UUID. // Worse for analytics (every invocation looks like a new user) but never // breaks the CLI for users with read-only home directories. - const id = randomUUID(); - cached = id; - return id; + cachedAnalyticsFallback ??= randomUUID(); + return cachedAnalyticsFallback; } } +/** Reset the process cache for isolated tests. */ +export function _resetMachineIdForTests(): void { + cachedInstallationId = null; + cachedAnalyticsFallback = null; +} + /** * PostHog distinct_id for an event. Falls back to the machine UUID when the * user hasn't logged in yet. diff --git a/packages/cli/src/lib/posthog.ts b/packages/cli/src/lib/posthog.ts index a1dccf8b..bbf0640d 100644 --- a/packages/cli/src/lib/posthog.ts +++ b/packages/cli/src/lib/posthog.ts @@ -77,7 +77,10 @@ export const posthog: PostHog = apiKey host, flushAt: 1, flushInterval: 0, - enableExceptionAutocapture: true, + // Raw exception messages and stacks can contain paths, hostnames, or + // collector stderr. Straude sends only allowlisted error codes and a + // one-way stable fingerprint through telemetry.ts. + enableExceptionAutocapture: false, before_send: beforeSend, }) : noop; diff --git a/packages/cli/src/lib/prompt.ts b/packages/cli/src/lib/prompt.ts index 036ccfb2..83a55380 100644 --- a/packages/cli/src/lib/prompt.ts +++ b/packages/cli/src/lib/prompt.ts @@ -1,6 +1,13 @@ import { createInterface } from "node:readline"; +let interactiveOverride: boolean | null = null; + +export function setInteractiveOverride(value: boolean | null): void { + interactiveOverride = value; +} + export function isInteractive(): boolean { + if (interactiveOverride !== null) return interactiveOverride; return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); } diff --git a/packages/cli/src/lib/scheduler.ts b/packages/cli/src/lib/scheduler.ts index 34776d3c..9bf8ba94 100644 --- a/packages/cli/src/lib/scheduler.ts +++ b/packages/cli/src/lib/scheduler.ts @@ -1,32 +1,85 @@ -import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; -import { execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { dirname } from "node:path"; import { CONFIG_DIR, AUTO_PUSH_SCRIPT_FILE, AUTO_PUSH_LOG_FILE, + AUTO_PUSH_LOG_KEEP_LINES, + AUTO_PUSH_LOG_MAX_BYTES, LAUNCHD_PLIST_PATH, + CLI_VERSION, } from "../config.js"; +import { + durableBackgroundInvocation, + shellQuote, +} from "./background-command.js"; const CRON_TAG = "# straude-auto-push"; +const LAUNCHD_LABEL = "com.straude.auto-push"; export function detectScheduler(): "launchd" | "cron" { return process.platform === "darwin" ? "launchd" : "cron"; } -export function isSchedulerInstalled(scheduler: "launchd" | "cron"): boolean { - if (scheduler === "launchd") { - return existsSync(LAUNCHD_PLIST_PATH); +function launchdDomain(): string { + const uid = process.getuid?.(); + if (uid == null) throw new Error("Unable to determine the current user for launchd."); + return `gui/${uid}`; +} + +function launchdService(): string { + return `${launchdDomain()}/${LAUNCHD_LABEL}`; +} + +function run( + command: string, + args: string[], + options: { input?: string; ignoreFailure?: boolean } = {}, +): string { + try { + return execFileSync(command, args, { + encoding: "utf-8", + input: options.input, + stdio: options.input == null ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], + }); + } catch (error) { + if (options.ignoreFailure) return ""; + const detail = (error as { stderr?: string | Buffer }).stderr?.toString().trim(); + throw new Error( + `${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`, + { cause: error }, + ); } - // cron: check if tagged entry exists in crontab +} + +function readCrontab(): string | null { try { - const crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf-8" }); - return crontab.includes(CRON_TAG); + return run("crontab", ["-l"]); } catch { - return false; + return null; } } +export function isSchedulerInstalled(scheduler: "launchd" | "cron"): boolean { + if (scheduler === "launchd") { + if (!existsSync(LAUNCHD_PLIST_PATH)) return false; + try { + run("launchctl", ["print", launchdService()]); + return true; + } catch { + return false; + } + } + return readCrontab()?.includes(CRON_TAG) ?? false; +} + function parseTime(time: string): { hour: number; minute: number } { const match = time.match(/^(\d{1,2}):(\d{2})$/); if (!match) throw new Error(`Invalid time format: ${time}. Use HH:MM (e.g., 09:00, 14:30).`); @@ -37,29 +90,45 @@ function parseTime(time: string): { hour: number; minute: number } { return { hour, minute }; } +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">"); +} + function buildWrapperScript(): string { const userPath = process.env.PATH ?? ""; - return `#!/bin/sh -# Generated by straude --auto — do not edit -export PATH="/usr/local/bin:/opt/homebrew/bin:${userPath}" - -TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') -echo "[$TIMESTAMP] Auto-push starting..." - -if command -v straude >/dev/null 2>&1; then - straude push 2>&1 -elif command -v bunx >/dev/null 2>&1; then - bunx straude@latest push 2>&1 + const durable = durableBackgroundInvocation(); + const runner = durable + ? `exec ${[durable.executable, ...durable.args, "push", "--non-interactive"] + .map(shellQuote) + .join(" ")}` + : `if command -v bunx >/dev/null 2>&1; then + exec bunx straude@${CLI_VERSION} push --non-interactive elif command -v npx >/dev/null 2>&1; then - npx --yes straude@latest push 2>&1 + exec npx --yes straude@${CLI_VERSION} push --non-interactive else - echo "[$TIMESTAMP] ERROR: Cannot find straude, bunx, or npx on PATH" + echo "[$TIMESTAMP] ERROR: Cannot find bunx or npx on PATH" exit 1 +fi`; + return `#!/bin/sh +# Generated by straude --auto. Do not edit. +PATH=${shellQuote(`/usr/local/bin:/opt/homebrew/bin:${userPath}`)} +export PATH +LOG_FILE=${shellQuote(AUTO_PUSH_LOG_FILE)} +LOG_TMP="\${LOG_FILE}.tmp.$$" + +if [ -f "$LOG_FILE" ] && [ "$(wc -c < "$LOG_FILE")" -gt ${AUTO_PUSH_LOG_MAX_BYTES} ]; then + tail -n ${AUTO_PUSH_LOG_KEEP_LINES} "$LOG_FILE" > "$LOG_TMP" && mv "$LOG_TMP" "$LOG_FILE" fi +rm -f "$LOG_TMP" +exec >> "$LOG_FILE" 2>&1 -EXIT_CODE=$? TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') -[ $EXIT_CODE -eq 0 ] && echo "[$TIMESTAMP] Auto-push completed" || echo "[$TIMESTAMP] Auto-push failed (exit $EXIT_CODE)" +echo "[$TIMESTAMP] Auto-push starting..." + +${runner} `; } @@ -69,11 +138,11 @@ function buildPlist(hour: number, minute: number): string { Label - com.straude.auto-push + ${LAUNCHD_LABEL} ProgramArguments /bin/sh - ${AUTO_PUSH_SCRIPT_FILE} + ${xmlEscape(AUTO_PUSH_SCRIPT_FILE)} StartCalendarInterval @@ -82,10 +151,6 @@ function buildPlist(hour: number, minute: number): string { Minute ${minute} - StandardOutPath - ${AUTO_PUSH_LOG_FILE} - StandardErrorPath - ${AUTO_PUSH_LOG_FILE} RunAtLoad @@ -100,107 +165,125 @@ function writeWrapperScript(): void { writeFileSync(AUTO_PUSH_SCRIPT_FILE, buildWrapperScript(), { encoding: "utf-8", mode: 0o755 }); } -function installLaunchd(hour: number, minute: number): void { - // Ensure LaunchAgents directory exists - const launchdDir = dirname(LAUNCHD_PLIST_PATH); - if (!existsSync(launchdDir)) { - mkdirSync(launchdDir, { recursive: true }); +function restoreFile( + path: string, + previous: string | null, + options?: { encoding: "utf-8"; mode?: number }, +): void { + if (previous === null) { + if (existsSync(path)) unlinkSync(path); + return; } + writeFileSync(path, previous, options ?? { encoding: "utf-8" }); +} - writeWrapperScript(); - writeFileSync(LAUNCHD_PLIST_PATH, buildPlist(hour, minute), { encoding: "utf-8" }); +function installLaunchd(hour: number, minute: number): void { + const launchdDir = dirname(LAUNCHD_PLIST_PATH); + if (!existsSync(launchdDir)) mkdirSync(launchdDir, { recursive: true }); + const previousWrapper = existsSync(AUTO_PUSH_SCRIPT_FILE) + ? readFileSync(AUTO_PUSH_SCRIPT_FILE, "utf8") + : null; + const previousPlist = existsSync(LAUNCHD_PLIST_PATH) + ? readFileSync(LAUNCHD_PLIST_PATH, "utf8") + : null; + const previousActive = previousPlist !== null + && run("launchctl", ["print", launchdService()], { ignoreFailure: true }).length > 0; try { - execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: "ignore" }); - } catch { - // launchctl load may fail if already loaded — that's fine + writeWrapperScript(); + writeFileSync(LAUNCHD_PLIST_PATH, buildPlist(hour, minute), { encoding: "utf-8" }); + run("launchctl", ["bootout", launchdDomain(), LAUNCHD_PLIST_PATH], { + ignoreFailure: true, + }); + run("launchctl", ["bootstrap", launchdDomain(), LAUNCHD_PLIST_PATH]); + run("launchctl", ["print", launchdService()]); + } catch (error) { + run("launchctl", ["bootout", launchdDomain(), LAUNCHD_PLIST_PATH], { + ignoreFailure: true, + }); + restoreFile( + AUTO_PUSH_SCRIPT_FILE, + previousWrapper, + { encoding: "utf-8", mode: 0o755 }, + ); + restoreFile(LAUNCHD_PLIST_PATH, previousPlist); + if (previousActive && previousPlist !== null) { + run("launchctl", ["bootstrap", launchdDomain(), LAUNCHD_PLIST_PATH], { + ignoreFailure: true, + }); + } + throw error; } } -function installCron(hour: number, minute: number): void { - writeWrapperScript(); - - const entry = `${minute} ${hour} * * * ${AUTO_PUSH_SCRIPT_FILE} >> ${AUTO_PUSH_LOG_FILE} 2>&1 ${CRON_TAG}`; - - let existing = ""; - try { - existing = execSync("crontab -l 2>/dev/null", { encoding: "utf-8" }); - } catch { - // No existing crontab — start fresh - } +function cronEntry(hour: number, minute: number): string { + return `${minute} ${hour} * * * ${shellQuote(AUTO_PUSH_SCRIPT_FILE)} ${CRON_TAG}`; +} - // Remove any existing straude entry, then append +function installCron(hour: number, minute: number): void { + const previousWrapper = existsSync(AUTO_PUSH_SCRIPT_FILE) + ? readFileSync(AUTO_PUSH_SCRIPT_FILE, "utf8") + : null; + const existing = readCrontab() ?? ""; const filtered = existing .split("\n") .filter((line) => !line.includes(CRON_TAG)) .join("\n"); - const newCrontab = (filtered.endsWith("\n") ? filtered : filtered + "\n") + entry + "\n"; - - execSync(`echo ${JSON.stringify(newCrontab)} | crontab -`, { stdio: "ignore" }); + const prefix = filtered.trimEnd(); + const newCrontab = `${prefix}${prefix ? "\n" : ""}${cronEntry(hour, minute)}\n`; + try { + writeWrapperScript(); + run("crontab", ["-"], { input: newCrontab }); + if (readCrontab()?.includes(cronEntry(hour, minute))) return; + throw new Error("Cron accepted the update but the Straude entry was not active."); + } catch (error) { + if (existing.trim() === "") run("crontab", ["-r"], { ignoreFailure: true }); + else run("crontab", ["-"], { input: existing.endsWith("\n") ? existing : `${existing}\n` }); + restoreFile( + AUTO_PUSH_SCRIPT_FILE, + previousWrapper, + { encoding: "utf-8", mode: 0o755 }, + ); + throw error; + } } function uninstallLaunchd(): void { if (existsSync(LAUNCHD_PLIST_PATH)) { - try { - execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: "ignore" }); - } catch { - // May not be loaded — that's fine - } + run("launchctl", ["bootout", launchdDomain(), LAUNCHD_PLIST_PATH], { + ignoreFailure: true, + }); unlinkSync(LAUNCHD_PLIST_PATH); } - if (existsSync(AUTO_PUSH_SCRIPT_FILE)) { - unlinkSync(AUTO_PUSH_SCRIPT_FILE); - } + if (existsSync(AUTO_PUSH_SCRIPT_FILE)) unlinkSync(AUTO_PUSH_SCRIPT_FILE); } function uninstallCron(): void { - let existing = ""; - try { - existing = execSync("crontab -l 2>/dev/null", { encoding: "utf-8" }); - } catch { - return; // No crontab — nothing to remove - } - - if (!existing.includes(CRON_TAG)) return; + const existing = readCrontab(); + if (existing == null || !existing.includes(CRON_TAG)) return; const filtered = existing .split("\n") .filter((line) => !line.includes(CRON_TAG)) - .join("\n"); - - if (filtered.trim() === "") { - // Empty crontab — remove it entirely - try { - execSync("crontab -r 2>/dev/null", { stdio: "ignore" }); - } catch { - // Already empty — fine - } + .join("\n") + .trimEnd(); + if (filtered === "") { + run("crontab", ["-r"], { ignoreFailure: true }); } else { - execSync(`echo ${JSON.stringify(filtered)} | crontab -`, { stdio: "ignore" }); - } - - if (existsSync(AUTO_PUSH_SCRIPT_FILE)) { - unlinkSync(AUTO_PUSH_SCRIPT_FILE); + run("crontab", ["-"], { input: `${filtered}\n` }); } + if (existsSync(AUTO_PUSH_SCRIPT_FILE)) unlinkSync(AUTO_PUSH_SCRIPT_FILE); } export function installScheduler(time: string, scheduler: "launchd" | "cron"): void { const { hour, minute } = parseTime(time); - - if (scheduler === "launchd") { - installLaunchd(hour, minute); - } else { - installCron(hour, minute); - } + if (scheduler === "launchd") installLaunchd(hour, minute); + else installCron(hour, minute); } export function uninstallScheduler(scheduler: "launchd" | "cron"): void { - if (scheduler === "launchd") { - uninstallLaunchd(); - } else { - uninstallCron(); - } + if (scheduler === "launchd") uninstallLaunchd(); + else uninstallCron(); } -// Exported for testing export { parseTime as _parseTime, buildWrapperScript as _buildWrapperScript, buildPlist as _buildPlist }; diff --git a/packages/cli/src/lib/sync-state.ts b/packages/cli/src/lib/sync-state.ts new file mode 100644 index 00000000..ac711da5 --- /dev/null +++ b/packages/cli/src/lib/sync-state.ts @@ -0,0 +1,499 @@ +import { randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { + parseUsageSubmitV2, + type UsageSubmitRequestV2, +} from "@straude/shared/usage-protocol"; +import { CONFIG_DIR } from "../config.js"; +import { assertCalendarDate } from "./calendar.js"; + +const OUTBOX_VERSION = 1 as const; +const LOCK_STALE_AFTER_MS = 15 * 60_000; +const LOCK_POLL_MS = 250; +const QUEUE_LOCK_TIMEOUT_MS = 2_000; +const QUEUE_LOCK_STALE_MS = 30_000; +const OUTBOX_LOCK_TIMEOUT_MS = 2_000; +const OUTBOX_LOCK_STALE_MS = 30_000; + +export type PendingRangeMode = + | "explicit_date" + | "explicit_days" + | "incremental" + | "first_sync" + | "migration"; + +export interface PendingUsageBatch { + request: UsageSubmitRequestV2; + requested_dates: string[]; + /** Last date proven contiguous for automatic-sync watermark advancement. */ + watermark_date?: string; + range_mode: PendingRangeMode; + migration_pending: boolean; + created_at: string; +} + +interface OutboxState { + version: typeof OUTBOX_VERSION; + batches: PendingUsageBatch[]; +} + +interface QueueState { + version: typeof OUTBOX_VERSION; + dates: string[]; +} + +interface LockState { + token: string; + pid: number; + started_at: string; + dates: string[]; +} + +export interface SyncStatePaths { + outbox: string; + lock: string; + queue: string; +} + +export interface SyncLease { + queuedDates: string[]; + acknowledgeQueuedDates: (dates: string[]) => void; + release: () => void; +} + +const DEFAULT_PATHS: SyncStatePaths = { + outbox: join(CONFIG_DIR, "pending-sync.json"), + lock: join(CONFIG_DIR, "sync.lock"), + queue: join(CONFIG_DIR, "sync-queue.json"), +}; + +export class SyncStateCorruptError extends Error { + readonly preservedPath: string; + + constructor(path: string, preservedPath: string) { + super(`Straude state at ${path} is corrupt. It was preserved at ${preservedPath}.`); + this.name = "SyncStateCorruptError"; + this.preservedPath = preservedPath; + } +} + +function ensureParent(path: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); +} + +function atomicWriteJson(path: string, value: unknown): void { + ensureParent(path); + const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`; + let descriptor: number | undefined; + try { + descriptor = openSync(temporary, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + chmodSync(temporary, 0o600); + renameSync(temporary, path); + chmodSync(path, 0o600); + try { + const directory = openSync(dirname(path), "r"); + try { + fsyncSync(directory); + } finally { + closeSync(directory); + } + } catch { + // Some platforms do not support fsync on directories. + } + } finally { + if (descriptor !== undefined) closeSync(descriptor); + if (existsSync(temporary)) unlinkSync(temporary); + } +} + +function preserveCorruptFile(path: string): never { + const suffix = new Date().toISOString().replaceAll(/[:.]/g, "-"); + const preserved = `${path}.corrupt-${suffix}`; + renameSync(path, preserved); + throw new SyncStateCorruptError(path, preserved); +} + +function parseOutbox(value: unknown): OutboxState | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + if (record.version !== OUTBOX_VERSION || !Array.isArray(record.batches)) return null; + + const batches: PendingUsageBatch[] = []; + for (const candidate of record.batches) { + if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) return null; + const batch = candidate as Record; + const parsedRequest = parseUsageSubmitV2(batch.request); + if (!parsedRequest.ok) return null; + if ( + !Array.isArray(batch.requested_dates) + || batch.requested_dates.some((date) => typeof date !== "string") + || new Set(batch.requested_dates).size !== batch.requested_dates.length + ) { + return null; + } + const requestedDates = batch.requested_dates as string[]; + try { + requestedDates.forEach((date) => assertCalendarDate(date)); + if (batch.watermark_date !== undefined) { + if ( + typeof batch.watermark_date !== "string" + || !requestedDates.includes(assertCalendarDate(batch.watermark_date)) + ) { + return null; + } + } + } catch { + return null; + } + if ( + batch.range_mode !== "explicit_date" + && batch.range_mode !== "explicit_days" + && batch.range_mode !== "incremental" + && batch.range_mode !== "first_sync" + && batch.range_mode !== "migration" + ) { + return null; + } + if (typeof batch.migration_pending !== "boolean" || typeof batch.created_at !== "string") { + return null; + } + batches.push({ + request: parsedRequest.value, + requested_dates: requestedDates, + ...(typeof batch.watermark_date === "string" + ? { watermark_date: batch.watermark_date } + : {}), + range_mode: batch.range_mode, + migration_pending: batch.migration_pending, + created_at: batch.created_at, + }); + } + return { version: OUTBOX_VERSION, batches }; +} + +export function loadPendingBatches( + paths: SyncStatePaths = DEFAULT_PATHS, +): PendingUsageBatch[] { + if (!existsSync(paths.outbox)) return []; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(paths.outbox, "utf8")); + } catch { + preserveCorruptFile(paths.outbox); + } + const outbox = parseOutbox(parsed); + if (!outbox) preserveCorruptFile(paths.outbox); + return outbox.batches; +} + +export function savePendingBatches( + batches: PendingUsageBatch[], + paths: SyncStatePaths = DEFAULT_PATHS, +): void { + withFileLock( + `${paths.outbox}.lock`, + OUTBOX_LOCK_TIMEOUT_MS, + OUTBOX_LOCK_STALE_MS, + "Another Straude process is updating the sync outbox. Retry shortly.", + () => { + const validated = parseOutbox({ version: OUTBOX_VERSION, batches }); + if (!validated) throw new Error("Refusing to persist an invalid Straude outbox."); + atomicWriteJson(paths.outbox, validated); + }, + ); +} + +export function upsertPendingBatch( + batch: PendingUsageBatch, + paths: SyncStatePaths = DEFAULT_PATHS, +): void { + withFileLock( + `${paths.outbox}.lock`, + OUTBOX_LOCK_TIMEOUT_MS, + OUTBOX_LOCK_STALE_MS, + "Another Straude process is updating the sync outbox. Retry shortly.", + () => { + const batches = loadPendingBatches(paths); + const index = batches.findIndex( + (candidate) => candidate.request.request_id === batch.request.request_id, + ); + if (index === -1) batches.push(batch); + else batches[index] = batch; + const validated = parseOutbox({ version: OUTBOX_VERSION, batches }); + if (!validated) throw new Error("Refusing to persist an invalid Straude outbox."); + atomicWriteJson(paths.outbox, validated); + }, + ); +} + +export function removePendingBatch( + requestId: string, + paths: SyncStatePaths = DEFAULT_PATHS, +): void { + withFileLock( + `${paths.outbox}.lock`, + OUTBOX_LOCK_TIMEOUT_MS, + OUTBOX_LOCK_STALE_MS, + "Another Straude process is updating the sync outbox. Retry shortly.", + () => { + const batches = loadPendingBatches(paths) + .filter((batch) => batch.request.request_id !== requestId); + const validated = parseOutbox({ version: OUTBOX_VERSION, batches }); + if (!validated) throw new Error("Refusing to persist an invalid Straude outbox."); + atomicWriteJson(paths.outbox, validated); + }, + ); +} + +function parseQueue(path: string): QueueState { + if (!existsSync(path)) return { version: OUTBOX_VERSION, dates: [] }; + try { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return preserveCorruptFile(path); + } + const record = value as Record; + if ( + record.version !== OUTBOX_VERSION + || !Array.isArray(record.dates) + || record.dates.some((date) => typeof date !== "string") + ) { + return preserveCorruptFile(path); + } + const dates = [...new Set(record.dates as string[])].sort(); + dates.forEach((date) => assertCalendarDate(date)); + return { version: OUTBOX_VERSION, dates }; + } catch (error) { + if (error instanceof SyncStateCorruptError) throw error; + return preserveCorruptFile(path); + } +} + +function waitSynchronously(milliseconds: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +function withFileLock( + lockPath: string, + timeoutMs: number, + staleMs: number, + timeoutMessage: string, + operation: () => T, +): T { + ensureParent(lockPath); + const deadline = Date.now() + timeoutMs; + while (true) { + try { + const descriptor = openSync(lockPath, "wx", 0o600); + try { + return operation(); + } finally { + closeSync(descriptor); + unlinkSync(lockPath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + try { + if (Date.now() - statSync(lockPath).mtimeMs > staleMs) { + unlinkSync(lockPath); + continue; + } + } catch (statError) { + if ((statError as NodeJS.ErrnoException).code !== "ENOENT") throw statError; + continue; + } + if (Date.now() >= deadline) { + throw new Error(timeoutMessage); + } + waitSynchronously(10); + } + } +} + +function withQueueLock(paths: SyncStatePaths, operation: () => T): T { + return withFileLock( + `${paths.queue}.lock`, + QUEUE_LOCK_TIMEOUT_MS, + QUEUE_LOCK_STALE_MS, + "Another Straude process is updating the sync queue. Retry shortly.", + operation, + ); +} + +function enqueueDates(dates: string[], paths: SyncStatePaths): void { + withQueueLock(paths, () => { + const queue = parseQueue(paths.queue); + atomicWriteJson(paths.queue, { + version: OUTBOX_VERSION, + dates: [...new Set([...queue.dates, ...dates])].sort(), + } satisfies QueueState); + }); +} + +function peekQueuedDates(paths: SyncStatePaths): string[] { + return withQueueLock(paths, () => parseQueue(paths.queue).dates); +} + +function acknowledgeQueuedDates(dates: string[], paths: SyncStatePaths): void { + const acknowledged = new Set(dates); + withQueueLock(paths, () => { + const queue = parseQueue(paths.queue); + atomicWriteJson(paths.queue, { + version: OUTBOX_VERSION, + dates: queue.dates.filter((date) => !acknowledged.has(date)), + } satisfies QueueState); + }); +} + +function processIsAlive(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function readLock(path: string): LockState | null { + try { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const record = value as Record; + if ( + typeof record.token !== "string" + || typeof record.pid !== "number" + || typeof record.started_at !== "string" + || !Array.isArray(record.dates) + || record.dates.some((date) => typeof date !== "string") + ) { + return null; + } + return { + token: record.token, + pid: record.pid, + started_at: record.started_at, + dates: record.dates as string[], + }; + } catch { + return null; + } +} + +function staleLock(lock: LockState | null, now: number): boolean { + if (!lock) return true; + const started = Date.parse(lock.started_at); + if (!processIsAlive(lock.pid)) return true; + // A live owner remains authoritative even if collection is slow. The age + // check only guards an implausibly old lock whose PID has since been reused. + return Number.isFinite(started) && now - started > LOCK_STALE_AFTER_MS * 96; +} + +function tryCreateLock( + dates: string[], + paths: SyncStatePaths, +): { token: string } | null { + ensureParent(paths.lock); + const token = randomUUID(); + const state: LockState = { + token, + pid: process.pid, + started_at: new Date().toISOString(), + dates, + }; + try { + const descriptor = openSync(paths.lock, "wx", 0o600); + try { + writeFileSync(descriptor, `${JSON.stringify(state)}\n`, "utf8"); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + return { token }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + return null; + } +} + +function releaseLock(token: string, paths: SyncStatePaths): void { + const lock = existsSync(paths.lock) ? readLock(paths.lock) : null; + if (lock?.token === token) unlinkSync(paths.lock); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +export async function acquireSyncLease(options: { + dates: string[]; + interactive: boolean; + waitMs?: number; + paths?: SyncStatePaths; + sleep?: (milliseconds: number) => Promise; +}): Promise { + const paths = options.paths ?? DEFAULT_PATHS; + const requestedDates = [...new Set(options.dates)].sort(); + requestedDates.forEach((date) => assertCalendarDate(date)); + const deadline = Date.now() + (options.waitMs ?? 30_000); + const wait = options.sleep ?? delay; + let queued = false; + + while (true) { + const created = tryCreateLock(requestedDates, paths); + if (created) { + return { + queuedDates: peekQueuedDates(paths), + acknowledgeQueuedDates: (dates) => acknowledgeQueuedDates(dates, paths), + release: () => releaseLock(created.token, paths), + }; + } + + const lock = readLock(paths.lock); + if (staleLock(lock, Date.now())) { + try { + unlinkSync(paths.lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + continue; + } + + if (!queued) { + enqueueDates(requestedDates, paths); + queued = true; + } + if (!options.interactive) return null; + if (Date.now() >= deadline) return null; + await wait(Math.min(LOCK_POLL_MS, Math.max(1, deadline - Date.now()))); + } +} + +export function syncStatePathsForDirectory(directory: string): SyncStatePaths { + return { + outbox: join(directory, "pending-sync.json"), + lock: join(directory, "sync.lock"), + queue: join(directory, "sync-queue.json"), + }; +} + +export function getStateFileMode(path: string): number { + return statSync(path).mode & 0o777; +} diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index b6806fa1..71d86271 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { performance } from "node:perf_hooks"; import type { StraudeConfig } from "./auth.js"; import { getDistinctId } from "./machine-id.js"; @@ -22,8 +23,18 @@ function errorName(error: unknown): string { return typeof error; } -function truncate(value: string, max: number): string { - return value.length > max ? `${value.slice(0, max)}...` : value; +function errorFingerprint(error: unknown): string { + const name = errorName(error); + const stack = error instanceof Error && error.stack + ? error.stack + .split("\n") + .slice(1, 6) + .map((frame) => frame + .replaceAll(/file:\/\/\/[^)\s]+[/\\]([^/\\)\s]+:\d+:\d+)/g, "$1") + .replaceAll(/(?:[A-Za-z]:)?[^()\s]+[/\\]([^/\\)\s]+:\d+:\d+)/g, "$1")) + .join("\n") + : ""; + return createHash("sha256").update(`${name}\n${stack}`).digest("hex").slice(0, 24); } export function reportUsagePushFailed( @@ -35,8 +46,8 @@ export function reportUsagePushFailed( distinctId: getDistinctId(config), event: "usage_push_failed", properties: { - error: truncate(errorMessage(error), 200), error_name: errorName(error), + error_fingerprint: errorFingerprint(error), ...properties, }, }); @@ -47,7 +58,15 @@ export function reportCliException( error: unknown, properties: TelemetryProperties = {}, ): void { - posthog.captureException(error, getDistinctId(config), properties); + posthog.capture({ + distinctId: getDistinctId(config), + event: "cli_exception", + properties: { + error_name: errorName(error), + error_fingerprint: errorFingerprint(error), + ...properties, + }, + }); } export async function shutdownTelemetryWithTimeout( @@ -56,13 +75,17 @@ export async function shutdownTelemetryWithTimeout( const startedAt = performance.now(); let timer: NodeJS.Timeout | undefined; try { - await Promise.race([ - posthog._shutdown(timeoutMs), - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs); - timer.unref?.(); - }), - ]); + try { + await Promise.race([ + posthog._shutdown(timeoutMs), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + }), + ]); + } catch { + // Telemetry must never change a command's result or exit code. + } } finally { if (timer) clearTimeout(timer); } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index b46853ba..5089fa33 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -8,12 +8,14 @@ const externals = Object.keys(pkg.dependencies ?? {}).filter( export default defineConfig({ entry: { index: "src/index.ts" }, format: ["esm"], - target: "node18", + target: "node20", platform: "node", outDir: "dist", clean: true, splitting: false, - sourcemap: false, + // Kept out of the npm package by the package.json files allowlist. Release + // automation stores it as a CI artifact for stack analysis. + sourcemap: true, shims: false, dts: false, external: externals, diff --git a/packages/shared/package.json b/packages/shared/package.json index d753d8ed..5787071c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -20,6 +20,11 @@ "types": "./dist/format.d.ts", "import": "./dist/format.js", "default": "./dist/format.js" + }, + "./usage-protocol": { + "types": "./dist/usage-protocol.d.ts", + "import": "./dist/usage-protocol.js", + "default": "./dist/usage-protocol.js" } }, "files": [ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eac54e7d..a90031f7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from "./models"; export * from "./format"; +export * from "./usage-protocol"; diff --git a/packages/shared/src/usage-protocol.ts b/packages/shared/src/usage-protocol.ts new file mode 100644 index 00000000..26ee403f --- /dev/null +++ b/packages/shared/src/usage-protocol.ts @@ -0,0 +1,781 @@ +export const USAGE_PROTOCOL_VERSION = 2 as const; +export const MAX_USAGE_ENTRIES_V2 = 32; +export const MAX_USAGE_AGENTS_PER_DAY_V2 = 64; +export const USAGE_CONTENT_HASH_PATTERN = /^[a-f0-9]{64}$/; + +const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const COST_EPSILON_USD = 0.005; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface ModelUsageComponent { + model: string; + input_tokens: number; + output_tokens: number; + reasoning_output_tokens: number; + cache_creation_tokens: number; + cache_read_tokens: number; + total_tokens: number; + cost_usd: number; +} + +export interface AgentUsageComponent { + agent: string; + models: string[]; + input_tokens: number; + output_tokens: number; + reasoning_output_tokens: number; + cache_creation_tokens: number; + cache_read_tokens: number; + total_tokens: number; + cost_usd: number; + model_breakdown: ModelUsageComponent[]; +} + +export interface UsageCollectorV2 { + name: string; + version: string; + pricing_mode: "online" | "offline"; + metadata?: { [key: string]: JsonValue }; +} + +export interface UsageInstallationV2 { + id: string; + previous_device_id?: string; + name?: string; +} + +export interface UsageEntryV2 { + date: string; + content_hash: string; + agents: AgentUsageComponent[]; + authoritative_correction?: boolean; + migration_id?: string; +} + +export interface UsageSubmitRequestV2 { + protocol_version: typeof USAGE_PROTOCOL_VERSION; + request_id: string; + source: "cli" | "web"; + timezone: string; + installation: UsageInstallationV2; + collector: UsageCollectorV2; + entries: UsageEntryV2[]; +} + +export interface UsageSubmitResultV2 { + usage_id: string; + post_id: string; + post_url: string; + action: "created" | "updated"; + previous_cost?: number; + daily_total?: number; + device_count?: number; +} + +export type UsageOutcomeStatusV2 = + | "committed" + | "unchanged" + | "retryable_error" + | "permanent_error" + | "identity_conflict"; + +export interface UsageOutcomeErrorV2 { + code: string; + message: string; + retry_after_ms?: number; +} + +export interface UsageOutcomeV2 { + date: string; + status: UsageOutcomeStatusV2; + result?: UsageSubmitResultV2; + error?: UsageOutcomeErrorV2; +} + +export interface UsageSubmitResponseV2 { + request_id: string; + outcomes: UsageOutcomeV2[]; +} + +export interface UsageProtocolError { + code: string; + message: string; + path?: string; +} + +export type UsageProtocolParseResult = + | { ok: true; value: UsageSubmitRequestV2 } + | { ok: false; error: UsageProtocolError }; + +export type AgentUsageParseResult = + | { ok: true; value: AgentUsageComponent } + | { ok: false; error: UsageProtocolError }; + +export type UsageResponseParseResult = + | { ok: true; value: UsageSubmitResponseV2 } + | { ok: false; error: UsageProtocolError }; + +interface NumericUsageFields { + input_tokens: number; + output_tokens: number; + reasoning_output_tokens: number; + cache_creation_tokens: number; + cache_read_tokens: number; + total_tokens: number; + cost_usd: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function error(code: string, message: string, path?: string): UsageProtocolParseResult { + return { ok: false, error: { code, message, path } }; +} + +function isValidDate(value: string): boolean { + const match = ISO_DATE_PATTERN.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year + && date.getUTCMonth() === month - 1 + && date.getUTCDate() === day; +} + +function isValidTimezone(value: string): boolean { + if (value.length === 0 || value.length > 100) return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(); + return true; + } catch { + return false; + } +} + +function readString( + record: Record, + key: string, + path: string, + maxLength = 255, +): string | UsageProtocolError { + const value = record[key]; + if (typeof value !== "string" || value.length === 0 || value.length > maxLength) { + return { + code: "invalid_request", + message: `${path} must be a non-empty string no longer than ${maxLength} characters`, + path, + }; + } + return value; +} + +function readUsageNumbers( + record: Record, + path: string, +): NumericUsageFields | UsageProtocolError { + const integerFields = [ + "input_tokens", + "output_tokens", + "reasoning_output_tokens", + "cache_creation_tokens", + "cache_read_tokens", + "total_tokens", + ] as const; + const values: Partial = {}; + for (const field of integerFields) { + const value = record[field]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return { + code: "invalid_usage_number", + message: `${path}.${field} must be a non-negative safe integer`, + path: `${path}.${field}`, + }; + } + values[field] = value; + } + const cost = record.cost_usd; + if (typeof cost !== "number" || !Number.isFinite(cost) || cost < 0) { + return { + code: "invalid_usage_number", + message: `${path}.cost_usd must be a finite non-negative number`, + path: `${path}.cost_usd`, + }; + } + + const componentTotal = values.input_tokens! + + values.output_tokens! + + values.reasoning_output_tokens! + + values.cache_creation_tokens! + + values.cache_read_tokens!; + if (values.total_tokens !== componentTotal) { + return { + code: "invalid_token_total", + message: `${path}.total_tokens must equal all token categories`, + path: `${path}.total_tokens`, + }; + } + + return { + input_tokens: values.input_tokens!, + output_tokens: values.output_tokens!, + reasoning_output_tokens: values.reasoning_output_tokens!, + cache_creation_tokens: values.cache_creation_tokens!, + cache_read_tokens: values.cache_read_tokens!, + total_tokens: values.total_tokens!, + cost_usd: cost, + }; +} + +function isProtocolError( + value: string | NumericUsageFields | UsageProtocolError, +): value is UsageProtocolError { + return typeof value === "object" && "code" in value; +} + +function parseStringArray( + value: unknown, + path: string, +): string[] | UsageProtocolError { + if (!Array.isArray(value) || value.length === 0) { + return { + code: "invalid_request", + message: `${path} must be a non-empty array`, + path, + }; + } + const strings: string[] = []; + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (typeof item !== "string" || item.length === 0 || item.length > 255) { + return { + code: "invalid_request", + message: `${path}[${index}] must be a non-empty string`, + path: `${path}[${index}]`, + }; + } + if (strings.includes(item)) { + return { + code: "duplicate_model", + message: `${path} must not contain duplicate model ids`, + path, + }; + } + strings.push(item); + } + return strings; +} + +function parseModelComponent( + value: unknown, + path: string, +): ModelUsageComponent | UsageProtocolError { + if (!isRecord(value)) { + return { code: "invalid_request", message: `${path} must be an object`, path }; + } + const model = readString(value, "model", `${path}.model`); + if (typeof model !== "string") return model; + const numbers = readUsageNumbers(value, path); + if (isProtocolError(numbers)) return numbers; + return { model, ...numbers }; +} + +function parseAgentComponent( + value: unknown, + path: string, +): AgentUsageComponent | UsageProtocolError { + if (!isRecord(value)) { + return { code: "invalid_request", message: `${path} must be an object`, path }; + } + const agent = readString(value, "agent", `${path}.agent`, 100); + if (typeof agent !== "string") return agent; + const models = parseStringArray(value.models, `${path}.models`); + if (!Array.isArray(models)) return models; + const numbers = readUsageNumbers(value, path); + if (isProtocolError(numbers)) return numbers; + if (!Array.isArray(value.model_breakdown) || value.model_breakdown.length === 0) { + return { + code: "invalid_request", + message: `${path}.model_breakdown must be a non-empty array`, + path: `${path}.model_breakdown`, + }; + } + + const breakdown: ModelUsageComponent[] = []; + for (let index = 0; index < value.model_breakdown.length; index += 1) { + const parsed = parseModelComponent( + value.model_breakdown[index], + `${path}.model_breakdown[${index}]`, + ); + if ("code" in parsed) return parsed; + if (breakdown.some((item) => item.model === parsed.model)) { + return { + code: "duplicate_model", + message: `${path}.model_breakdown must not contain duplicate model ids`, + path: `${path}.model_breakdown`, + }; + } + breakdown.push(parsed); + } + + const breakdownModels = [...breakdown.map((item) => item.model)].sort(); + const declaredModels = [...models].sort(); + if (JSON.stringify(breakdownModels) !== JSON.stringify(declaredModels)) { + return { + code: "invalid_agent_aggregate", + message: `${path}.models must match model_breakdown model ids`, + path: `${path}.models`, + }; + } + + const aggregate = breakdown.reduce((sum, item) => ({ + input_tokens: sum.input_tokens + item.input_tokens, + output_tokens: sum.output_tokens + item.output_tokens, + reasoning_output_tokens: sum.reasoning_output_tokens + item.reasoning_output_tokens, + cache_creation_tokens: sum.cache_creation_tokens + item.cache_creation_tokens, + cache_read_tokens: sum.cache_read_tokens + item.cache_read_tokens, + total_tokens: sum.total_tokens + item.total_tokens, + cost_usd: sum.cost_usd + item.cost_usd, + }), { + input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + cost_usd: 0, + }); + const integerFields: Array> = [ + "input_tokens", + "output_tokens", + "reasoning_output_tokens", + "cache_creation_tokens", + "cache_read_tokens", + "total_tokens", + ]; + if ( + integerFields.some((field) => aggregate[field] !== numbers[field]) + || Math.abs(aggregate.cost_usd - numbers.cost_usd) > COST_EPSILON_USD + ) { + return { + code: "invalid_agent_aggregate", + message: `${path} totals must equal the sum of model_breakdown`, + path, + }; + } + + return { + agent, + models, + ...numbers, + model_breakdown: breakdown, + }; +} + +export function parseAgentUsageComponent(value: unknown): AgentUsageParseResult { + const parsed = parseAgentComponent(value, "agent"); + return "code" in parsed + ? { ok: false, error: parsed } + : { ok: true, value: parsed }; +} + +function parseJsonObject(value: unknown, path: string): { [key: string]: JsonValue } | UsageProtocolError { + if (!isRecord(value)) { + return { code: "invalid_request", message: `${path} must be an object`, path }; + } + try { + const roundTripped: unknown = JSON.parse(JSON.stringify(value)); + if (!isRecord(roundTripped)) throw new Error("not an object"); + return roundTripped as { [key: string]: JsonValue }; + } catch { + return { + code: "invalid_request", + message: `${path} must contain JSON-compatible values`, + path, + }; + } +} + +function isUsageProtocolError( + value: { [key: string]: JsonValue } | UsageProtocolError, +): value is UsageProtocolError { + return "code" in value + && typeof value.code === "string" + && "message" in value + && typeof value.message === "string"; +} + +export function parseUsageSubmitV2(value: unknown): UsageProtocolParseResult { + if (!isRecord(value) || value.protocol_version !== USAGE_PROTOCOL_VERSION) { + return error("unsupported_protocol", "protocol_version must be 2", "protocol_version"); + } + + const requestId = readString(value, "request_id", "request_id", 128); + if (typeof requestId !== "string") return { ok: false, error: requestId }; + if (value.source !== "cli" && value.source !== "web") { + return error("invalid_source", "source must be cli or web", "source"); + } + const timezone = readString(value, "timezone", "timezone", 100); + if (typeof timezone !== "string") return { ok: false, error: timezone }; + if (!isValidTimezone(timezone)) { + return error("invalid_timezone", "timezone must be a valid IANA timezone", "timezone"); + } + + if (!isRecord(value.installation)) { + return error("invalid_installation", "installation must be an object", "installation"); + } + const installationId = readString(value.installation, "id", "installation.id", 36); + if (typeof installationId !== "string" || !UUID_PATTERN.test(installationId)) { + return error("invalid_installation", "installation.id must be a UUID", "installation.id"); + } + const previousDeviceId = value.installation.previous_device_id; + if ( + previousDeviceId !== undefined + && (typeof previousDeviceId !== "string" + || !UUID_PATTERN.test(previousDeviceId) + || previousDeviceId === installationId) + ) { + return error( + "invalid_installation", + "installation.previous_device_id must be a distinct UUID", + "installation.previous_device_id", + ); + } + const installationName = value.installation.name; + if ( + installationName !== undefined + && (typeof installationName !== "string" || installationName.length > 255) + ) { + return error( + "invalid_installation", + "installation.name must be no longer than 255 characters", + "installation.name", + ); + } + + if (!isRecord(value.collector)) { + return error("invalid_collector", "collector must be an object", "collector"); + } + const collectorName = readString(value.collector, "name", "collector.name", 100); + if (typeof collectorName !== "string") return { ok: false, error: collectorName }; + const collectorVersion = readString(value.collector, "version", "collector.version", 100); + if (typeof collectorVersion !== "string") return { ok: false, error: collectorVersion }; + if (value.collector.pricing_mode !== "online" && value.collector.pricing_mode !== "offline") { + return error( + "invalid_collector", + "collector.pricing_mode must be online or offline", + "collector.pricing_mode", + ); + } + let collectorMetadata: { [key: string]: JsonValue } | undefined; + if (value.collector.metadata !== undefined) { + const parsedMetadata = parseJsonObject(value.collector.metadata, "collector.metadata"); + if (isUsageProtocolError(parsedMetadata)) return { ok: false, error: parsedMetadata }; + collectorMetadata = parsedMetadata; + } + + if ( + !Array.isArray(value.entries) + || value.entries.length === 0 + || value.entries.length > MAX_USAGE_ENTRIES_V2 + ) { + return error( + "invalid_entries", + `entries must contain between 1 and ${MAX_USAGE_ENTRIES_V2} days`, + "entries", + ); + } + + const entries: UsageEntryV2[] = []; + const seenDates = new Set(); + for (let entryIndex = 0; entryIndex < value.entries.length; entryIndex += 1) { + const rawEntry = value.entries[entryIndex]; + const path = `entries[${entryIndex}]`; + if (!isRecord(rawEntry)) { + return error("invalid_entry", `${path} must be an object`, path); + } + const date = rawEntry.date; + if (typeof date !== "string" || !isValidDate(date)) { + return error("invalid_date", `${path}.date must be a real YYYY-MM-DD date`, `${path}.date`); + } + if (seenDates.has(date)) { + return error("duplicate_date", `entries contains duplicate date ${date}`, `${path}.date`); + } + seenDates.add(date); + if (typeof rawEntry.content_hash !== "string" || !USAGE_CONTENT_HASH_PATTERN.test(rawEntry.content_hash)) { + return error( + "invalid_content_hash", + `${path}.content_hash must be a lowercase SHA-256 hex digest`, + `${path}.content_hash`, + ); + } + if ( + !Array.isArray(rawEntry.agents) + || rawEntry.agents.length === 0 + || rawEntry.agents.length > MAX_USAGE_AGENTS_PER_DAY_V2 + ) { + return error( + "invalid_agents", + `${path}.agents must contain between 1 and ${MAX_USAGE_AGENTS_PER_DAY_V2} agents`, + `${path}.agents`, + ); + } + const agents: AgentUsageComponent[] = []; + for (let agentIndex = 0; agentIndex < rawEntry.agents.length; agentIndex += 1) { + const parsedAgent = parseAgentComponent( + rawEntry.agents[agentIndex], + `${path}.agents[${agentIndex}]`, + ); + if ("code" in parsedAgent) return { ok: false, error: parsedAgent }; + if (agents.some((agent) => agent.agent === parsedAgent.agent)) { + return error( + "duplicate_agent", + `${path}.agents must not contain duplicate agent ids`, + `${path}.agents[${agentIndex}].agent`, + ); + } + agents.push(parsedAgent); + } + if ( + rawEntry.authoritative_correction !== undefined + && typeof rawEntry.authoritative_correction !== "boolean" + ) { + return error( + "invalid_entry", + `${path}.authoritative_correction must be boolean`, + `${path}.authoritative_correction`, + ); + } + if ( + rawEntry.migration_id !== undefined + && (typeof rawEntry.migration_id !== "string" + || rawEntry.migration_id.length === 0 + || rawEntry.migration_id.length > 100) + ) { + return error( + "invalid_entry", + `${path}.migration_id must be a non-empty string no longer than 100 characters`, + `${path}.migration_id`, + ); + } + entries.push({ + date, + content_hash: rawEntry.content_hash, + agents, + ...(rawEntry.authoritative_correction === undefined + ? {} + : { authoritative_correction: rawEntry.authoritative_correction }), + ...(rawEntry.migration_id === undefined ? {} : { migration_id: rawEntry.migration_id }), + }); + } + + return { + ok: true, + value: { + protocol_version: USAGE_PROTOCOL_VERSION, + request_id: requestId, + source: value.source, + timezone, + installation: { + id: installationId, + ...(previousDeviceId === undefined ? {} : { previous_device_id: previousDeviceId }), + ...(installationName === undefined ? {} : { name: installationName }), + }, + collector: { + name: collectorName, + version: collectorVersion, + pricing_mode: value.collector.pricing_mode, + ...(collectorMetadata === undefined ? {} : { metadata: collectorMetadata }), + }, + entries, + }, + }; +} + +function sortObject(value: JsonValue): JsonValue { + if (Array.isArray(value)) return value.map(sortObject); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortObject(value[key]!)]), + ); +} + +export function canonicalizeUsageEntryV2(entry: UsageEntryV2): string { + const canonicalAgents = [...entry.agents] + .sort((left, right) => left.agent.localeCompare(right.agent)) + .map((agent) => ({ + ...agent, + models: [...agent.models].sort(), + model_breakdown: [...agent.model_breakdown] + .sort((left, right) => left.model.localeCompare(right.model)) + .map((model) => ({ ...model })), + })); + const canonical = { + date: entry.date, + agents: canonicalAgents, + authoritative_correction: entry.authoritative_correction ?? false, + migration_id: entry.migration_id ?? null, + }; + const jsonCompatible: JsonValue = JSON.parse(JSON.stringify(canonical)); + return JSON.stringify(sortObject(jsonCompatible)); +} + +function parseOutcomeResult(value: unknown, path: string): UsageSubmitResultV2 | UsageProtocolError { + if (!isRecord(value)) { + return { code: "invalid_response", message: `${path} must be an object`, path }; + } + const usageId = readString(value, "usage_id", `${path}.usage_id`, 100); + if (typeof usageId !== "string") return usageId; + const postId = readString(value, "post_id", `${path}.post_id`, 100); + if (typeof postId !== "string") return postId; + const postUrl = readString(value, "post_url", `${path}.post_url`, 2_048); + if (typeof postUrl !== "string") return postUrl; + if (value.action !== "created" && value.action !== "updated") { + return { + code: "invalid_response", + message: `${path}.action must be created or updated`, + path: `${path}.action`, + }; + } + const optionalNumbers = ["previous_cost", "daily_total", "device_count"] as const; + for (const field of optionalNumbers) { + const number = value[field]; + if ( + number !== undefined + && (typeof number !== "number" || !Number.isFinite(number) || number < 0) + ) { + return { + code: "invalid_response", + message: `${path}.${field} must be a finite non-negative number`, + path: `${path}.${field}`, + }; + } + } + return { + usage_id: usageId, + post_id: postId, + post_url: postUrl, + action: value.action, + ...(typeof value.previous_cost === "number" ? { previous_cost: value.previous_cost } : {}), + ...(typeof value.daily_total === "number" ? { daily_total: value.daily_total } : {}), + ...(typeof value.device_count === "number" ? { device_count: value.device_count } : {}), + }; +} + +function parseOutcomeError(value: unknown, path: string): UsageOutcomeErrorV2 | UsageProtocolError { + if (!isRecord(value)) { + return { code: "invalid_response", message: `${path} must be an object`, path }; + } + const code = readString(value, "code", `${path}.code`, 100); + if (typeof code !== "string") return code; + const message = readString(value, "message", `${path}.message`, 2_048); + if (typeof message !== "string") return message; + if ( + value.retry_after_ms !== undefined + && ( + typeof value.retry_after_ms !== "number" + || !Number.isSafeInteger(value.retry_after_ms) + || value.retry_after_ms < 0 + ) + ) { + return { + code: "invalid_response", + message: `${path}.retry_after_ms must be a non-negative safe integer`, + path: `${path}.retry_after_ms`, + }; + } + return { + code, + message, + ...(typeof value.retry_after_ms === "number" + ? { retry_after_ms: value.retry_after_ms } + : {}), + }; +} + +function responseError(code: string, message: string, path?: string): UsageResponseParseResult { + return { ok: false, error: { code, message, path } }; +} + +function isUsageOutcomeStatus(value: unknown): value is UsageOutcomeStatusV2 { + return value === "committed" + || value === "unchanged" + || value === "retryable_error" + || value === "permanent_error" + || value === "identity_conflict"; +} + +export function parseUsageSubmitResponseV2(value: unknown): UsageResponseParseResult { + if (!isRecord(value)) { + return responseError("invalid_response", "response must be an object"); + } + const requestId = readString(value, "request_id", "request_id", 128); + if (typeof requestId !== "string") return { ok: false, error: requestId }; + if ( + !Array.isArray(value.outcomes) + || value.outcomes.length === 0 + || value.outcomes.length > MAX_USAGE_ENTRIES_V2 + ) { + return responseError( + "invalid_response", + `outcomes must contain between 1 and ${MAX_USAGE_ENTRIES_V2} entries`, + "outcomes", + ); + } + + const outcomes: UsageOutcomeV2[] = []; + const seenDates = new Set(); + for (let index = 0; index < value.outcomes.length; index += 1) { + const candidate = value.outcomes[index]; + const path = `outcomes[${index}]`; + if (!isRecord(candidate)) { + return responseError("invalid_response", `${path} must be an object`, path); + } + if (typeof candidate.date !== "string" || !isValidDate(candidate.date)) { + return responseError("invalid_response", `${path}.date must be a real YYYY-MM-DD date`, `${path}.date`); + } + if (seenDates.has(candidate.date)) { + return responseError("duplicate_date", `outcomes contains duplicate date ${candidate.date}`, `${path}.date`); + } + seenDates.add(candidate.date); + if (!isUsageOutcomeStatus(candidate.status)) { + return responseError("invalid_response", `${path}.status is invalid`, `${path}.status`); + } + const status = candidate.status; + if (status === "committed" || status === "unchanged") { + if (candidate.result === undefined) { + outcomes.push({ date: candidate.date, status }); + continue; + } + const result = parseOutcomeResult(candidate.result, `${path}.result`); + if ("code" in result) return { ok: false, error: result }; + outcomes.push({ date: candidate.date, status, result }); + continue; + } + const outcomeError = parseOutcomeError(candidate.error, `${path}.error`); + if ("path" in outcomeError || !("code" in outcomeError) || !("message" in outcomeError)) { + return { ok: false, error: outcomeError }; + } + outcomes.push({ date: candidate.date, status, error: outcomeError }); + } + return { + ok: true, + value: { + request_id: requestId, + outcomes, + }, + }; +} diff --git a/papercuts.md b/papercuts.md new file mode 100644 index 00000000..caa0bd03 --- /dev/null +++ b/papercuts.md @@ -0,0 +1,9 @@ +# Papercuts + +2026-07-23T13:41:45.875Z — gpt-5.6-sol — ohong + +validating GitHub workflow YAML with Ruby → system Ruby 2.6 rejected YAML.load_file aliases keyword; rerun without keyword + +2026-07-23T13:56:29.556Z — gpt-5.6-sol — ohong + +making CLI checks build the shared workspace first → bun with --cwd before run printed help and exited zero instead of running the script; put --cwd after run diff --git a/supabase/migrations/20260723133731_usage_submission_v2.sql b/supabase/migrations/20260723133731_usage_submission_v2.sql new file mode 100644 index 00000000..dd1a0971 --- /dev/null +++ b/supabase/migrations/20260723133731_usage_submission_v2.sql @@ -0,0 +1,886 @@ +CREATE OR REPLACE FUNCTION public.usage_web_installation_id(p_user_id UUID) +RETURNS UUID +LANGUAGE sql +IMMUTABLE +STRICT +SECURITY INVOKER +SET search_path = '' +AS $function$ + SELECT ( + substr(pg_catalog.md5('straude-web-import:' || p_user_id::TEXT), 1, 8) + || '-' + || substr(pg_catalog.md5('straude-web-import:' || p_user_id::TEXT), 9, 4) + || '-5' + || substr(pg_catalog.md5('straude-web-import:' || p_user_id::TEXT), 14, 3) + || '-8' + || substr(pg_catalog.md5('straude-web-import:' || p_user_id::TEXT), 18, 3) + || '-' + || substr(pg_catalog.md5('straude-web-import:' || p_user_id::TEXT), 21, 12) + )::UUID +$function$; + +REVOKE ALL ON FUNCTION public.usage_web_installation_id(UUID) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.usage_web_installation_id(UUID) TO service_role; + +-- The legacy browser import used one reserved device UUID for every account. +-- Normalize it before seeding the user-scoped installation alias table. +UPDATE public.device_usage +SET + device_id = public.usage_web_installation_id(user_id), + device_name = COALESCE(device_name, 'web-import') +WHERE device_id = '00000000-0000-0000-0000-000000000001'::UUID; + +CREATE TABLE public.usage_installation_aliases ( + device_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + canonical_device_id UUID NOT NULL, + name TEXT CHECK (name IS NULL OR char_length(name) <= 255), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, device_id) +); + +CREATE INDEX usage_installation_aliases_user_canonical_idx + ON public.usage_installation_aliases(user_id, canonical_device_id); + +ALTER TABLE public.usage_installation_aliases ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON TABLE public.usage_installation_aliases FROM PUBLIC; +REVOKE ALL ON TABLE public.usage_installation_aliases FROM anon; +REVOKE ALL ON TABLE public.usage_installation_aliases FROM authenticated; +GRANT SELECT, INSERT, UPDATE ON TABLE public.usage_installation_aliases TO service_role; + +-- Preserve the original installation creation order so deterministic repairs +-- can choose the earliest known installation as canonical. +INSERT INTO public.usage_installation_aliases ( + device_id, + user_id, + canonical_device_id, + name, + created_at, + updated_at +) +SELECT + usage.device_id, + usage.user_id, + usage.device_id, + pg_catalog.left(max(usage.device_name), 255), + min(COALESCE(usage.created_at, pg_catalog.now())), + max(COALESCE(usage.updated_at, usage.created_at, pg_catalog.now())) +FROM public.device_usage AS usage +GROUP BY usage.user_id, usage.device_id +ON CONFLICT (user_id, device_id) DO NOTHING; + +CREATE TABLE public.usage_agent_daily ( + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + date DATE NOT NULL, + device_id UUID NOT NULL, + agent TEXT NOT NULL CHECK (char_length(agent) BETWEEN 1 AND 100), + models TEXT[] NOT NULL, + input_tokens BIGINT NOT NULL CHECK (input_tokens >= 0), + output_tokens BIGINT NOT NULL CHECK (output_tokens >= 0), + reasoning_output_tokens BIGINT NOT NULL CHECK (reasoning_output_tokens >= 0), + cache_creation_tokens BIGINT NOT NULL CHECK (cache_creation_tokens >= 0), + cache_read_tokens BIGINT NOT NULL CHECK (cache_read_tokens >= 0), + total_tokens BIGINT NOT NULL CHECK ( + total_tokens = input_tokens + + output_tokens + + reasoning_output_tokens + + cache_creation_tokens + + cache_read_tokens + ), + cost_usd NUMERIC(14, 6) NOT NULL CHECK (cost_usd >= 0), + model_breakdown JSONB NOT NULL CHECK (jsonb_typeof(model_breakdown) = 'array'), + content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), + collector JSONB NOT NULL CHECK (jsonb_typeof(collector) = 'object'), + migration_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, date, device_id, agent) +); + +CREATE INDEX usage_agent_daily_user_date_idx + ON public.usage_agent_daily(user_id, date); + +ALTER TABLE public.usage_agent_daily ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON TABLE public.usage_agent_daily FROM PUBLIC; +REVOKE ALL ON TABLE public.usage_agent_daily FROM anon; +REVOKE ALL ON TABLE public.usage_agent_daily FROM authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.usage_agent_daily TO service_role; + +-- Existing rows predate source partitioning. Keep their accounting under one +-- explicit source until a trusted v2 snapshot atomically replaces it. +INSERT INTO public.usage_agent_daily ( + user_id, + date, + device_id, + agent, + models, + input_tokens, + output_tokens, + reasoning_output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_tokens, + cost_usd, + model_breakdown, + content_hash, + collector, + created_at, + updated_at +) +SELECT + usage.user_id, + usage.date, + usage.device_id, + 'legacy-unpartitioned', + ARRAY( + SELECT value + FROM jsonb_array_elements_text(COALESCE(usage.models, '[]'::JSONB)) AS value + ), + GREATEST(COALESCE(usage.input_tokens, 0), 0), + GREATEST(COALESCE(usage.output_tokens, 0), 0), + GREATEST(COALESCE(usage.reasoning_output_tokens, 0), 0) + + GREATEST( + COALESCE(usage.total_tokens, 0) + - GREATEST(COALESCE(usage.input_tokens, 0), 0) + - GREATEST(COALESCE(usage.output_tokens, 0), 0) + - GREATEST(COALESCE(usage.reasoning_output_tokens, 0), 0) + - GREATEST(COALESCE(usage.cache_creation_tokens, 0), 0) + - GREATEST(COALESCE(usage.cache_read_tokens, 0), 0), + 0 + ), + GREATEST(COALESCE(usage.cache_creation_tokens, 0), 0), + GREATEST(COALESCE(usage.cache_read_tokens, 0), 0), + GREATEST( + COALESCE(usage.total_tokens, 0), + GREATEST(COALESCE(usage.input_tokens, 0), 0) + + GREATEST(COALESCE(usage.output_tokens, 0), 0) + + GREATEST(COALESCE(usage.reasoning_output_tokens, 0), 0) + + GREATEST(COALESCE(usage.cache_creation_tokens, 0), 0) + + GREATEST(COALESCE(usage.cache_read_tokens, 0), 0) + ), + GREATEST(COALESCE(usage.cost_usd, 0), 0), + COALESCE(usage.model_breakdown, '[]'::JSONB), + pg_catalog.md5( + usage.user_id::TEXT || ':' || usage.date::TEXT || ':' || usage.device_id::TEXT + ) || pg_catalog.md5(COALESCE(usage.raw_hash, 'legacy-unpartitioned')), + CASE + WHEN jsonb_typeof(usage.collector_meta) = 'object' THEN usage.collector_meta + ELSE jsonb_build_object('name', 'legacy-unpartitioned') + END, + COALESCE(usage.created_at, pg_catalog.now()), + COALESCE(usage.updated_at, usage.created_at, pg_catalog.now()) +FROM public.device_usage AS usage +ON CONFLICT (user_id, date, device_id, agent) DO NOTHING; + +CREATE TABLE public.usage_submission_outcomes ( + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + request_id TEXT NOT NULL CHECK (char_length(request_id) BETWEEN 1 AND 128), + date DATE NOT NULL, + content_hash TEXT NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'), + canonical_payload_hash TEXT NOT NULL CHECK (canonical_payload_hash ~ '^[a-f0-9]{64}$'), + outcome JSONB NOT NULL CHECK (jsonb_typeof(outcome) = 'object'), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, request_id, date) +); + +ALTER TABLE public.usage_submission_outcomes ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON TABLE public.usage_submission_outcomes FROM PUBLIC; +REVOKE ALL ON TABLE public.usage_submission_outcomes FROM anon; +REVOKE ALL ON TABLE public.usage_submission_outcomes FROM authenticated; +GRANT SELECT, INSERT, UPDATE ON TABLE public.usage_submission_outcomes TO service_role; + +CREATE OR REPLACE FUNCTION public.submit_usage_day_v2( + p_user_id UUID, + p_request_id TEXT, + p_source TEXT, + p_timezone TEXT, + p_installation JSONB, + p_collector JSONB, + p_entry JSONB, + p_canonical_payload_hash TEXT, + p_is_verified BOOLEAN +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $function$ +DECLARE + v_date DATE := (p_entry ->> 'date')::DATE; + v_content_hash TEXT := p_entry ->> 'content_hash'; + v_device_id UUID := (p_installation ->> 'id')::UUID; + v_previous_device_id UUID := NULLIF(p_installation ->> 'previous_device_id', '')::UUID; + v_device_name TEXT := NULLIF(p_installation ->> 'name', ''); + v_lock_device_id UUID; + v_current_canonical UUID; + v_previous_canonical UUID; + v_canonical_device_id UUID; + v_existing_outcome public.usage_submission_outcomes%ROWTYPE; + v_existing_agent public.usage_agent_daily%ROWTYPE; + v_agent JSONB; + v_migration_id TEXT := NULLIF(p_entry ->> 'migration_id', ''); + v_trusted_partitioned_snapshot BOOLEAN := + p_is_verified + AND p_source = 'cli' + AND p_collector ->> 'name' = 'ccusage' + AND p_collector ->> 'version' = '20.0.16' + AND p_collector ->> 'pricing_mode' = 'online'; + v_authoritative BOOLEAN := + v_trusted_partitioned_snapshot + AND COALESCE((p_entry ->> 'authoritative_correction')::BOOLEAN, false) + AND NULLIF(p_entry ->> 'migration_id', '') = 'ccusage-by-agent-v2'; + v_legacy_authoritative BOOLEAN := + p_is_verified + AND p_source = 'cli' + AND p_collector ->> 'name' = 'legacy-ccusage' + AND COALESCE((p_entry ->> 'authoritative_correction')::BOOLEAN, false) + AND NULLIF(p_entry ->> 'migration_id', '') = 'legacy-codex-correction-v1'; + v_previous_cost NUMERIC; + v_usage_id UUID; + v_post_id UUID; + v_action TEXT; + v_device_count INTEGER; + v_daily_total NUMERIC; + v_outcome JSONB; + v_reconciliation_candidate_id UUID; + v_possible_duplicate_device_id UUID; +BEGIN + IF p_source NOT IN ('cli', 'web') THEN + RAISE EXCEPTION 'invalid source' USING ERRCODE = '22023'; + END IF; + IF p_timezone IS NULL OR p_timezone = '' THEN + RAISE EXCEPTION 'timezone is required' USING ERRCODE = '22023'; + END IF; + IF jsonb_typeof(p_installation) <> 'object' + OR jsonb_typeof(p_collector) <> 'object' + OR jsonb_typeof(p_entry) <> 'object' + OR jsonb_typeof(p_entry -> 'agents') <> 'array' + THEN + RAISE EXCEPTION 'invalid usage payload' USING ERRCODE = '22023'; + END IF; + IF p_source = 'web' THEN + v_device_id := public.usage_web_installation_id(p_user_id); + v_previous_device_id := NULL; + v_device_name := COALESCE(v_device_name, 'web-import'); + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::TEXT || ':' || v_date::TEXT, 0) + ); + FOR v_lock_device_id IN + SELECT DISTINCT device_id + FROM unnest(ARRAY[v_device_id, v_previous_device_id]) AS ids(device_id) + WHERE device_id IS NOT NULL + ORDER BY device_id + LOOP + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended('usage-installation:' || v_lock_device_id::TEXT, 0) + ); + END LOOP; + + SELECT * + INTO v_existing_outcome + FROM public.usage_submission_outcomes + WHERE user_id = p_user_id + AND request_id = p_request_id + AND date = v_date + FOR UPDATE; + + IF FOUND THEN + IF v_existing_outcome.content_hash = v_content_hash + AND v_existing_outcome.canonical_payload_hash = p_canonical_payload_hash + THEN + RETURN v_existing_outcome.outcome || jsonb_build_object('status', 'unchanged'); + END IF; + RETURN jsonb_build_object( + 'date', v_date, + 'status', 'identity_conflict', + 'error', jsonb_build_object( + 'code', 'idempotency_conflict', + 'message', 'request_id and date already committed with different content' + ) + ); + END IF; + + SELECT candidate.id + INTO v_reconciliation_candidate_id + FROM public.usage_device_reconciliation_candidates AS candidate + WHERE candidate.user_id = p_user_id + AND candidate.status IN ('proof_merge', 'ambiguous') + AND ( + candidate.device_id_a IN (v_device_id, v_previous_device_id) + OR candidate.device_id_b IN (v_device_id, v_previous_device_id) + ) + ORDER BY candidate.created_at + LIMIT 1; + IF v_reconciliation_candidate_id IS NOT NULL THEN + RETURN jsonb_build_object( + 'date', v_date, + 'status', 'identity_conflict', + 'error', jsonb_build_object( + 'code', 'device_reconciliation_required', + 'message', 'Device identity must be resolved before usage can be submitted' + ) + ); + END IF; + + SELECT canonical_device_id + INTO v_current_canonical + FROM public.usage_installation_aliases + WHERE user_id = p_user_id + AND device_id = v_device_id + FOR UPDATE; + + IF v_previous_device_id IS NOT NULL THEN + SELECT canonical_device_id + INTO v_previous_canonical + FROM public.usage_installation_aliases + WHERE user_id = p_user_id + AND device_id = v_previous_device_id + FOR UPDATE; + END IF; + + IF v_current_canonical IS NOT NULL + AND v_previous_canonical IS NOT NULL + AND v_current_canonical <> v_previous_canonical + THEN + RETURN jsonb_build_object( + 'date', v_date, + 'status', 'identity_conflict', + 'error', jsonb_build_object( + 'code', 'installation_alias_conflict', + 'message', 'installation and previous_device_id resolve to different identities' + ) + ); + END IF; + + v_canonical_device_id := COALESCE( + v_current_canonical, + v_previous_canonical, + v_previous_device_id, + v_device_id + ); + INSERT INTO public.usage_installation_aliases ( + device_id, + user_id, + canonical_device_id, + name, + updated_at + ) + VALUES ( + v_device_id, + p_user_id, + v_canonical_device_id, + v_device_name, + pg_catalog.now() + ) + ON CONFLICT (user_id, device_id) DO UPDATE + SET name = COALESCE(EXCLUDED.name, public.usage_installation_aliases.name), + updated_at = pg_catalog.now(); + + IF v_previous_device_id IS NOT NULL THEN + INSERT INTO public.usage_installation_aliases ( + device_id, + user_id, + canonical_device_id, + updated_at + ) + VALUES ( + v_previous_device_id, + p_user_id, + v_canonical_device_id, + pg_catalog.now() + ) + ON CONFLICT (user_id, device_id) DO NOTHING; + END IF; + + IF v_device_name IS NOT NULL THEN + SELECT alias.canonical_device_id + INTO v_possible_duplicate_device_id + FROM public.usage_installation_aliases AS alias + WHERE alias.user_id = p_user_id + AND alias.canonical_device_id <> v_canonical_device_id + AND lower(pg_catalog.regexp_replace(alias.name, '[^a-zA-Z0-9]+', '', 'g')) + = lower(pg_catalog.regexp_replace(v_device_name, '[^a-zA-Z0-9]+', '', 'g')) + ORDER BY alias.created_at, alias.canonical_device_id + LIMIT 1; + + IF v_possible_duplicate_device_id IS NOT NULL THEN + INSERT INTO public.usage_device_reconciliation_candidates ( + user_id, + device_id_a, + device_id_b, + normalized_hostname, + status, + proof + ) + VALUES ( + p_user_id, + CASE + WHEN v_canonical_device_id::TEXT < v_possible_duplicate_device_id::TEXT + THEN v_canonical_device_id + ELSE v_possible_duplicate_device_id + END, + CASE + WHEN v_canonical_device_id::TEXT < v_possible_duplicate_device_id::TEXT + THEN v_possible_duplicate_device_id + ELSE v_canonical_device_id + END, + lower(pg_catalog.regexp_replace(v_device_name, '[^a-zA-Z0-9]+', '', 'g')), + 'ambiguous', + jsonb_build_object('algorithm', 'hostname-quarantine-v1') + ) + ON CONFLICT (user_id, device_id_a, device_id_b) DO NOTHING; + + SELECT candidate.id + INTO v_reconciliation_candidate_id + FROM public.usage_device_reconciliation_candidates AS candidate + WHERE candidate.user_id = p_user_id + AND candidate.status IN ('proof_merge', 'ambiguous') + AND candidate.device_id_a IN ( + v_canonical_device_id, v_possible_duplicate_device_id + ) + AND candidate.device_id_b IN ( + v_canonical_device_id, v_possible_duplicate_device_id + ) + LIMIT 1; + IF v_reconciliation_candidate_id IS NOT NULL THEN + RETURN jsonb_build_object( + 'date', v_date, + 'status', 'identity_conflict', + 'error', jsonb_build_object( + 'code', 'device_reconciliation_required', + 'message', 'Device identity must be resolved before usage can be submitted' + ) + ); + END IF; + END IF; + END IF; + + SELECT cost_usd + INTO v_previous_cost + FROM public.daily_usage + WHERE user_id = p_user_id + AND date = v_date + FOR UPDATE; + v_action := CASE WHEN FOUND THEN 'updated' ELSE 'created' END; + + FOR v_agent IN + SELECT value + FROM jsonb_array_elements(p_entry -> 'agents') + LOOP + SELECT * + INTO v_existing_agent + FROM public.usage_agent_daily + WHERE user_id = p_user_id + AND date = v_date + AND device_id = v_canonical_device_id + AND agent = v_agent ->> 'agent' + FOR UPDATE; + + IF NOT FOUND + OR v_authoritative + OR ( + v_legacy_authoritative + AND v_agent ->> 'agent' = 'legacy-unpartitioned' + ) + OR ( + (v_agent ->> 'cost_usd')::NUMERIC >= v_existing_agent.cost_usd + AND (v_agent ->> 'input_tokens')::BIGINT >= v_existing_agent.input_tokens + AND (v_agent ->> 'output_tokens')::BIGINT >= v_existing_agent.output_tokens + AND (v_agent ->> 'reasoning_output_tokens')::BIGINT >= v_existing_agent.reasoning_output_tokens + AND (v_agent ->> 'cache_creation_tokens')::BIGINT >= v_existing_agent.cache_creation_tokens + AND (v_agent ->> 'cache_read_tokens')::BIGINT >= v_existing_agent.cache_read_tokens + AND (v_agent ->> 'total_tokens')::BIGINT >= v_existing_agent.total_tokens + ) + THEN + INSERT INTO public.usage_agent_daily ( + user_id, + date, + device_id, + agent, + models, + input_tokens, + output_tokens, + reasoning_output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_tokens, + cost_usd, + model_breakdown, + content_hash, + collector, + migration_id, + updated_at + ) + VALUES ( + p_user_id, + v_date, + v_canonical_device_id, + v_agent ->> 'agent', + ARRAY(SELECT jsonb_array_elements_text(v_agent -> 'models')), + (v_agent ->> 'input_tokens')::BIGINT, + (v_agent ->> 'output_tokens')::BIGINT, + (v_agent ->> 'reasoning_output_tokens')::BIGINT, + (v_agent ->> 'cache_creation_tokens')::BIGINT, + (v_agent ->> 'cache_read_tokens')::BIGINT, + (v_agent ->> 'total_tokens')::BIGINT, + (v_agent ->> 'cost_usd')::NUMERIC, + v_agent -> 'model_breakdown', + v_content_hash, + p_collector, + v_migration_id, + pg_catalog.now() + ) + ON CONFLICT (user_id, date, device_id, agent) DO UPDATE + SET models = EXCLUDED.models, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + reasoning_output_tokens = EXCLUDED.reasoning_output_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + total_tokens = EXCLUDED.total_tokens, + cost_usd = EXCLUDED.cost_usd, + model_breakdown = EXCLUDED.model_breakdown, + content_hash = EXCLUDED.content_hash, + collector = EXCLUDED.collector, + migration_id = EXCLUDED.migration_id, + updated_at = pg_catalog.now(); + END IF; + END LOOP; + + IF v_trusted_partitioned_snapshot + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements(p_entry -> 'agents') AS submitted(value) + WHERE submitted.value ->> 'agent' = 'legacy-unpartitioned' + ) + THEN + DELETE FROM public.usage_agent_daily + WHERE user_id = p_user_id + AND date = v_date + AND device_id = v_canonical_device_id + AND agent = 'legacy-unpartitioned'; + END IF; + + IF v_authoritative THEN + DELETE FROM public.usage_agent_daily + WHERE user_id = p_user_id + AND date = v_date + AND device_id = v_canonical_device_id + AND agent NOT IN ( + SELECT value ->> 'agent' + FROM jsonb_array_elements(p_entry -> 'agents') + ); + END IF; + + WITH agent_totals AS ( + SELECT + COALESCE(sum(cost_usd), 0) AS cost_usd, + COALESCE(sum(input_tokens), 0) AS input_tokens, + COALESCE(sum(output_tokens), 0) AS output_tokens, + COALESCE(sum(reasoning_output_tokens), 0) AS reasoning_output_tokens, + COALESCE(sum(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(sum(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(sum(total_tokens), 0) AS total_tokens, + count(*)::INTEGER AS session_count + FROM public.usage_agent_daily + WHERE user_id = p_user_id + AND date = v_date + AND device_id = v_canonical_device_id + ), + model_names AS ( + SELECT COALESCE(jsonb_agg(DISTINCT model ORDER BY model), '[]'::JSONB) AS models + FROM public.usage_agent_daily rows + CROSS JOIN LATERAL unnest(rows.models) AS model + WHERE rows.user_id = p_user_id + AND rows.date = v_date + AND rows.device_id = v_canonical_device_id + ), + model_costs AS ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object('model', model, 'cost_usd', cost_usd) + ORDER BY model + ), + '[]'::JSONB + ) AS model_breakdown + FROM ( + SELECT + breakdown ->> 'model' AS model, + sum((breakdown ->> 'cost_usd')::NUMERIC) AS cost_usd + FROM public.usage_agent_daily rows + CROSS JOIN LATERAL jsonb_array_elements(rows.model_breakdown) AS breakdown + WHERE rows.user_id = p_user_id + AND rows.date = v_date + AND rows.device_id = v_canonical_device_id + GROUP BY breakdown ->> 'model' + ) costs + ) + INSERT INTO public.device_usage ( + user_id, + device_id, + device_name, + date, + cost_usd, + input_tokens, + output_tokens, + reasoning_output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_tokens, + models, + model_breakdown, + session_count, + raw_hash, + collector_meta, + updated_at + ) + SELECT + p_user_id, + v_canonical_device_id, + v_device_name, + v_date, + agent_totals.cost_usd, + agent_totals.input_tokens, + agent_totals.output_tokens, + agent_totals.reasoning_output_tokens, + agent_totals.cache_creation_tokens, + agent_totals.cache_read_tokens, + agent_totals.total_tokens, + model_names.models, + model_costs.model_breakdown, + agent_totals.session_count, + v_content_hash, + p_collector, + pg_catalog.now() + FROM agent_totals, model_names, model_costs + ON CONFLICT (user_id, date, device_id) DO UPDATE + SET device_name = COALESCE(EXCLUDED.device_name, public.device_usage.device_name), + cost_usd = EXCLUDED.cost_usd, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + reasoning_output_tokens = EXCLUDED.reasoning_output_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + total_tokens = EXCLUDED.total_tokens, + models = EXCLUDED.models, + model_breakdown = EXCLUDED.model_breakdown, + session_count = EXCLUDED.session_count, + raw_hash = EXCLUDED.raw_hash, + collector_meta = EXCLUDED.collector_meta, + updated_at = pg_catalog.now(); + + WITH device_totals AS ( + SELECT + COALESCE(sum(cost_usd), 0) AS cost_usd, + COALESCE(sum(input_tokens), 0) AS input_tokens, + COALESCE(sum(output_tokens), 0) AS output_tokens, + COALESCE(sum(reasoning_output_tokens), 0) AS reasoning_output_tokens, + COALESCE(sum(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(sum(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(sum(total_tokens), 0) AS total_tokens, + count(*)::INTEGER AS device_count + FROM public.device_usage + WHERE user_id = p_user_id + AND date = v_date + ), + model_names AS ( + SELECT COALESCE(jsonb_agg(DISTINCT model ORDER BY model), '[]'::JSONB) AS models + FROM public.device_usage rows + CROSS JOIN LATERAL jsonb_array_elements_text( + COALESCE(rows.models, '[]'::JSONB) + ) AS model + WHERE rows.user_id = p_user_id + AND rows.date = v_date + ), + model_costs AS ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object('model', model, 'cost_usd', cost_usd) + ORDER BY model + ), + '[]'::JSONB + ) AS model_breakdown + FROM ( + SELECT + breakdown ->> 'model' AS model, + sum((breakdown ->> 'cost_usd')::NUMERIC) AS cost_usd + FROM public.device_usage rows + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(rows.model_breakdown, '[]'::JSONB)) AS breakdown + WHERE rows.user_id = p_user_id + AND rows.date = v_date + GROUP BY breakdown ->> 'model' + ) costs + ) + INSERT INTO public.daily_usage ( + user_id, + date, + cost_usd, + input_tokens, + output_tokens, + reasoning_output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_tokens, + models, + model_breakdown, + session_count, + is_verified, + raw_hash, + collector_meta, + updated_at + ) + SELECT + p_user_id, + v_date, + device_totals.cost_usd, + device_totals.input_tokens, + device_totals.output_tokens, + device_totals.reasoning_output_tokens, + device_totals.cache_creation_tokens, + device_totals.cache_read_tokens, + device_totals.total_tokens, + model_names.models, + model_costs.model_breakdown, + device_totals.device_count, + p_is_verified, + v_content_hash, + p_collector, + pg_catalog.now() + FROM device_totals, model_names, model_costs + ON CONFLICT (user_id, date) DO UPDATE + SET cost_usd = EXCLUDED.cost_usd, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + reasoning_output_tokens = EXCLUDED.reasoning_output_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + total_tokens = EXCLUDED.total_tokens, + models = EXCLUDED.models, + model_breakdown = EXCLUDED.model_breakdown, + session_count = EXCLUDED.session_count, + is_verified = public.daily_usage.is_verified OR EXCLUDED.is_verified, + raw_hash = EXCLUDED.raw_hash, + collector_meta = EXCLUDED.collector_meta, + updated_at = pg_catalog.now() + RETURNING id, cost_usd + INTO v_usage_id, v_daily_total; + + INSERT INTO public.posts ( + user_id, + daily_usage_id, + title, + usage_generated_title, + updated_at + ) + VALUES ( + p_user_id, + v_usage_id, + pg_catalog.to_char(v_date, 'Mon FMDD') + || CASE + WHEN v_daily_total > 0 + THEN ', $' || pg_catalog.to_char(v_daily_total, 'FM999999990.00') + ELSE '' + END, + true, + pg_catalog.now() + ) + ON CONFLICT (daily_usage_id) DO UPDATE + SET title = CASE + WHEN public.posts.usage_generated_title THEN EXCLUDED.title + ELSE public.posts.title + END, + updated_at = pg_catalog.now() + RETURNING id + INTO v_post_id; + + SELECT count(*)::INTEGER + INTO v_device_count + FROM public.device_usage + WHERE user_id = p_user_id + AND date = v_date; + + v_outcome := jsonb_build_object( + 'date', v_date, + 'status', 'committed', + 'result', jsonb_strip_nulls(jsonb_build_object( + 'usage_id', v_usage_id, + 'post_id', v_post_id, + 'action', v_action, + 'previous_cost', v_previous_cost, + 'daily_total', v_daily_total, + 'device_count', v_device_count + )) + ); + + INSERT INTO public.usage_submission_outcomes ( + user_id, + request_id, + date, + content_hash, + canonical_payload_hash, + outcome + ) + VALUES ( + p_user_id, + p_request_id, + v_date, + v_content_hash, + p_canonical_payload_hash, + v_outcome + ); + + RETURN v_outcome; +END; +$function$; + +REVOKE ALL ON FUNCTION public.submit_usage_day_v2( + UUID, + TEXT, + TEXT, + TEXT, + JSONB, + JSONB, + JSONB, + TEXT, + BOOLEAN +) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.submit_usage_day_v2( + UUID, + TEXT, + TEXT, + TEXT, + JSONB, + JSONB, + JSONB, + TEXT, + BOOLEAN +) FROM anon; +REVOKE ALL ON FUNCTION public.submit_usage_day_v2( + UUID, + TEXT, + TEXT, + TEXT, + JSONB, + JSONB, + JSONB, + TEXT, + BOOLEAN +) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.submit_usage_day_v2( + UUID, + TEXT, + TEXT, + TEXT, + JSONB, + JSONB, + JSONB, + TEXT, + BOOLEAN +) TO service_role; diff --git a/supabase/migrations/20260723135641_usage_reconciliation.sql b/supabase/migrations/20260723135641_usage_reconciliation.sql new file mode 100644 index 00000000..bd0e12a1 --- /dev/null +++ b/supabase/migrations/20260723135641_usage_reconciliation.sql @@ -0,0 +1,1173 @@ +ALTER TABLE public.posts + ADD COLUMN usage_generated_title BOOLEAN NOT NULL DEFAULT false; + +UPDATE public.posts AS post +SET usage_generated_title = true +FROM public.daily_usage AS daily +WHERE post.daily_usage_id = daily.id + AND ( + post.title = pg_catalog.to_char(daily.date, 'Mon FMDD') + || CASE + WHEN daily.cost_usd > 0 + THEN ', $' || pg_catalog.to_char(daily.cost_usd, 'FM999999990.00') + ELSE '' + END + OR post.title ~ '^[A-Z][a-z]{2} [0-9]{1,2}( — .+)?$' + ); + +CREATE TABLE public.usage_device_reconciliation_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + device_id_a UUID NOT NULL, + device_id_b UUID NOT NULL, + normalized_hostname TEXT NOT NULL, + overlap_dates DATE[] NOT NULL DEFAULT '{}', + divergent_dates DATE[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL CHECK ( + status IN ('proof_merge', 'ambiguous', 'merged', 'kept_separate') + ), + proof JSONB NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(proof) = 'object'), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + CHECK (device_id_a::TEXT < device_id_b::TEXT), + UNIQUE (user_id, device_id_a, device_id_b) +); + +CREATE INDEX usage_device_candidates_user_status_idx + ON public.usage_device_reconciliation_candidates(user_id, status, created_at); + +CREATE TABLE public.usage_device_reconciliation_decisions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + candidate_id UUID NOT NULL + REFERENCES public.usage_device_reconciliation_candidates(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + decision TEXT NOT NULL CHECK (decision IN ('merge', 'keep_separate')), + canonical_device_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE public.usage_repair_batches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reason TEXT NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 500), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'running', 'completed', 'rolled_back', 'failed')), + cursor_candidate_id UUID, + processed_count INTEGER NOT NULL DEFAULT 0 CHECK (processed_count >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); + +CREATE TABLE public.usage_corrections_ledger ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + batch_id UUID NOT NULL REFERENCES public.usage_repair_batches(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + table_name TEXT NOT NULL CHECK ( + table_name IN ( + 'usage_installation_aliases', + 'usage_agent_daily', + 'device_usage', + 'daily_usage', + 'posts', + 'usage_device_reconciliation_candidates', + 'usage_device_reconciliation_decisions' + ) + ), + row_key JSONB NOT NULL CHECK (jsonb_typeof(row_key) = 'object'), + before_row JSONB, + after_row JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX usage_corrections_ledger_batch_idx + ON public.usage_corrections_ledger(batch_id, id); + +ALTER TABLE public.usage_device_reconciliation_candidates ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.usage_device_reconciliation_decisions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.usage_repair_batches ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.usage_corrections_ledger ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON TABLE public.usage_device_reconciliation_candidates FROM PUBLIC, anon, authenticated; +REVOKE ALL ON TABLE public.usage_device_reconciliation_decisions FROM PUBLIC, anon, authenticated; +REVOKE ALL ON TABLE public.usage_repair_batches FROM PUBLIC, anon, authenticated; +REVOKE ALL ON TABLE public.usage_corrections_ledger FROM PUBLIC, anon, authenticated; +GRANT SELECT, INSERT, UPDATE ON TABLE public.usage_device_reconciliation_candidates TO service_role; +GRANT SELECT, INSERT, DELETE ON TABLE public.usage_device_reconciliation_decisions TO service_role; +GRANT SELECT, INSERT, UPDATE ON TABLE public.usage_repair_batches TO service_role; +GRANT SELECT, INSERT, UPDATE ON TABLE public.usage_corrections_ledger TO service_role; +GRANT USAGE, SELECT ON SEQUENCE public.usage_corrections_ledger_id_seq TO service_role; +GRANT DELETE ON TABLE public.device_usage TO service_role; + +CREATE OR REPLACE FUNCTION public.list_usage_device_candidates(p_user_id UUID) +RETURNS TABLE ( + id UUID, + device_id_a UUID, + device_id_b UUID, + normalized_hostname TEXT, + overlap_dates DATE[], + status TEXT, + created_at TIMESTAMPTZ +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $function$ + SELECT + candidate.id, + candidate.device_id_a, + candidate.device_id_b, + candidate.normalized_hostname, + candidate.overlap_dates, + candidate.status, + candidate.created_at + FROM public.usage_device_reconciliation_candidates AS candidate + WHERE candidate.user_id = p_user_id + AND candidate.status IN ('proof_merge', 'ambiguous') + ORDER BY candidate.created_at, candidate.id; +$function$; + +CREATE OR REPLACE FUNCTION public.discover_usage_device_candidates( + p_user_id UUID DEFAULT NULL +) +RETURNS INTEGER +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $function$ +DECLARE + v_inserted INTEGER; +BEGIN + INSERT INTO public.usage_installation_aliases ( + device_id, user_id, canonical_device_id, name + ) + SELECT + usage.device_id, + usage.user_id, + usage.device_id, + max(usage.device_name) + FROM public.device_usage AS usage + WHERE p_user_id IS NULL OR usage.user_id = p_user_id + GROUP BY usage.user_id, usage.device_id + ON CONFLICT (user_id, device_id) DO NOTHING; + + WITH named_devices AS ( + SELECT + alias.user_id, + alias.canonical_device_id AS device_id, + lower(pg_catalog.regexp_replace(alias.name, '[^a-zA-Z0-9]+', '', 'g')) + AS normalized_hostname + FROM public.usage_installation_aliases AS alias + WHERE alias.name IS NOT NULL + AND (p_user_id IS NULL OR alias.user_id = p_user_id) + GROUP BY alias.user_id, alias.canonical_device_id, + lower(pg_catalog.regexp_replace(alias.name, '[^a-zA-Z0-9]+', '', 'g')) + ), + pairs AS ( + SELECT + left_device.user_id, + left_device.device_id AS device_id_a, + right_device.device_id AS device_id_b, + left_device.normalized_hostname + FROM named_devices AS left_device + JOIN named_devices AS right_device + ON right_device.user_id = left_device.user_id + AND right_device.normalized_hostname = left_device.normalized_hostname + AND left_device.device_id::TEXT < right_device.device_id::TEXT + WHERE left_device.normalized_hostname <> '' + ), + agent_fingerprints AS ( + SELECT + rows.user_id, + rows.device_id, + rows.date, + jsonb_agg( + jsonb_build_object( + 'agent', rows.agent, + 'models', rows.models, + 'input_tokens', rows.input_tokens, + 'output_tokens', rows.output_tokens, + 'reasoning_output_tokens', rows.reasoning_output_tokens, + 'cache_creation_tokens', rows.cache_creation_tokens, + 'cache_read_tokens', rows.cache_read_tokens, + 'total_tokens', rows.total_tokens, + 'cost_usd', rows.cost_usd, + 'model_breakdown', rows.model_breakdown, + 'collector', rows.collector + ) + ORDER BY rows.agent + ) AS fingerprint + FROM public.usage_agent_daily AS rows + GROUP BY rows.user_id, rows.device_id, rows.date + ), + fingerprints AS ( + SELECT + pair.*, + COALESCE(array_agg(left_usage.date ORDER BY left_usage.date) + FILTER ( + WHERE left_usage.date IS NOT NULL + AND right_usage.date IS NOT NULL + AND left_agents.fingerprint IS NOT NULL + AND left_agents.fingerprint = right_agents.fingerprint + AND jsonb_build_array( + left_usage.cost_usd, left_usage.input_tokens, left_usage.output_tokens, + left_usage.reasoning_output_tokens, left_usage.cache_creation_tokens, + left_usage.cache_read_tokens, left_usage.total_tokens, + left_usage.models, left_usage.model_breakdown + ) = jsonb_build_array( + right_usage.cost_usd, right_usage.input_tokens, right_usage.output_tokens, + right_usage.reasoning_output_tokens, right_usage.cache_creation_tokens, + right_usage.cache_read_tokens, right_usage.total_tokens, + right_usage.models, right_usage.model_breakdown + ) + ), '{}') AS overlap_dates, + COALESCE(array_agg(left_usage.date ORDER BY left_usage.date) + FILTER ( + WHERE left_usage.date IS NOT NULL + AND right_usage.date IS NOT NULL + AND ( + left_agents.fingerprint IS DISTINCT FROM right_agents.fingerprint + OR jsonb_build_array( + left_usage.cost_usd, left_usage.input_tokens, left_usage.output_tokens, + left_usage.reasoning_output_tokens, left_usage.cache_creation_tokens, + left_usage.cache_read_tokens, left_usage.total_tokens, + left_usage.models, left_usage.model_breakdown + ) IS DISTINCT FROM jsonb_build_array( + right_usage.cost_usd, right_usage.input_tokens, right_usage.output_tokens, + right_usage.reasoning_output_tokens, right_usage.cache_creation_tokens, + right_usage.cache_read_tokens, right_usage.total_tokens, + right_usage.models, right_usage.model_breakdown + ) + ) + ), '{}') AS divergent_dates + FROM pairs AS pair + LEFT JOIN public.device_usage AS left_usage + ON left_usage.user_id = pair.user_id + AND left_usage.device_id = pair.device_id_a + LEFT JOIN public.device_usage AS right_usage + ON right_usage.user_id = pair.user_id + AND right_usage.device_id = pair.device_id_b + AND right_usage.date = left_usage.date + LEFT JOIN agent_fingerprints AS left_agents + ON left_agents.user_id = pair.user_id + AND left_agents.device_id = pair.device_id_a + AND left_agents.date = left_usage.date + LEFT JOIN agent_fingerprints AS right_agents + ON right_agents.user_id = pair.user_id + AND right_agents.device_id = pair.device_id_b + AND right_agents.date = left_usage.date + GROUP BY pair.user_id, pair.device_id_a, pair.device_id_b, + pair.normalized_hostname + ) + INSERT INTO public.usage_device_reconciliation_candidates ( + user_id, device_id_a, device_id_b, normalized_hostname, + overlap_dates, divergent_dates, status, proof + ) + SELECT + user_id, + device_id_a, + device_id_b, + normalized_hostname, + overlap_dates, + divergent_dates, + CASE + WHEN cardinality(overlap_dates) >= 2 AND cardinality(divergent_dates) = 0 + THEN 'proof_merge' + ELSE 'ambiguous' + END, + jsonb_build_object( + 'algorithm', 'canonical-accounting-v1', + 'identical_overlap_count', cardinality(overlap_dates), + 'divergent_overlap_count', cardinality(divergent_dates) + ) + FROM fingerprints + ON CONFLICT (user_id, device_id_a, device_id_b) DO UPDATE + SET normalized_hostname = EXCLUDED.normalized_hostname, + overlap_dates = EXCLUDED.overlap_dates, + divergent_dates = EXCLUDED.divergent_dates, + proof = EXCLUDED.proof, + status = CASE + WHEN public.usage_device_reconciliation_candidates.status + IN ('merged', 'kept_separate') + THEN public.usage_device_reconciliation_candidates.status + ELSE EXCLUDED.status + END; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + RETURN v_inserted; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.resolve_usage_device_candidate( + p_user_id UUID, + p_candidate_id UUID, + p_decision TEXT +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $function$ +DECLARE + v_candidate public.usage_device_reconciliation_candidates%ROWTYPE; + v_canonical UUID; + v_other UUID; + v_batch UUID; + v_decision_id UUID; + v_owned_batch BOOLEAN := false; +BEGIN + IF p_decision NOT IN ('merge', 'keep_separate') THEN + RAISE EXCEPTION 'invalid reconciliation decision' USING ERRCODE = '22023'; + END IF; + + SELECT * + INTO v_candidate + FROM public.usage_device_reconciliation_candidates + WHERE id = p_candidate_id AND user_id = p_user_id + FOR UPDATE; + IF NOT FOUND OR v_candidate.status NOT IN ('proof_merge', 'ambiguous') THEN + RAISE EXCEPTION 'candidate not found or already resolved' USING ERRCODE = 'P0002'; + END IF; + + IF p_decision = 'keep_separate' THEN + UPDATE public.usage_device_reconciliation_candidates + SET status = 'kept_separate', resolved_at = now() + WHERE id = p_candidate_id; + INSERT INTO public.usage_device_reconciliation_decisions ( + candidate_id, user_id, decision + ) VALUES (p_candidate_id, p_user_id, p_decision); + RETURN jsonb_build_object( + 'id', p_candidate_id, 'status', 'kept_separate', 'decision', p_decision + ); + END IF; + + SELECT alias.canonical_device_id + INTO v_canonical + FROM public.usage_installation_aliases AS alias + WHERE alias.user_id = p_user_id + AND alias.canonical_device_id IN (v_candidate.device_id_a, v_candidate.device_id_b) + ORDER BY alias.created_at, alias.canonical_device_id + LIMIT 1; + v_canonical := COALESCE(v_canonical, v_candidate.device_id_a); + v_other := CASE + WHEN v_canonical = v_candidate.device_id_a + THEN v_candidate.device_id_b + ELSE v_candidate.device_id_a + END; + + v_batch := NULLIF( + pg_catalog.current_setting('straude.usage_repair_batch_id', true), '' + )::UUID; + IF v_batch IS NULL THEN + INSERT INTO public.usage_repair_batches(reason, status) + VALUES ('manual device reconciliation ' || p_candidate_id::TEXT, 'running') + RETURNING id INTO v_batch; + v_owned_batch := true; + END IF; + + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + VALUES ( + v_batch, + p_user_id, + 'device reconciliation state', + 'usage_device_reconciliation_candidates', + jsonb_build_object('id', v_candidate.id), + to_jsonb(v_candidate) + ); + + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + SELECT + v_batch, p_user_id, 'manual device merge', 'usage_installation_aliases', + jsonb_build_object('device_id', alias.device_id), to_jsonb(alias) + FROM public.usage_installation_aliases AS alias + WHERE alias.user_id = p_user_id + AND alias.canonical_device_id IN (v_candidate.device_id_a, v_candidate.device_id_b); + + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + SELECT + v_batch, p_user_id, 'manual device merge', 'usage_agent_daily', + jsonb_build_object( + 'user_id', rows.user_id, 'date', rows.date, + 'device_id', rows.device_id, 'agent', rows.agent + ), + to_jsonb(rows) + FROM public.usage_agent_daily AS rows + WHERE rows.user_id = p_user_id + AND rows.device_id IN (v_candidate.device_id_a, v_candidate.device_id_b); + + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + SELECT + v_batch, p_user_id, 'manual device merge', 'device_usage', + jsonb_build_object( + 'user_id', rows.user_id, 'date', rows.date, 'device_id', rows.device_id + ), + to_jsonb(rows) + FROM public.device_usage AS rows + WHERE rows.user_id = p_user_id + AND rows.device_id IN (v_candidate.device_id_a, v_candidate.device_id_b); + + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + SELECT + v_batch, p_user_id, 'recompute daily aggregate', 'daily_usage', + jsonb_build_object('user_id', daily.user_id, 'date', daily.date), + to_jsonb(daily) + FROM public.daily_usage AS daily + WHERE daily.user_id = p_user_id + AND daily.date IN ( + SELECT date FROM public.device_usage + WHERE user_id = p_user_id + AND device_id IN (v_candidate.device_id_a, v_candidate.device_id_b) + ); + + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + SELECT + v_batch, p_user_id, 'generated post title', 'posts', + jsonb_build_object('id', post.id), to_jsonb(post) + FROM public.posts AS post + JOIN public.daily_usage AS daily ON daily.id = post.daily_usage_id + WHERE daily.user_id = p_user_id + AND post.usage_generated_title + AND daily.date IN ( + SELECT date FROM public.device_usage + WHERE user_id = p_user_id + AND device_id IN (v_candidate.device_id_a, v_candidate.device_id_b) + ); + + UPDATE public.usage_installation_aliases + SET canonical_device_id = v_canonical, updated_at = now() + WHERE user_id = p_user_id + AND canonical_device_id IN (v_candidate.device_id_a, v_candidate.device_id_b); + + DELETE FROM public.usage_agent_daily AS duplicate + USING public.usage_agent_daily AS canonical + WHERE duplicate.user_id = p_user_id + AND duplicate.device_id = v_other + AND canonical.user_id = duplicate.user_id + AND canonical.date = duplicate.date + AND canonical.device_id = v_canonical + AND canonical.agent = duplicate.agent + AND canonical.models IS NOT DISTINCT FROM duplicate.models + AND canonical.input_tokens = duplicate.input_tokens + AND canonical.output_tokens = duplicate.output_tokens + AND canonical.reasoning_output_tokens = duplicate.reasoning_output_tokens + AND canonical.cache_creation_tokens = duplicate.cache_creation_tokens + AND canonical.cache_read_tokens = duplicate.cache_read_tokens + AND canonical.total_tokens = duplicate.total_tokens + AND canonical.cost_usd = duplicate.cost_usd + AND canonical.model_breakdown IS NOT DISTINCT FROM duplicate.model_breakdown + AND canonical.collector IS NOT DISTINCT FROM duplicate.collector; + UPDATE public.usage_agent_daily AS rows + SET device_id = v_canonical, updated_at = now() + WHERE rows.user_id = p_user_id + AND rows.device_id = v_other + AND NOT EXISTS ( + SELECT 1 + FROM public.usage_agent_daily AS canonical + WHERE canonical.user_id = rows.user_id + AND canonical.date = rows.date + AND canonical.device_id = v_canonical + AND canonical.agent = rows.agent + ); + + DELETE FROM public.device_usage AS duplicate + USING public.device_usage AS canonical + WHERE duplicate.user_id = p_user_id + AND duplicate.device_id = v_other + AND canonical.user_id = duplicate.user_id + AND canonical.date = duplicate.date + AND canonical.device_id = v_canonical + AND canonical.cost_usd = duplicate.cost_usd + AND canonical.input_tokens = duplicate.input_tokens + AND canonical.output_tokens = duplicate.output_tokens + AND canonical.reasoning_output_tokens = duplicate.reasoning_output_tokens + AND canonical.cache_creation_tokens = duplicate.cache_creation_tokens + AND canonical.cache_read_tokens = duplicate.cache_read_tokens + AND canonical.total_tokens = duplicate.total_tokens + AND canonical.models IS NOT DISTINCT FROM duplicate.models + AND canonical.model_breakdown IS NOT DISTINCT FROM duplicate.model_breakdown; + UPDATE public.device_usage AS rows + SET device_id = v_canonical, updated_at = now() + WHERE rows.user_id = p_user_id + AND rows.device_id = v_other + AND NOT EXISTS ( + SELECT 1 + FROM public.device_usage AS canonical + WHERE canonical.user_id = rows.user_id + AND canonical.date = rows.date + AND canonical.device_id = v_canonical + ); + + WITH totals AS ( + SELECT + user_id, date, + sum(cost_usd) AS cost_usd, + sum(input_tokens) AS input_tokens, + sum(output_tokens) AS output_tokens, + sum(reasoning_output_tokens) AS reasoning_output_tokens, + sum(cache_creation_tokens) AS cache_creation_tokens, + sum(cache_read_tokens) AS cache_read_tokens, + sum(total_tokens) AS total_tokens, + sum(session_count)::INTEGER AS session_count, + bool_or(COALESCE((collector_meta ->> 'is_verified')::BOOLEAN, false)) + AS collector_verified + FROM public.device_usage + WHERE user_id = p_user_id + GROUP BY user_id, date + ), + model_names AS ( + SELECT + rows.user_id, + rows.date, + COALESCE(jsonb_agg(DISTINCT model ORDER BY model), '[]'::JSONB) AS models + FROM public.device_usage AS rows + CROSS JOIN LATERAL jsonb_array_elements_text( + COALESCE(rows.models, '[]'::JSONB) + ) AS model + WHERE rows.user_id = p_user_id + GROUP BY rows.user_id, rows.date + ), + model_costs AS ( + SELECT + costs.user_id, + costs.date, + COALESCE(jsonb_agg( + jsonb_build_object('model', costs.model, 'cost_usd', costs.cost_usd) + ORDER BY costs.model + ), '[]'::JSONB) AS model_breakdown + FROM ( + SELECT + source.user_id, + source.date, + breakdown ->> 'model' AS model, + sum((breakdown ->> 'cost_usd')::NUMERIC) AS cost_usd + FROM public.device_usage AS source + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(source.model_breakdown, '[]'::JSONB) + ) AS breakdown + WHERE source.user_id = p_user_id + GROUP BY source.user_id, source.date, breakdown ->> 'model' + ) AS costs + GROUP BY costs.user_id, costs.date + ) + UPDATE public.daily_usage AS daily + SET cost_usd = totals.cost_usd, + input_tokens = totals.input_tokens, + output_tokens = totals.output_tokens, + reasoning_output_tokens = totals.reasoning_output_tokens, + cache_creation_tokens = totals.cache_creation_tokens, + cache_read_tokens = totals.cache_read_tokens, + total_tokens = totals.total_tokens, + models = COALESCE(model_names.models, '[]'::JSONB), + model_breakdown = COALESCE(model_costs.model_breakdown, '[]'::JSONB), + session_count = totals.session_count, + is_verified = daily.is_verified OR totals.collector_verified, + updated_at = now() + FROM totals + LEFT JOIN model_names USING (user_id, date) + LEFT JOIN model_costs USING (user_id, date) + WHERE daily.user_id = totals.user_id + AND daily.date = totals.date; + + UPDATE public.posts AS post + SET title = pg_catalog.to_char(daily.date, 'Mon FMDD') + || CASE + WHEN daily.cost_usd > 0 + THEN ', $' || pg_catalog.to_char(daily.cost_usd, 'FM999999990.00') + ELSE '' + END, + updated_at = now() + FROM public.daily_usage AS daily + WHERE post.daily_usage_id = daily.id + AND post.usage_generated_title + AND EXISTS ( + SELECT 1 + FROM public.usage_corrections_ledger AS ledger + WHERE ledger.batch_id = v_batch + AND ledger.table_name = 'posts' + AND (ledger.row_key ->> 'id')::UUID = post.id + ); + + UPDATE public.usage_corrections_ledger AS ledger + SET after_row = CASE ledger.table_name + WHEN 'usage_installation_aliases' THEN ( + SELECT to_jsonb(alias) + FROM public.usage_installation_aliases AS alias + WHERE alias.user_id = ledger.user_id + AND alias.device_id = (ledger.row_key ->> 'device_id')::UUID + ) + WHEN 'usage_agent_daily' THEN ( + SELECT to_jsonb(rows) + FROM public.usage_agent_daily AS rows + WHERE rows.user_id = (ledger.row_key ->> 'user_id')::UUID + AND rows.date = (ledger.row_key ->> 'date')::DATE + AND rows.agent = ledger.row_key ->> 'agent' + AND rows.device_id = v_canonical + ) + WHEN 'device_usage' THEN ( + SELECT to_jsonb(rows) + FROM public.device_usage AS rows + WHERE rows.user_id = (ledger.row_key ->> 'user_id')::UUID + AND rows.date = (ledger.row_key ->> 'date')::DATE + AND rows.device_id = v_canonical + ) + WHEN 'daily_usage' THEN ( + SELECT to_jsonb(daily) + FROM public.daily_usage AS daily + WHERE daily.user_id = (ledger.row_key ->> 'user_id')::UUID + AND daily.date = (ledger.row_key ->> 'date')::DATE + ) + WHEN 'posts' THEN ( + SELECT to_jsonb(post) + FROM public.posts AS post + WHERE post.id = (ledger.row_key ->> 'id')::UUID + ) + END + WHERE ledger.batch_id = v_batch; + + INSERT INTO public.usage_device_reconciliation_decisions ( + candidate_id, user_id, decision, canonical_device_id + ) VALUES (p_candidate_id, p_user_id, p_decision, v_canonical) + RETURNING id INTO v_decision_id; + UPDATE public.usage_device_reconciliation_candidates + SET status = 'merged', resolved_at = now() + WHERE id = p_candidate_id; + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, after_row + ) + SELECT + v_batch, p_user_id, 'device reconciliation decision', + 'usage_device_reconciliation_decisions', + jsonb_build_object('id', decision.id), + to_jsonb(decision) + FROM public.usage_device_reconciliation_decisions AS decision + WHERE decision.id = v_decision_id; + UPDATE public.usage_corrections_ledger AS ledger + SET after_row = to_jsonb(candidate) + FROM public.usage_device_reconciliation_candidates AS candidate + WHERE ledger.batch_id = v_batch + AND ledger.table_name = 'usage_device_reconciliation_candidates' + AND candidate.id = (ledger.row_key ->> 'id')::UUID; + IF v_owned_batch THEN + UPDATE public.usage_repair_batches + SET status = 'completed', processed_count = 1, + updated_at = now(), completed_at = now() + WHERE id = v_batch; + END IF; + + PERFORM public.recalculate_user_level(p_user_id); + + RETURN jsonb_build_object( + 'id', p_candidate_id, + 'status', 'merged', + 'decision', p_decision, + 'canonical_device_id', v_canonical, + 'repair_batch_id', v_batch + ); +END; +$function$; + +CREATE OR REPLACE FUNCTION public.start_usage_repair_batch(p_reason TEXT) +RETURNS UUID +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $function$ +DECLARE + v_batch UUID; +BEGIN + INSERT INTO public.usage_repair_batches(reason) + VALUES (p_reason) + RETURNING id INTO v_batch; + RETURN v_batch; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.run_usage_repair_batch( + p_batch_id UUID, + p_limit INTEGER DEFAULT 25 +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $function$ +DECLARE + v_batch public.usage_repair_batches%ROWTYPE; + v_candidate RECORD; + v_processed INTEGER := 0; +BEGIN + IF p_limit < 1 OR p_limit > 500 THEN + RAISE EXCEPTION 'invalid repair batch limit' USING ERRCODE = '22023'; + END IF; + SELECT * INTO v_batch + FROM public.usage_repair_batches + WHERE id = p_batch_id + FOR UPDATE; + IF NOT FOUND OR v_batch.status IN ('completed', 'rolled_back') THEN + RAISE EXCEPTION 'repair batch is not runnable' USING ERRCODE = '55000'; + END IF; + UPDATE public.usage_repair_batches + SET status = 'running', updated_at = now() + WHERE id = p_batch_id; + PERFORM public.discover_usage_device_candidates(NULL); + PERFORM pg_catalog.set_config( + 'straude.usage_repair_batch_id', p_batch_id::TEXT, true + ); + + FOR v_candidate IN + SELECT id, user_id + FROM public.usage_device_reconciliation_candidates + WHERE status = 'proof_merge' + AND (v_batch.cursor_candidate_id IS NULL OR id > v_batch.cursor_candidate_id) + ORDER BY id + LIMIT p_limit + LOOP + PERFORM public.resolve_usage_device_candidate( + v_candidate.user_id, v_candidate.id, 'merge' + ); + v_processed := v_processed + 1; + UPDATE public.usage_repair_batches + SET cursor_candidate_id = v_candidate.id, + processed_count = processed_count + 1, + updated_at = now() + WHERE id = p_batch_id; + END LOOP; + + IF v_processed < p_limit THEN + WITH derived AS ( + SELECT + devices.user_id, + devices.date, + sum(devices.cost_usd) AS cost_usd, + sum(devices.input_tokens) AS input_tokens, + sum(devices.output_tokens) AS output_tokens, + sum(devices.reasoning_output_tokens) AS reasoning_output_tokens, + sum(devices.cache_creation_tokens) AS cache_creation_tokens, + sum(devices.cache_read_tokens) AS cache_read_tokens, + sum(devices.total_tokens) AS total_tokens, + sum(devices.session_count)::INTEGER AS session_count, + COALESCE(( + SELECT jsonb_agg(DISTINCT model ORDER BY model) + FROM public.device_usage AS model_rows + CROSS JOIN LATERAL jsonb_array_elements_text( + COALESCE(model_rows.models, '[]'::JSONB) + ) AS model + WHERE model_rows.user_id = devices.user_id + AND model_rows.date = devices.date + ), '[]'::JSONB) AS models, + COALESCE(( + SELECT jsonb_agg( + jsonb_build_object( + 'model', costs.model, 'cost_usd', costs.cost_usd + ) ORDER BY costs.model + ) + FROM ( + SELECT + breakdown ->> 'model' AS model, + sum((breakdown ->> 'cost_usd')::NUMERIC) AS cost_usd + FROM public.device_usage AS cost_rows + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(cost_rows.model_breakdown, '[]'::JSONB) + ) AS breakdown + WHERE cost_rows.user_id = devices.user_id + AND cost_rows.date = devices.date + GROUP BY breakdown ->> 'model' + ) AS costs + ), '[]'::JSONB) AS model_breakdown + FROM public.device_usage AS devices + GROUP BY devices.user_id, devices.date + ) + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + SELECT + p_batch_id, + daily.user_id, + 'aggregate mismatch repair', + 'daily_usage', + jsonb_build_object('user_id', daily.user_id, 'date', daily.date), + to_jsonb(daily) + FROM public.daily_usage AS daily + JOIN derived + ON derived.user_id = daily.user_id AND derived.date = daily.date + WHERE jsonb_build_array( + daily.cost_usd, daily.input_tokens, daily.output_tokens, + daily.reasoning_output_tokens, daily.cache_creation_tokens, + daily.cache_read_tokens, daily.total_tokens, daily.session_count, + daily.models, daily.model_breakdown + ) IS DISTINCT FROM jsonb_build_array( + derived.cost_usd, derived.input_tokens, derived.output_tokens, + derived.reasoning_output_tokens, derived.cache_creation_tokens, + derived.cache_read_tokens, derived.total_tokens, derived.session_count, + derived.models, derived.model_breakdown + ); + + INSERT INTO public.usage_corrections_ledger ( + batch_id, user_id, reason, table_name, row_key, before_row + ) + SELECT + p_batch_id, + daily.user_id, + 'aggregate mismatch generated title', + 'posts', + jsonb_build_object('id', post.id), + to_jsonb(post) + FROM public.posts AS post + JOIN public.daily_usage AS daily ON daily.id = post.daily_usage_id + WHERE post.usage_generated_title + AND EXISTS ( + SELECT 1 + FROM public.usage_corrections_ledger AS ledger + WHERE ledger.batch_id = p_batch_id + AND ledger.table_name = 'daily_usage' + AND ledger.reason = 'aggregate mismatch repair' + AND (ledger.row_key ->> 'user_id')::UUID = daily.user_id + AND (ledger.row_key ->> 'date')::DATE = daily.date + ); + + WITH derived AS ( + SELECT + devices.user_id, + devices.date, + sum(devices.cost_usd) AS cost_usd, + sum(devices.input_tokens) AS input_tokens, + sum(devices.output_tokens) AS output_tokens, + sum(devices.reasoning_output_tokens) AS reasoning_output_tokens, + sum(devices.cache_creation_tokens) AS cache_creation_tokens, + sum(devices.cache_read_tokens) AS cache_read_tokens, + sum(devices.total_tokens) AS total_tokens, + sum(devices.session_count)::INTEGER AS session_count, + COALESCE(( + SELECT jsonb_agg(DISTINCT model ORDER BY model) + FROM public.device_usage AS model_rows + CROSS JOIN LATERAL jsonb_array_elements_text( + COALESCE(model_rows.models, '[]'::JSONB) + ) AS model + WHERE model_rows.user_id = devices.user_id + AND model_rows.date = devices.date + ), '[]'::JSONB) AS models, + COALESCE(( + SELECT jsonb_agg( + jsonb_build_object( + 'model', costs.model, 'cost_usd', costs.cost_usd + ) ORDER BY costs.model + ) + FROM ( + SELECT + breakdown ->> 'model' AS model, + sum((breakdown ->> 'cost_usd')::NUMERIC) AS cost_usd + FROM public.device_usage AS cost_rows + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE(cost_rows.model_breakdown, '[]'::JSONB) + ) AS breakdown + WHERE cost_rows.user_id = devices.user_id + AND cost_rows.date = devices.date + GROUP BY breakdown ->> 'model' + ) AS costs + ), '[]'::JSONB) AS model_breakdown + FROM public.device_usage AS devices + GROUP BY devices.user_id, devices.date + ) + UPDATE public.daily_usage AS daily + SET cost_usd = derived.cost_usd, + input_tokens = derived.input_tokens, + output_tokens = derived.output_tokens, + reasoning_output_tokens = derived.reasoning_output_tokens, + cache_creation_tokens = derived.cache_creation_tokens, + cache_read_tokens = derived.cache_read_tokens, + total_tokens = derived.total_tokens, + models = derived.models, + model_breakdown = derived.model_breakdown, + session_count = derived.session_count, + updated_at = now() + FROM derived + WHERE daily.user_id = derived.user_id + AND daily.date = derived.date + AND EXISTS ( + SELECT 1 FROM public.usage_corrections_ledger AS ledger + WHERE ledger.batch_id = p_batch_id + AND ledger.table_name = 'daily_usage' + AND ledger.reason = 'aggregate mismatch repair' + AND (ledger.row_key ->> 'user_id')::UUID = daily.user_id + AND (ledger.row_key ->> 'date')::DATE = daily.date + ); + + UPDATE public.posts AS post + SET title = pg_catalog.to_char(daily.date, 'Mon FMDD') + || CASE + WHEN daily.cost_usd > 0 + THEN ', $' || pg_catalog.to_char(daily.cost_usd, 'FM999999990.00') + ELSE '' + END, + updated_at = now() + FROM public.daily_usage AS daily + WHERE post.daily_usage_id = daily.id + AND post.usage_generated_title + AND EXISTS ( + SELECT 1 FROM public.usage_corrections_ledger AS ledger + WHERE ledger.batch_id = p_batch_id + AND ledger.table_name = 'posts' + AND (ledger.row_key ->> 'id')::UUID = post.id + ); + + UPDATE public.usage_corrections_ledger AS ledger + SET after_row = CASE ledger.table_name + WHEN 'daily_usage' THEN ( + SELECT to_jsonb(daily) + FROM public.daily_usage AS daily + WHERE daily.user_id = (ledger.row_key ->> 'user_id')::UUID + AND daily.date = (ledger.row_key ->> 'date')::DATE + ) + WHEN 'posts' THEN ( + SELECT to_jsonb(post) + FROM public.posts AS post + WHERE post.id = (ledger.row_key ->> 'id')::UUID + ) + END + WHERE ledger.batch_id = p_batch_id + AND ledger.reason IN ( + 'aggregate mismatch repair', + 'aggregate mismatch generated title' + ); + + FOR v_candidate IN + SELECT DISTINCT user_id + FROM public.usage_corrections_ledger + WHERE batch_id = p_batch_id + AND reason = 'aggregate mismatch repair' + LOOP + PERFORM public.recalculate_user_level(v_candidate.user_id); + END LOOP; + END IF; + + PERFORM pg_catalog.set_config('straude.usage_repair_batch_id', '', true); + + UPDATE public.usage_repair_batches + SET status = CASE + WHEN v_processed < p_limit THEN 'completed' + ELSE 'running' + END, + completed_at = CASE WHEN v_processed < p_limit THEN now() ELSE NULL END, + updated_at = now() + WHERE id = p_batch_id; + + RETURN jsonb_build_object( + 'batch_id', p_batch_id, + 'processed', v_processed, + 'complete', v_processed < p_limit + ); +END; +$function$; + +CREATE OR REPLACE FUNCTION public.rollback_usage_repair_batch(p_batch_id UUID) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = '' +AS $function$ +DECLARE + v_entry RECORD; + v_restored INTEGER := 0; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM public.usage_repair_batches + WHERE id = p_batch_id AND status = 'completed' + ) THEN + RAISE EXCEPTION 'repair batch is not rollbackable' USING ERRCODE = '55000'; + END IF; + + DELETE FROM public.usage_agent_daily AS rows + WHERE EXISTS ( + SELECT 1 + FROM public.usage_corrections_ledger AS ledger + WHERE ledger.batch_id = p_batch_id + AND ledger.table_name = 'usage_agent_daily' + AND ledger.after_row IS NOT NULL + AND rows.user_id = (ledger.after_row ->> 'user_id')::UUID + AND rows.date = (ledger.after_row ->> 'date')::DATE + AND rows.device_id = (ledger.after_row ->> 'device_id')::UUID + AND rows.agent = ledger.after_row ->> 'agent' + ); + DELETE FROM public.device_usage AS rows + WHERE EXISTS ( + SELECT 1 + FROM public.usage_corrections_ledger AS ledger + WHERE ledger.batch_id = p_batch_id + AND ledger.table_name = 'device_usage' + AND ledger.after_row IS NOT NULL + AND rows.user_id = (ledger.after_row ->> 'user_id')::UUID + AND rows.date = (ledger.after_row ->> 'date')::DATE + AND rows.device_id = (ledger.after_row ->> 'device_id')::UUID + ); + FOR v_entry IN + SELECT * FROM public.usage_corrections_ledger + WHERE batch_id = p_batch_id + ORDER BY id DESC + LOOP + IF v_entry.table_name = 'usage_device_reconciliation_decisions' THEN + DELETE FROM public.usage_device_reconciliation_decisions + WHERE id = (v_entry.row_key ->> 'id')::UUID; + v_restored := v_restored + 1; + ELSIF v_entry.table_name = 'usage_device_reconciliation_candidates' + AND v_entry.before_row IS NOT NULL + THEN + INSERT INTO public.usage_device_reconciliation_candidates + SELECT (pg_catalog.jsonb_populate_record( + NULL::public.usage_device_reconciliation_candidates, v_entry.before_row + )).* + ON CONFLICT (id) DO UPDATE + SET user_id = EXCLUDED.user_id, + device_id_a = EXCLUDED.device_id_a, + device_id_b = EXCLUDED.device_id_b, + normalized_hostname = EXCLUDED.normalized_hostname, + overlap_dates = EXCLUDED.overlap_dates, + divergent_dates = EXCLUDED.divergent_dates, + status = EXCLUDED.status, + proof = EXCLUDED.proof, + created_at = EXCLUDED.created_at, + resolved_at = EXCLUDED.resolved_at; + v_restored := v_restored + 1; + ELSIF v_entry.table_name = 'posts' + AND v_entry.before_row IS NOT NULL + THEN + INSERT INTO public.posts + SELECT (pg_catalog.jsonb_populate_record( + NULL::public.posts, v_entry.before_row + )).* + ON CONFLICT (id) DO UPDATE + SET user_id = EXCLUDED.user_id, + daily_usage_id = EXCLUDED.daily_usage_id, + title = EXCLUDED.title, + description = EXCLUDED.description, + images = EXCLUDED.images, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at, + usage_generated_title = EXCLUDED.usage_generated_title; + v_restored := v_restored + 1; + ELSIF v_entry.table_name = 'usage_installation_aliases' + AND v_entry.before_row IS NOT NULL + THEN + INSERT INTO public.usage_installation_aliases + SELECT (pg_catalog.jsonb_populate_record( + NULL::public.usage_installation_aliases, v_entry.before_row + )).* + ON CONFLICT (user_id, device_id) DO UPDATE + SET canonical_device_id = EXCLUDED.canonical_device_id, + name = EXCLUDED.name, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at; + v_restored := v_restored + 1; + ELSIF v_entry.table_name = 'usage_agent_daily' + AND v_entry.before_row IS NOT NULL + THEN + INSERT INTO public.usage_agent_daily + SELECT (pg_catalog.jsonb_populate_record( + NULL::public.usage_agent_daily, v_entry.before_row + )).* + ON CONFLICT (user_id, date, device_id, agent) DO UPDATE + SET models = EXCLUDED.models, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + reasoning_output_tokens = EXCLUDED.reasoning_output_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + total_tokens = EXCLUDED.total_tokens, + cost_usd = EXCLUDED.cost_usd, + model_breakdown = EXCLUDED.model_breakdown, + content_hash = EXCLUDED.content_hash, + collector = EXCLUDED.collector, + migration_id = EXCLUDED.migration_id, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at; + v_restored := v_restored + 1; + ELSIF v_entry.table_name = 'device_usage' + AND v_entry.before_row IS NOT NULL + THEN + INSERT INTO public.device_usage + SELECT (pg_catalog.jsonb_populate_record( + NULL::public.device_usage, v_entry.before_row + )).* + ON CONFLICT (user_id, date, device_id) DO UPDATE + SET device_name = EXCLUDED.device_name, + cost_usd = EXCLUDED.cost_usd, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + reasoning_output_tokens = EXCLUDED.reasoning_output_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + total_tokens = EXCLUDED.total_tokens, + models = EXCLUDED.models, + model_breakdown = EXCLUDED.model_breakdown, + session_count = EXCLUDED.session_count, + raw_hash = EXCLUDED.raw_hash, + collector_meta = EXCLUDED.collector_meta, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at; + v_restored := v_restored + 1; + ELSIF v_entry.table_name = 'daily_usage' + AND v_entry.before_row IS NOT NULL + THEN + INSERT INTO public.daily_usage + SELECT (pg_catalog.jsonb_populate_record( + NULL::public.daily_usage, v_entry.before_row + )).* + ON CONFLICT (user_id, date) DO UPDATE + SET cost_usd = EXCLUDED.cost_usd, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + reasoning_output_tokens = EXCLUDED.reasoning_output_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + total_tokens = EXCLUDED.total_tokens, + models = EXCLUDED.models, + model_breakdown = EXCLUDED.model_breakdown, + session_count = EXCLUDED.session_count, + is_verified = EXCLUDED.is_verified, + raw_hash = EXCLUDED.raw_hash, + collector_meta = EXCLUDED.collector_meta, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at; + v_restored := v_restored + 1; + END IF; + END LOOP; + + FOR v_entry IN + SELECT DISTINCT user_id + FROM public.usage_corrections_ledger + WHERE batch_id = p_batch_id + LOOP + PERFORM public.recalculate_user_level(v_entry.user_id); + END LOOP; + + UPDATE public.usage_repair_batches + SET status = 'rolled_back', updated_at = now() + WHERE id = p_batch_id; + RETURN jsonb_build_object('batch_id', p_batch_id, 'restored_rows', v_restored); +END; +$function$; + +REVOKE ALL ON FUNCTION public.list_usage_device_candidates(UUID) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.discover_usage_device_candidates(UUID) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.resolve_usage_device_candidate(UUID, UUID, TEXT) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.start_usage_repair_batch(TEXT) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.run_usage_repair_batch(UUID, INTEGER) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.rollback_usage_repair_batch(UUID) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.list_usage_device_candidates(UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.discover_usage_device_candidates(UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.resolve_usage_device_candidate(UUID, UUID, TEXT) TO service_role; +GRANT EXECUTE ON FUNCTION public.start_usage_repair_batch(TEXT) TO service_role; +GRANT EXECUTE ON FUNCTION public.run_usage_repair_batch(UUID, INTEGER) TO service_role; +GRANT EXECUTE ON FUNCTION public.rollback_usage_repair_batch(UUID) TO service_role; From 4dbf8ddc7b3c4845fb82969da3b43aceec617abc Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Thu, 23 Jul 2026 12:40:32 -0700 Subject: [PATCH 2/5] Follow compatible ccusage releases --- .github/workflows/ccusage-compatibility.yml | 42 +++ .../integration/usage-submit.test.ts | 41 ++- bun.lock | 16 +- docs/API.md | 2 +- docs/CHANGELOG.md | 10 +- docs/CLI.md | 30 ++- docs/CLI_OPERATIONS.md | 7 + docs/DECISIONS.md | 14 +- docs/SECURITY.md | 2 +- docs/audit-2026-07-23.md | 6 +- ...usage-upstream-compatibility-2026-07-23.md | 111 ++++++++ packages/cli/README.md | 2 +- .../ccusage-pricing.integration.test.ts | 14 +- packages/cli/__tests__/ccusage.test.ts | 239 ++++++++++++++++-- packages/cli/__tests__/commands/push.test.ts | 6 +- .../cli/__tests__/flows/cli-sync-flow.test.ts | 2 +- packages/cli/__tests__/sync-state.test.ts | 2 +- packages/cli/package.json | 5 +- packages/cli/scripts/benchmark-collector.mjs | 17 +- .../scripts/check-ccusage-compatibility.ts | 174 +++++++++++++ packages/cli/scripts/packaged-cli-e2e.mjs | 31 ++- packages/cli/src/lib/ccusage.ts | 69 ++++- papercuts.md | 4 + .../20260723133731_usage_submission_v2.sql | 18 +- 24 files changed, 765 insertions(+), 99 deletions(-) create mode 100644 .github/workflows/ccusage-compatibility.yml create mode 100644 docs/ccusage-upstream-compatibility-2026-07-23.md create mode 100644 packages/cli/scripts/check-ccusage-compatibility.ts diff --git a/.github/workflows/ccusage-compatibility.yml b/.github/workflows/ccusage-compatibility.yml new file mode 100644 index 00000000..99d3b0fb --- /dev/null +++ b/.github/workflows/ccusage-compatibility.yml @@ -0,0 +1,42 @@ +name: ccusage compatibility + +on: + schedule: + - cron: "17 15 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + latest: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.3 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install frozen Straude dependencies + run: bun install --frozen-lockfile + + - name: Install ccusage latest in isolation + id: latest + shell: bash + run: | + set -euo pipefail + canary_dir="$(mktemp -d)" + npm install --prefix "$canary_dir" --no-audit --no-fund ccusage@latest + echo "package_dir=$canary_dir/node_modules/ccusage" >> "$GITHUB_OUTPUT" + + - name: Run latest through the production parser + run: bun packages/cli/scripts/check-ccusage-compatibility.ts --package-dir "${{ steps.latest.outputs.package_dir }}" + env: + STRAUDE_CCUSAGE_CANARY_MAX_MS: "60000" diff --git a/apps/web/__tests__/integration/usage-submit.test.ts b/apps/web/__tests__/integration/usage-submit.test.ts index 9dc576ba..fb673fa3 100644 --- a/apps/web/__tests__/integration/usage-submit.test.ts +++ b/apps/web/__tests__/integration/usage-submit.test.ts @@ -93,14 +93,19 @@ function v2Agent(overrides: Record = {}) { }; } -function v2Body(requestId: string, contentHash: string, overrides: Record = {}) { +function v2Body( + requestId: string, + contentHash: string, + overrides: Record = {}, + collectorVersion = "20.0.18", +) { return { protocol_version: 2, request_id: requestId, source: "cli", timezone: "UTC", installation: { id: DEVICE_ID, name: "integration-device" }, - collector: { name: "ccusage", version: "20.0.16", pricing_mode: "online" }, + collector: { name: "ccusage", version: collectorVersion, pricing_mode: "online" }, entries: [{ date: today, content_hash: contentHash, @@ -200,7 +205,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { [ userId, JSON.stringify({ id: DEVICE_ID, name: "concurrent-device" }), - JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + JSON.stringify({ name: "ccusage", version: "20.0.18", pricing_mode: "online" }), JSON.stringify(entry), "9".repeat(64), ], @@ -309,7 +314,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { 'cache_read_tokens', 30, 'total_tokens', 160, 'cost_usd', 0.25 )), repeat('a', 64), - '{"name":"ccusage","version":"20.0.16","pricing_mode":"online"}'::jsonb + '{"name":"ccusage","version":"20.0.18","pricing_mode":"online"}'::jsonb FROM unnest($2::date[]) AS day CROSS JOIN unnest($3::uuid[]) AS device`, [userId, dates, [deviceA, deviceB]], @@ -326,7 +331,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { '["gpt-5.6"]'::jsonb, '[{"model":"gpt-5.6","cost_usd":0.25}]'::jsonb, 1, repeat('a', 64), - '{"name":"ccusage","version":"20.0.16","pricing_mode":"online"}'::jsonb + '{"name":"ccusage","version":"20.0.18","pricing_mode":"online"}'::jsonb FROM unnest($2::date[]) AS day CROSS JOIN unnest($3::uuid[]) AS device`, [userId, dates, [deviceA, deviceB]], @@ -488,7 +493,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { cost_usd: 0.5, }], }).model_breakdown), - JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + JSON.stringify({ name: "ccusage", version: "20.0.18", pricing_mode: "online" }), ], ); await db.query( @@ -509,7 +514,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { today, deviceA, deviceB, - JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + JSON.stringify({ name: "ccusage", version: "20.0.18", pricing_mode: "online" }), ], ); await db.query( @@ -638,7 +643,7 @@ describe("POST /api/usage/submit (real Supabase)", () => { [ userId, JSON.stringify({ id: DEVICE_ID, name: "rollback-device" }), - JSON.stringify({ name: "ccusage", version: "20.0.16", pricing_mode: "online" }), + JSON.stringify({ name: "ccusage", version: "20.0.18", pricing_mode: "online" }), JSON.stringify(invalidEntry), "f".repeat(64), ], @@ -693,10 +698,26 @@ describe("POST /api/usage/submit (real Supabase)", () => { expect(Number(row.rows[0].total_tokens)).toBe(260); expect(Number(row.rows[0].cost_usd)).toBeCloseTo(0.5, 6); - await callSubmit(v2Body("v2-trusted-low", "3".repeat(64), { + await callSubmit(v2Body("v2-below-floor-low", "3".repeat(64), { authoritative_correction: true, migration_id: "ccusage-by-agent-v2", - }), token); + }, "20.0.17"), token); + await callSubmit(v2Body("v2-invalid-version-low", "4".repeat(64), { + authoritative_correction: true, + migration_id: "ccusage-by-agent-v2", + }, "not-semver"), token); + row = await db.query( + "SELECT total_tokens, cost_usd, migration_id FROM public.usage_agent_daily WHERE user_id = $1", + [userId], + ); + expect(Number(row.rows[0].total_tokens)).toBe(260); + expect(Number(row.rows[0].cost_usd)).toBeCloseTo(0.5, 6); + expect(row.rows[0].migration_id).toBeNull(); + + await callSubmit(v2Body("v2-trusted-later-major-low", "5".repeat(64), { + authoritative_correction: true, + migration_id: "ccusage-by-agent-v2", + }, "21.0.0+canary.1"), token); row = await db.query( "SELECT total_tokens, cost_usd, migration_id FROM public.usage_agent_daily WHERE user_id = $1", [userId], diff --git a/bun.lock b/bun.lock index 627f621a..35d91fc0 100644 --- a/bun.lock +++ b/bun.lock @@ -73,7 +73,7 @@ }, "dependencies": { "@pppp606/ink-chart": "^0.2.4", - "ccusage": "20.0.16", + "ccusage": ">=20.0.18", "chalk": "^5.6.2", "ink": "^6.8.0", "posthog-node": "^5.29.1", @@ -176,17 +176,17 @@ "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], - "@ccusage/ccusage-darwin-arm64": ["@ccusage/ccusage-darwin-arm64@20.0.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/F1L8jPKN0ngiRu9laHK/qTEAz7oY5imSRljgqcduNaCrg8EK9uDOSSaLDEJLymGnKBNB5DDblux3A57VzRWXQ=="], + "@ccusage/ccusage-darwin-arm64": ["@ccusage/ccusage-darwin-arm64@20.0.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3Uv3ED3RyepxNyYYU/BGqfzsL+yWkzxb9gETDLn6DR1umMmoLBLz2bzOfHe1tFXpDEBcx75cj70P4Mq4WEJ/Kg=="], - "@ccusage/ccusage-darwin-x64": ["@ccusage/ccusage-darwin-x64@20.0.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-qqTt23mhU4EhIM5ATaT8WWml0Mw/o3gxuqkcufABz8/fUNXIKrhgK8oVt5SxL5rZTDDb3rxi+DdH5DvSMLgnIA=="], + "@ccusage/ccusage-darwin-x64": ["@ccusage/ccusage-darwin-x64@20.0.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y2NWvCsRkhbakBj0Pn9NReQnaJfsGEQRV+q4moUuVAHe2Hh40IwBjCMv5NVECHNU1TJT73WIxHpfC3mcIDL3Pw=="], - "@ccusage/ccusage-linux-arm64": ["@ccusage/ccusage-linux-arm64@20.0.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-txoej2ik+ZI6r9zwmIG8OoEj6i60JbYyJClbgfIFSQDFkkTriARFwmSY53xViibcua7oQvyrsj/Ypkusmb4Xdg=="], + "@ccusage/ccusage-linux-arm64": ["@ccusage/ccusage-linux-arm64@20.0.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-55gGCy13tLmPV9sxRBHP5Jf6o/1hrIF4KUk/0BoawSrOPbSOQZG+8qGXTILNxrOZ4YLmPB2srTMd2vq+6K+LcA=="], - "@ccusage/ccusage-linux-x64": ["@ccusage/ccusage-linux-x64@20.0.16", "", { "os": "linux", "cpu": "x64" }, "sha512-WqUgfVagmh8CcaBy1r4s4RgrVSqs+WB9wKTnvW5PDL8PCsa9dYrMSpW0+LP3AJZq6QSVAu5Bpr/CxxlDsvS9Xw=="], + "@ccusage/ccusage-linux-x64": ["@ccusage/ccusage-linux-x64@20.0.18", "", { "os": "linux", "cpu": "x64" }, "sha512-3bF4ZY/JFRc/kZTagriV4Mr1kmItzMM4kZHd+S3eD8Yg7KI2X4j+0n4TMO8yqgJ6aDwdj0QvhSTW7qcp0rXdCw=="], - "@ccusage/ccusage-win32-arm64": ["@ccusage/ccusage-win32-arm64@20.0.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-W4w3BoQE3MZfHFeLms7zMnEq4i+IhGqlCxaMMaV7anIYrvHPZgIXGg2ajGe8xCT/S7Bm8+SnDqInX46ps8ypYQ=="], + "@ccusage/ccusage-win32-arm64": ["@ccusage/ccusage-win32-arm64@20.0.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-Iaxx9Td97M2cVrMpGRrSPTdtpFzw/Ku+lpRYlYlbnFM3AdQ+wlD3xzaYsQ+OdlmPS2DSgHzt3Cob4fgIhvHpbA=="], - "@ccusage/ccusage-win32-x64": ["@ccusage/ccusage-win32-x64@20.0.16", "", { "os": "win32", "cpu": "x64" }, "sha512-vr0gi8zcxjSgdDSO9edXAJrrNV6tIdyl4HZSeboJOirlc41TqnS0yn+OzWvdEtEEKiiqPGL0tQ+gacVy64yvzw=="], + "@ccusage/ccusage-win32-x64": ["@ccusage/ccusage-win32-x64@20.0.18", "", { "os": "win32", "cpu": "x64" }, "sha512-2DaCLgoVsVfxijdrOupR3+x4tbPJTmdIcIVyxkzMRJAa5U6dIWzc/WBtaDruir/CfTrwyZvj1CPdfKVTKDoCDA=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.0.1", "", {}, "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ=="], @@ -856,7 +856,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "ccusage": ["ccusage@20.0.16", "", { "optionalDependencies": { "@ccusage/ccusage-darwin-arm64": "20.0.16", "@ccusage/ccusage-darwin-x64": "20.0.16", "@ccusage/ccusage-linux-arm64": "20.0.16", "@ccusage/ccusage-linux-x64": "20.0.16", "@ccusage/ccusage-win32-arm64": "20.0.16", "@ccusage/ccusage-win32-x64": "20.0.16" }, "bin": { "ccusage": "./src/cli.js" } }, "sha512-vxEgCt1rjNSUURoiK4wyuvOIL3Rtya81t5DlN7Qj63xLPxZyU0wflTkU+MrPypcagZ2vhnby5z+r8pd5tOnjWA=="], + "ccusage": ["ccusage@20.0.18", "", { "optionalDependencies": { "@ccusage/ccusage-darwin-arm64": "20.0.18", "@ccusage/ccusage-darwin-x64": "20.0.18", "@ccusage/ccusage-linux-arm64": "20.0.18", "@ccusage/ccusage-linux-x64": "20.0.18", "@ccusage/ccusage-win32-arm64": "20.0.18", "@ccusage/ccusage-win32-x64": "20.0.18" }, "bin": { "ccusage": "./src/cli.js" } }, "sha512-/81kwSZg487xoHQadEdcP4hqw9GICkaPtVjSM1HHJ4nBplUByX9tSZF7YIaQQWMcWZ5Gzt0cD2yDEFrgcAkrZg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], diff --git a/docs/API.md b/docs/API.md index 4b820870..50550439 100644 --- a/docs/API.md +++ b/docs/API.md @@ -324,7 +324,7 @@ imports are adapted server-side during the migration. }, "collector": { "name": "ccusage", - "version": "20.0.16", + "version": "20.0.18", "pricing_mode": "online" }, "entries": [ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0a1fbd66..3bca17ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,10 +9,12 @@ - **Durable installation reconciliation.** The CLI keeps its installation UUID outside the login config and submits the prior device ID once for alias migration. The server quarantines ambiguous identities, proves automatic merges from hostname plus identical overlapping accounting fingerprints, exposes explicit `straude devices` resolution commands, and records historical repair changes in an append-only ledger with an admin-only rollback function. - **Bounded failure handling.** API calls retry network failures, 408, 425, 429, and 5xx responses within an absolute deadline, using jitter and `Retry-After` when supplied. Login initialization has a 10-second deadline, noninteractive runs fail with actionable exit codes instead of opening a browser, telemetry shutdown is bounded, and automatic-run logs rotate. - **Live pricing is a commit gate.** Collection keeps ccusage diagnostic output enabled, rejects embedded or incomplete pricing, and makes at most three attempts inside a shared 60-second recovery budget. Pricing failure leaves both the outbox and contiguous watermark unchanged, so stale estimates can never become committed usage. -- **Reproducible package and release pipeline.** The CLI now requires Node 20+, pins `ccusage` to the fixture-tested `20.0.16`, targets Node 20, and emits a source map that is excluded from npm and retained as a CI artifact. Bun is fixed at 1.3.3 and CI uses the frozen lockfile. `npm pack` builds from a clean `tsup` output, while the package allowlist ships only `dist/index.js`. -- **Cross-platform packaged verification.** CI and tag releases build one tarball, install that exact artifact on Linux, macOS, and Windows under Node 20 and 22, then run the real bundled collector against the GPT-5.6 fixture and a delayed scorecard server. The check also verifies the CLI version, Node engine, exact ccusage dependency, bin entry, and absence of source maps or TypeScript build metadata. +- **Forward-compatible ccusage updates with fail-closed pricing.** The CLI declares `ccusage: >=20.0.18`, accepts any stable version above that floor, records the installed version, and keeps agent/model IDs generic. Nonzero-token Claude or Codex model usage at zero cost now raises `PricingUnavailableError`, while other sources may legitimately be free. Fresh installs can take later stable releases, including a compatible new major; existing installations keep their installed collector until reinstall or upgrade. +- **Reproducible package and release pipeline.** The CLI now requires Node 20+, locks the normal CI graph to `ccusage@20.0.18`, targets Node 20, and emits a source map that is excluded from npm and retained as a CI artifact. Bun is fixed at 1.3.3 and CI uses the frozen lockfile. `npm pack` builds from a clean `tsup` output, while the package allowlist ships only `dist/index.js`. +- **Cross-platform packaged verification.** CI and tag releases build one tarball, install that exact artifact on Linux, macOS, and Windows under Node 20 and 22, then run the real installed compatible collector against the GPT-5.6 fixture and a delayed scorecard server. The check also verifies the CLI version, Node engine, dependency range, actual collector version, bin entry, and absence of source maps or TypeScript build metadata. +- **Weekly ccusage latest canary.** A separate scheduled/manual workflow installs `ccusage@latest` in isolation and runs the real fixture through Straude's production parser. New majors are accepted when compatible; schema or pricing drift and runs beyond 60 seconds fail without changing the frozen-lock release gate or downloading latest in the product path. - **Tag-driven publishing.** A `straude@` tag runs CLI typecheck/tests, packages once, waits for the full OS/Node matrix, publishes the tested tarball to npm with provenance, and creates the matching GitHub release with the tarball and its `SHA256SUMS` digest. Publishing remains manual until a matching tag is pushed; this workflow does not create tags. -- **Repeatable performance benchmarks.** `bun run --cwd packages/cli benchmark` measures packed CLI startup, while `benchmark:collector` runs the pinned collector over deterministic 1, 3, 7, and 30-day fixtures and reports first-run and warm median/p95 latency. CI archives collector results without imposing a machine-dependent threshold; accuracy fixtures remain a mandatory gate. +- **Repeatable performance benchmarks.** `bun run --cwd packages/cli benchmark` measures packed CLI startup, while `benchmark:collector` runs the lockfile-resolved collector over deterministic 1, 3, 7, and 30-day fixtures and reports first-run and warm median/p95 latency. CI archives collector results without imposing a machine-dependent threshold; accuracy fixtures remain a mandatory gate. - **CLI documentation corrected.** The documented windows now match behavior: 3 days on a fresh first sync, up to 7 contiguous uncommitted days per normal run, and up to 30 days only when explicitly requested. The reference also documents exit codes, platform support, automatic sync, telemetry, packaged testing, and the real `/api/cli/dashboard` endpoint. ### Fixed @@ -25,7 +27,7 @@ ### Changed -- **All ccusage sources and the OpenAI GPT-5.6 family are now tracked.** The CLI dependency floor is `ccusage@20.0.16`, the first release with `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` plus request-level long-context pricing. Collection now uses current online LiteLLM pricing by default, avoiding stale embedded-price estimates. Unified rows are no longer filtered to Claude/Codex: Straude accepts every source ID emitted by ccusage, carries each row's source IDs through submission metadata, and retains source-aware handling for trusted Codex corrections. A real bundled-binary fixture locks the four GPT-5.6 variants to 440,000 total tokens and $1.917 in API-equivalent spend at the current LiteLLM rates. +- **All ccusage sources and the OpenAI GPT-5.6 family are now tracked.** The CLI dependency floor is `ccusage@20.0.18`, with `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` plus request-level long-context pricing. Collection now uses current online LiteLLM pricing by default, avoiding stale embedded-price estimates. Unified rows are no longer filtered to Claude/Codex: Straude accepts every source ID emitted by ccusage, carries each row's source IDs through submission metadata, and retains source-aware handling for trusted Codex corrections. A real bundled-binary fixture locks the four GPT-5.6 variants to 440,000 total tokens and $1.917 in API-equivalent spend at the current LiteLLM rates. - **Activation funnel events are now captured exclusively server-side.** `trackActivationEvent` no longer double-captures via browser posthog-js for consented users; the consent-exempt, privacy-limited server path (which owns anonymous→user identity stitching) is the single source of truth for funnel math. diff --git a/docs/CLI.md b/docs/CLI.md index dc1e6eae..d6c45a5c 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -143,20 +143,22 @@ on Windows. Claude Code hooks are independent of the OS scheduler. ## Data Sources -Straude invokes its installed, exact `ccusage@20.0.16` native binary once per sync: +Straude invokes its installed `ccusage` native binary once per sync. Supported +collector versions are stable releases `>=20.0.18`: ```bash ccusage daily --json --since YYYYMMDD --until YYYYMMDD --no-offline --by-agent --timezone IANA_TIMEZONE ``` -The unified report automatically detects and combines every source ccusage supports. As of ccusage 20.0.16, those built-in sources are Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, and Gemini CLI. Configured custom pi-format stores are accepted too. +The unified report automatically detects and combines every source ccusage supports. Source and model IDs are preserved as generic strings, so later stable releases can add them without a Straude allowlist change. ccusage owns local path discovery, source-format parsing, deduplication, token accounting, model aliases, and per-model cost calculation. Straude validates the unified daily JSON, preserves each row's `metadata.agents`, and submits the aggregate token buckets, models, and model cost breakdown. The raw local logs and paths are never uploaded. Online LiteLLM pricing is required, so model prices can change independently of -the pinned collector code. If ccusage reports missing prices or falls back to -its embedded snapshot, Straude retries within a bounded 60-second recovery -budget and submits nothing unless live pricing becomes complete. +the installed collector code. If ccusage reports missing prices, falls back to +its embedded snapshot, or emits nonzero-token Claude/Codex usage at zero cost, +Straude retries within a bounded 60-second recovery budget and submits nothing +unless live pricing becomes complete. Other sources may legitimately be free. A SHA-256 hash of the ccusage version, detected sources, date range, and raw unified JSON is sent for deduplication. @@ -268,11 +270,23 @@ bun run --cwd packages/cli test:packaged ``` `test:packaged` performs a clean build through `npm pack`, installs the tarball -in a temporary project, checks its manifest and version, runs the real pinned -ccusage binary against the GPT-5.6 fixture, submits to a local HTTP server, and +in a temporary project, checks the declared dependency range and actual +compatible collector version, runs that binary against the GPT-5.6 fixture, +submits to a local HTTP server, and waits for the scorecard render. CI repeats the installed-tarball check on Linux, macOS, and Windows with Node 20 and 22. +The normal gate remains frozen to `bun.lock`. A separate weekly/manual +`ccusage compatibility` workflow installs `ccusage@latest` in isolation and +runs the real fixture through the production parser. A new major is accepted +when its output passes; schema drift, missing paid-model pricing, or a run +beyond the 60-second budget fails. +The product runtime never invokes `npx ccusage@latest`. + +The published `>=20.0.18` range affects dependency resolution on fresh installs. +An existing installation keeps its installed collector until Straude is +reinstalled or upgraded. + Tags of the form `straude@` trigger the release workflow. It publishes the exact matrix-tested tarball to npm with provenance and creates a matching GitHub release containing the tarball and its `SHA256SUMS` digest. @@ -294,7 +308,7 @@ caches, then prints JSON with median and p95 `--version` process latency. Override its default 15 samples with `STRAUDE_BENCH_ITERATIONS`. The collector harness creates deterministic 1, 3, 7, and 30-day Codex fixture -sets and records the first process plus warm median/p95 for the pinned ccusage +sets and records the first process plus warm median/p95 for the lockfile-resolved ccusage binary. Override its default seven warm samples with `STRAUDE_COLLECTOR_BENCH_ITERATIONS`. It uses offline fixture pricing to isolate local scan cost from network availability. CI archives these measurements; diff --git a/docs/CLI_OPERATIONS.md b/docs/CLI_OPERATIONS.md index d52e55b2..94626d1a 100644 --- a/docs/CLI_OPERATIONS.md +++ b/docs/CLI_OPERATIONS.md @@ -47,6 +47,13 @@ failures, and no operation running past its deadline. Compare performance changes on the same runner class using `benchmark` and `benchmark:collector`; accuracy fixtures must pass regardless of latency. +The frozen-lock CI gate and the `ccusage compatibility` canary serve different +purposes. CI proves the release graph in `bun.lock`; the weekly/manual canary +installs `ccusage@latest` in isolation and passes a new major through the same +production parser and accounting gates. Schema drift, zero-priced Claude/Codex +usage, or a collector run beyond 60 seconds fails clearly. A canary failure does not alter +already-installed CLIs or download collector code at runtime. + ## Historical repair Only a service-role database session may call the repair functions. Start a diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e069e0d9..97d63137 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -2,9 +2,17 @@ ## Delegate all usage accounting to bundled ccusage v20 (2026-06-09) -**Decision:** All supported coding-agent ingestion runs through a single bundled `ccusage` (compatible `^20.0.16` range) invoked as a native binary, with a `>=20.0.16` accuracy floor validated against the bundled package version. Straude's native collectors, token normalizer, source whitelist, and pricing aliases are deleted; Straude only parses ccusage's unified daily JSON into storage rows. +**Decision:** All supported coding-agent ingestion runs through a single installed `ccusage` v20 dependency invoked as a native binary. The current floor is `20.0.18`; Straude's native collectors, token normalizer, source whitelist, and pricing aliases are deleted, so Straude only parses ccusage's unified daily JSON into storage rows. -**Alternatives considered:** (a) Keep the native Codex collector in parallel with ccusage Claude collection — rejected because it duplicates upstream parsing/dedupe/pricing work that ccusage now does correctly (v20 ships `metadata.agents`, archived-session dedupe, `thread_spawn` replay skipping) and was the source of two past inflation incidents. (b) Use a global `ccusage` from PATH — rejected because version skew on user machines breaks the accuracy floor; bundling pins the exact behavior we tested. +**Alternatives considered:** (a) Keep the native Codex collector in parallel with ccusage Claude collection — rejected because it duplicates upstream parsing/dedupe/pricing work that ccusage now does correctly (v20 ships `metadata.agents`, archived-session dedupe, `thread_spawn` replay skipping) and was the source of two past inflation incidents. (b) Use a global `ccusage` from PATH — rejected because uncontrolled version skew can bypass the package dependency and accuracy floor. + +## Accept stable ccusage releases above the accuracy floor and fail closed on paid-model pricing (2026-07-23) + +**Decision:** Publish `ccusage: >=20.0.18`, accept any stable semantic version at or above that floor, and record the version actually installed. Agent and model IDs remain opaque strings. Any Claude or Codex model breakdown with nonzero tokens and zero cost is rejected with `PricingUnavailableError`; other sources may legitimately report zero-cost usage. + +**Why:** ccusage owns source adapters and pricing support, so a patch or major ceiling delays new models and sources until Straude republishes. The open-ended floor lets fresh installs pick up a newer stable collector when its output still passes Straude's strict parser, accounting, and pricing invariants. Existing installs do not mutate in place; they receive the newer collector only after reinstalling or upgrading Straude. + +**Verification:** The frozen lockfile keeps the normal CI gate on `20.0.18`. A separate scheduled/manual workflow installs `ccusage@latest` in isolation, runs the real GPT-5.6 fixture through the production collector/parser regardless of major, checks a bounded runtime, and proves unknown Codex pricing fails closed. **Trade-off accepted:** The unified all-agent ccusage report benchmarked ~9.5% slower (median +152ms on a three-day mixed fixture) than the old parallel native path. Accepted: accuracy and a single owner for token accounting outweigh sub-second CLI latency. `reasoning_output_tokens` is derived as a non-negative residual `totalTokens - (input + output + cacheCreate + cacheRead)` when a source exposes reasoning outside the other reported buckets. @@ -926,7 +934,7 @@ Pricing the new-logic numbers at gpt-5.5 rates: $228.68 — matches what OpenAI ## ccusage Owns Sources and Current Model Pricing (2026-07-09) -**Decision:** Require `ccusage >=20.0.16`, run unified reports with online LiteLLM pricing by default, and accept every non-empty source ID ccusage emits. Preserve each daily row's `metadata.agents` in the normalized entry and collector metadata. Claude and Codex retain explicit collector markers only for their existing repair semantics; other sources use the generic ccusage run metadata. +**Decision:** Require stable `ccusage >=20.0.18`, run unified reports with online LiteLLM pricing by default, and accept every non-empty source ID ccusage emits. Preserve each daily row's `metadata.agents` in the normalized entry and collector metadata. Claude and Codex retain explicit collector markers only for their existing repair semantics; other sources use the generic ccusage run metadata. **Alternatives considered:** diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1a62fa70..a8744bbc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -47,7 +47,7 @@ Supabase can check passwords against HaveIBeenPwned to block known-compromised p ### Passed -**CLI release supply chain.** CLI runtime collection is pinned to `ccusage@20.0.16`. CI installs the Bun lockfile with Bun 1.3.3 in frozen mode, builds one npm tarball, and installs that exact artifact on Linux, macOS, and Windows under Node 20 and 22. The tag workflow requests npm trusted-publishing credentials over OIDC, carries no long-lived publish token, and publishes only after the package matrix passes; npm must be configured to trust `release-cli.yml` before the first release. Source maps are excluded from npm and GitHub releases, then retained as short-lived GitHub Actions artifacts for production diagnosis. +**CLI release supply chain.** CLI runtime collection accepts any stable `ccusage >=20.0.18`, while CI installs the exact `20.0.18` graph recorded in the frozen Bun lockfile. Fresh installs may resolve a later stable release, including a new major, only to have its output pass the same strict schema, accounting, and pricing checks before submission. The scheduled/manual compatibility canary tests `ccusage@latest` in isolation through the production parser, and the runtime never downloads `latest`. CI builds one npm tarball and installs that exact artifact on Linux, macOS, and Windows under Node 20 and 22. The tag workflow requests npm trusted-publishing credentials over OIDC, carries no long-lived publish token, and publishes only after the package matrix passes; npm must be configured to trust `release-cli.yml` before the first release. Source maps are excluded from npm and GitHub releases, then retained as short-lived GitHub Actions artifacts for production diagnosis. **RLS enabled on all 9 tables with appropriate policies.** Live database confirmed: diff --git a/docs/audit-2026-07-23.md b/docs/audit-2026-07-23.md index b77ee420..8b7669c0 100644 --- a/docs/audit-2026-07-23.md +++ b/docs/audit-2026-07-23.md @@ -44,7 +44,7 @@ argv - Working tree: clean `main` at `f007465`, aligned with `origin/main`. - Remote CI: current SHA passed on 2026-07-23; the CLI job reported 19 files and 203 tests passing. - Local execution: not run because dependencies are absent in this checkout. Installing them would have violated the read-only audit boundary. -- Registry: `straude@0.1.30` declares `ccusage: ^20.0.16`; a clean install currently resolves `20.0.18`, while `bun.lock:859` and CI use `20.0.16`. +- Registry at audit time: `straude@0.1.30` declared `ccusage: ^20.0.16`; a clean install resolved `20.0.18`, while the then-current `bun.lock` and CI used `20.0.16`. The 0.2.0 hardening branch now declares `>=20.0.18` and locks CI to `20.0.18`. - Production API, previous seven days: `/api/usage/submit` recorded 192 HTTP 200s, 53 HTTP 401s, two HTTP 500s, and one HTTP 400. No 207 was observed in that window. The route emits no structured start/end/error logs, so the 500s could not be attributed. - Live database, previous 30 days: 1,368 `daily_usage` rows and 1,530 `device_usage` rows; zero daily/device aggregate mismatches, token-invariant mismatches, model-breakdown cost mismatches, duplicate raw-hash groups, or exact same-host duplicate fingerprints. - Historical database: 233 daily/device value mismatches across 39 users, ending 2026-05-05, plus 59 same-user/same-hostname user-days with different device IDs but identical token totals and cost within half a cent. Those 59 rows span four users and imply up to $10,593.37 and 14.8B tokens of duplicated aggregation. This is a strong duplicate-device heuristic, not proof that every row in the set is invalid. @@ -233,11 +233,11 @@ Facts below are verified observations. Judgments describe severity and expected **Severity: High** -**Fact:** `packages/cli/package.json:32-38` publishes `ccusage: ^20.0.16`, while `bun.lock:859` fixes CI to 20.0.16. The npm range currently resolves 20.0.18. ccusage owns source discovery, parsing, deduplication, token accounting, and pricing (`packages/cli/README.md:12-18`). +**Fact at audit time:** `packages/cli/package.json` published `ccusage: ^20.0.16`, while the lockfile fixed CI to 20.0.16. ccusage owns source discovery, parsing, deduplication, token accounting, and pricing. **Judgment:** This creates exactly the kind of parser/schema drift seen in issues #13, #87, #99, and #132, without requiring a Straude release. -**Remediation:** Prefer an exact collector version in the published package. If a range is retained, release-gate both the minimum and the current maximum with golden fixtures and a packed-install test before declaring compatibility. +**Resolution:** The retained range is now `>=20.0.18`; runtime rejects versions below the floor, prereleases, and invalid semver, while later stable majors must pass the same strict parser and accounting checks. The parser fails closed on zero-priced Claude/Codex usage. The frozen lockfile tests `20.0.18`; packaged E2E validates both the range and installed version; a separate weekly/manual `ccusage@latest` canary runs the real fixture through the production parser. Fresh installs receive compatible updates, but existing `node_modules` stays unchanged until reinstall or upgrade. #### D2. The declared Node 18 floor is false diff --git a/docs/ccusage-upstream-compatibility-2026-07-23.md b/docs/ccusage-upstream-compatibility-2026-07-23.md new file mode 100644 index 00000000..075cf315 --- /dev/null +++ b/docs/ccusage-upstream-compatibility-2026-07-23.md @@ -0,0 +1,111 @@ +# ccusage upstream compatibility review + +Date: 2026-07-23 + +## Verdict + +Straude `0.2.0` uses the ccusage native Rust collector and includes the relevant upstream runtime performance work, including the fix for the pricing bug reported in [ccusage issue #934](https://github.com/ccusage/ccusage/issues/934). It treats model and agent IDs as data rather than maintaining runtime allowlists, so an unfamiliar model such as `claude-opus-5` or `gpt-6-codex` can flow through without a Straude code change when the installed ccusage binary can parse and price it. + +The review began with Straude pinned to and accepting exactly `20.0.16`, while npm `latest` was [`20.0.18`](https://github.com/ccusage/ccusage/releases/tag/v20.0.18). That missed Claude advisor-model accounting added in [`20.0.17`](https://github.com/ccusage/ccusage/releases/tag/v20.0.17) and the expanded embedded Moonshot/Kimi catalog in `20.0.18`. + +Both `20.0.16` and `20.0.18` emit an unknown synthetic Codex `gpt-6` model with real tokens, `$0` cost, no stderr warning, and no serialized `missingPricing` marker. The implementation now closes that gap with a source-based guard, accepts any stable release above the accuracy floor, and keeps the repository lockfile exact. Fresh Straude installations can receive new collector support, including a later major whose output passes the production invariants, without a Straude model/source allowlist update. Existing `node_modules` does not update silently; that still requires reinstalling or upgrading Straude. + +## Implementation outcome + +- `packages/cli` now declares `ccusage: >=20.0.18`; `bun.lock` resolves the current `20.0.18` release. +- Runtime accepts any stable semantic version `>=20.0.18`, records the installed version, and rejects older, prerelease, and invalid versions. +- Nonzero-token Claude or Codex model breakdowns with zero cost raise `PricingUnavailableError` in the shared production parser. Other source IDs may legitimately report zero-cost usage. +- Focused tests preserve all 15 current source IDs, add `future-agent`, exercise future Claude/Codex model names, and cover the version boundaries. +- Packaged E2E checks the published range and actual installed compatible version. A weekly/manual canary installs `ccusage@latest` in isolation and runs the real fixture through the production parser with a 60-second budget. + +## Version and feature status + +| Area | Straude today | Latest upstream | Finding | +| --- | --- | --- | --- | +| Collector package | `>=20.0.18`, with `20.0.18` in `bun.lock` | `20.0.18` | Current; fresh installs may take later stable releases | +| Runtime gate | Stable `>=20.0.18` | v20 JSON remains compatible | New majors are accepted when production invariants pass | +| Issue #934 | Included | Included | Fixed before v20, with stronger exact/boundary matching in v20 | +| Native performance work | Included through `20.0.15` | No newer installed-CLI performance change in `20.0.17` or `20.0.18` | Straude has the relevant speedups | +| Claude advisor usage | Included through locked `20.0.18` | Added in `20.0.17` | Current | +| Kimi/Moonshot embedded models | Updated through locked `20.0.18` | Expanded in `20.0.18` | Current | +| Supported sources | 15 unified sources | Same 15 sources | Straude invokes the correct unified report | +| Unknown paid model pricing | Fails closed for nonzero-token Claude/Codex usage | Upstream can emit `$0` without provenance | Straude guard prevents silent submission | + +## Accuracy issue #934 is fixed + +Issue #934 showed `gpt-5.4-mini` falling through to a first-match substring lookup and receiving `gpt-5` pricing. The immediate fix, merged in [PR #1018](https://github.com/ccusage/ccusage/pull/1018) and released in [`19.0.3`](https://github.com/ccusage/ccusage/releases/tag/v19.0.3), changed fallback selection from insertion order to the closest model-name length. + +The Rust implementation shipped in Straude's `20.0.16` goes further: + +- `gpt-5.4-mini` has an [exact built-in pricing entry](https://github.com/ccusage/ccusage/blob/v20.0.16/rust/crates/ccusage/src/pricing.rs#L1011-L1026). +- Fallback matching chooses the [longest matching key](https://github.com/ccusage/ccusage/blob/v20.0.16/rust/crates/ccusage/src/pricing.rs#L460-L472) and enforces model-version boundaries, so `gpt-5` cannot match an adjacent numeric version indiscriminately. +- Pricing lookup results, including misses, are cached before repeated message processing ([source](https://github.com/ccusage/ccusage/blob/v20.0.16/rust/crates/ccusage/src/pricing.rs#L399-L448)). + +Straude adds a second validation layer: it rejects negative or non-finite values, inconsistent token totals, duplicate dates/agents/models, aggregate-to-breakdown differences, missing-pricing warnings, and model-cost drift over $0.005. ccusage marks missing pricing internally but [omits that boolean from serialized JSON](https://github.com/ccusage/ccusage/blob/v20.0.18/rust/crates/ccusage/src/types.rs#L93-L105), so Straude keeps diagnostic logging enabled and scans stderr for ccusage's explicit ["Missing pricing ... cost excludes this model" warning](https://github.com/ccusage/ccusage/blob/v20.0.18/rust/crates/ccusage/src/output.rs#L363-L382). + +That warning path is not complete. A synthetic Codex `gpt-6` row produced `$0` with no warning on both tested versions, so all of Straude's arithmetic invariants passed. For Claude and Codex, where nonzero model usage is paid API-equivalent usage, Straude should reject any nonzero-token model breakdown with zero cost unless ccusage exposes explicit trustworthy provenance that the model is free. This rule is source-based, not a model-name allowlist, and automatically starts accepting a future model when ccusage supplies its price. + +## Straude includes the relevant speed improvements + +The native Rust collector arrived in [`20.0.0`](https://github.com/ccusage/ccusage/releases/tag/v20.0.0). Straude resolves the installed platform-specific native package directly and invokes it with `execFile`, so it avoids package-runner and JavaScript-shim overhead on every collection. + +The `20.0.16` pin includes the important runtime optimizations: + +- [PR #1096](https://github.com/ccusage/ccusage/pull/1096) reported a 2.18x improvement for unified daily JSON and 2.16x for Codex daily JSON by reducing allocation, hashing, and parsing overhead. +- [PR #1122](https://github.com/ccusage/ccusage/pull/1122) made a 1 GiB Codex JSON fixture 1.55x faster with bounded-memory loading and one-pass aggregation. +- [PR #1158](https://github.com/ccusage/ccusage/pull/1158) reduced peak RSS on a 50 MB unified Codex fixture from about 82 MB to 5.5 MB while slightly improving latency. +- [PR #1326](https://github.com/ccusage/ccusage/pull/1326) moved JSONL adapters onto byte-oriented prefiltering. +- [PR #1332](https://github.com/ccusage/ccusage/pull/1332) parallelized file and database reads across all agent loaders, including a measured 5.36x OpenCode improvement when its database covered most files. +- [PR #1407](https://github.com/ccusage/ccusage/pull/1407), released in `20.0.15`, cached pricing lookups that otherwise scanned roughly 2,200 entries per message. + +`20.0.17` is an accounting fix and `20.0.18`'s performance release note concerns the Nix build dependency cache, not the installed CLI runtime. Straude is therefore current on the upstream speed work that affects users, but should still upgrade for the two accuracy/coverage fixes. + +## Future model and source behavior + +### Models + +Straude does not whitelist model IDs. Its collector types use plain strings, preserve ccusage's model name, and validate only the accounting shape and a 255-character protocol bound. The existing `20.0.16` collector already contains tests and pricing for [`claude-fable-5`](https://github.com/ccusage/ccusage/blob/v20.0.16/rust/crates/ccusage/src/pricing.rs#L1988-L2003) and the [`gpt-5.6` family](https://github.com/ccusage/ccusage/blob/v20.0.16/rust/crates/ccusage/src/pricing.rs#L2045-L2073). + +For an existing supported source, a future model can work without either project releasing code when: + +1. the source log records a model ID and token/cost fields in the format its existing ccusage adapter already understands; and +2. live LiteLLM or models.dev pricing contains the model; and +3. ccusage emits a nonzero price, or explicit trustworthy free-model provenance, for nonzero usage. + +ccusage loads live LiteLLM pricing and lazily falls back to live models.dev before its embedded models.dev snapshot ([source](https://github.com/ccusage/ccusage/blob/v20.0.16/rust/crates/ccusage/src/pricing.rs#L399-L439)). Straude's online mode therefore already supports data-only additions to those catalogs. If Opus 5, a later Fable model, or GPT-6 needs a new alias, parser rule, request-level tier, or hardcoded price in ccusage, a fresh Straude install can consume the stable release containing that code as long as its output passes the production invariants. + +### Sources + +Straude calls `ccusage daily --json --by-agent`, which is the correct upstream interface for every detected supported source. ccusage `20.0.18` currently compiles 15 built-in loaders: Claude, Codex, OpenCode, Amp, Droid, Codebuff, Hermes, pi, Goose, OpenClaw, Kilo, Copilot, Gemini, Kimi, and Qwen ([source](https://github.com/ccusage/ccusage/blob/v20.0.18/rust/crates/ccusage/src/adapter/all/loader.rs#L28-L31)). Its unified JSON includes source metadata and optional per-agent rows ([source](https://github.com/ccusage/ccusage/blob/v20.0.18/rust/crates/ccusage/src/adapter/all/report.rs#L132-L170)), which matches Straude's parser. + +Straude accepts any non-empty agent string at runtime, so a newly compiled ccusage source does not require a Straude allowlist update. It does require a newer ccusage binary because the upstream loader list is compiled code. + +The boundary is data quality, not naming. ccusage's [Source Support Q&A](https://ccusage.com/guide/source-support-qa) requires local timestamps, session and model identity, and token counts or recorded cost; it explicitly refuses to estimate usage from transcript text. Straude cannot reliably support an agent that ccusage rejects because its local files lack those fields. + +## Compatibility probe + +I ran the released `ccusage@20.0.18` native package with Straude's production flags (`daily --json --by-agent --timezone UTC --no-offline`) against the repository's GPT-5.6 Codex fixture, then parsed the output with Straude's current `parseCcusageOutput`. + +The unmodified parser accepted one Codex day containing `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`, preserving 440,000 tokens and $1.917 of cost. For known-priced models, the only blocker is `collectCcusageUsageAsync`'s exact version equality check, not an output incompatibility. + +A second synthetic fixture changed the recorded model to `gpt-6`. Both `20.0.16` and `20.0.18` preserved the model and token data but returned zero cost without a warning or JSON missing-pricing marker, proving the zero-cost acceptance gap above. + +On the small direct-native fixture, 30 warm runs measured: + +| Version | Median | p95 | +| --- | ---: | ---: | +| `20.0.16` | 8.24 ms | 13.66 ms | +| `20.0.18` | 6.96 ms | 7.31 ms | + +This fixture is too small to generalize the relative speedup, but it confirms that both releases use the fast native path and that upgrading does not introduce an obvious local performance regression. + +## Implemented recommendation + +1. **Upgrade to `20.0.18` now.** This closes the advisor-model undercount and Kimi/Moonshot catalog gap while retaining all current performance work. +2. **Close the zero-cost provenance gap.** Reject nonzero-token Claude or Codex model breakdowns with zero cost unless ccusage later serializes an explicit trustworthy free-model/pricing-provenance field. Add a `gpt-6` fixture that must fail until ccusage prices it, then pass automatically. +3. **Publish an open-ended stable dependency floor.** Change the CLI dependency to `>=20.0.18` and accept later stable collector versions when their output passes Straude's strict parser, accounting, and pricing validation. Keep `bun.lock` exact so repository tests remain reproducible. +4. **Keep the accounting boundary strict and model-agnostic.** Preserve arbitrary source/model strings, per-agent/model invariant checks, and fail closed on missing pricing or JSON contract drift. Add explicit fixtures named like `claude-opus-5`, `claude-fable-6`, `gpt-6-codex`, and `future-agent` to prevent a later allowlist from creeping in. +5. **Canary upstream continuously.** A scheduled job should install `ccusage@latest` and run the known-price, unknown-price, real collector, malformed-output, and performance fixtures. A new major should pass when compatible rather than fail solely because of its version. +6. **Do not invoke `ccusage@latest` through npx on every sync.** That adds a registry/network dependency to collection, restores cold-download latency, permits an untested major/schema change, and makes a previously working installed CLI fail because npm is unavailable. + +The trade-off is explicit: the open-ended dependency floor lets a future stable release reach fresh installs before Straude has tested that exact version. The strict parser and zero-cost guard convert schema, accounting, or pricing drift into a stopped sync rather than silent bad accounting, and the scheduled canary shortens detection. There is no mechanism that is simultaneously instant for every existing installation, offline, and locked to a pre-tested collector digest. diff --git a/packages/cli/README.md b/packages/cli/README.md index 170c18bd..712c888d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,7 +17,7 @@ Running with no arguments performs a smart sync: logs you in if needed, then pus - Node 20+ - Local session data from any source supported by ccusage. -Straude invokes its installed [`ccusage`](https://github.com/ccusage/ccusage) dependency directly. Version `20.0.16` is pinned so the parser, token accounting, and native binary match the release fixture tested by Straude. Live LiteLLM pricing is required; embedded-price fallback is detected, retried within a bounded recovery budget, and never submitted. Straude uses ccusage's unified per-agent report, so all detected sources are included by default: Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and compatible custom source IDs. +Straude invokes its installed [`ccusage`](https://github.com/ccusage/ccusage) dependency directly. It accepts any stable release `>=20.0.18`, while the repository lockfile keeps CI reproducible. Fresh Straude installs can therefore resolve newer collector support, including a future major whose output still satisfies Straude's strict schema and accounting checks; an existing installation keeps its current `node_modules` until the package is reinstalled or upgraded. Live LiteLLM pricing is required, and Straude fails closed when Claude or Codex usage has tokens but no price. Straude uses ccusage's unified per-agent report, so future source and model IDs do not need a Straude allowlist update. ## Commands diff --git a/packages/cli/__tests__/ccusage-pricing.integration.test.ts b/packages/cli/__tests__/ccusage-pricing.integration.test.ts index 99cb7a87..af62142b 100644 --- a/packages/cli/__tests__/ccusage-pricing.integration.test.ts +++ b/packages/cli/__tests__/ccusage-pricing.integration.test.ts @@ -1,7 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { fileURLToPath } from "node:url"; import { - CCUSAGE_MIN_VERSION, _resetCcusageResolver, collectCcusageUsageAsync, } from "../src/lib/ccusage.js"; @@ -25,11 +24,6 @@ const ISOLATED_SOURCE_ENV = [ const originalEnvironment = new Map(); -function comparableVersion(version: string): number { - const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number); - return major * 1_000_000 + minor * 1_000 + patch; -} - beforeAll(() => { originalEnvironment.set("HOME", process.env.HOME); originalEnvironment.set("CODEX_HOME", process.env.CODEX_HOME); @@ -51,15 +45,13 @@ afterAll(() => { _resetCcusageResolver(); }); -describe("bundled ccusage GPT-5.6 pricing", () => { - it("logs Codex tokens and LiteLLM API spend for the complete GPT-5.6 family", async () => { +describe("lockfile ccusage 20.0.18 GPT-5.6 pricing", () => { + it("parses production JSON with Codex tokens and the complete GPT-5.6 family", async () => { const usage = await collectCcusageUsageAsync("20260709", "20260709", 10_000, { pricingMode: "online", }); - expect(comparableVersion(usage.version)).toBeGreaterThanOrEqual( - comparableVersion(CCUSAGE_MIN_VERSION), - ); + expect(usage.version).toBe("20.0.18"); expect(usage.agents).toEqual(["codex"]); expect(usage.data).toHaveLength(1); diff --git a/packages/cli/__tests__/ccusage.test.ts b/packages/cli/__tests__/ccusage.test.ts index 1878ecf0..c8c4ce37 100644 --- a/packages/cli/__tests__/ccusage.test.ts +++ b/packages/cli/__tests__/ccusage.test.ts @@ -11,13 +11,14 @@ vi.mock("node:child_process", () => ({ import { CCUSAGE_CLAUDE_COLLECTOR, CCUSAGE_CODEX_COLLECTOR, + PricingUnavailableError, collectCcusageUsageAsync, parseCcusageOutput, _resetCcusageResolver, _setCcusageCommandForTests, } from "../src/lib/ccusage.js"; -const ALL_BUILT_IN_CCUSAGE_AGENTS = [ +const SOURCE_IDS = [ "claude", "codex", "opencode", @@ -33,6 +34,7 @@ const ALL_BUILT_IN_CCUSAGE_AGENTS = [ "qwen", "copilot", "gemini", + "future-agent", ].sort(); function row(overrides: Record = {}) { @@ -96,7 +98,7 @@ beforeEach(() => { describe("parseCcusageOutput", () => { it("parses ccusage v20 daily rows and derives reasoning residuals", () => { - const parsed = parseCcusageOutput(rawOutput(), { version: "20.0.16" }); + const parsed = parseCcusageOutput(rawOutput(), { version: "20.0.18" }); expect(parsed.data).toHaveLength(1); expect(parsed.data[0]).toEqual({ @@ -146,7 +148,7 @@ describe("parseCcusageOutput", () => { expect(parsed.agents).toEqual(["codex"]); expect(parsed.collector).toEqual({ codex: CCUSAGE_CODEX_COLLECTOR, - ccusage_version: "20.0.16", + ccusage_version: "20.0.18", ccusage_agents: ["codex"], pricing_mode: "online", }); @@ -193,13 +195,13 @@ describe("parseCcusageOutput", () => { }, ], }), - ]), { version: "20.0.16" }); + ]), { version: "20.0.18" }); expect(parsed.agents).toEqual(["claude", "codex"]); expect(parsed.collector).toEqual({ claude: CCUSAGE_CLAUDE_COLLECTOR, codex: CCUSAGE_CODEX_COLLECTOR, - ccusage_version: "20.0.16", + ccusage_version: "20.0.18", ccusage_agents: ["claude", "codex"], pricing_mode: "online", }); @@ -207,7 +209,7 @@ describe("parseCcusageOutput", () => { expect(parsed.data[0]!.agents).toEqual(["claude", "codex"]); }); - it("preserves every built-in ccusage data source", () => { + it("preserves current and future ccusage source IDs without an allowlist", () => { const parsed = parseCcusageOutput(rawOutput([ row({ period: "2026-05-11", @@ -216,8 +218,8 @@ describe("parseCcusageOutput", () => { { modelName: "gpt-5.6", cost: 0.002 }, { modelName: "gemini-3-pro", cost: 0.00110625 }, ], - metadata: { agents: ALL_BUILT_IN_CCUSAGE_AGENTS }, - agents: ALL_BUILT_IN_CCUSAGE_AGENTS.map((agent, index) => ({ + metadata: { agents: SOURCE_IDS }, + agents: SOURCE_IDS.map((agent, index) => ({ agent, modelsUsed: index === 0 ? ["gpt-5.6", "gemini-3-pro"] : [], inputTokens: index === 0 ? 750 : 0, @@ -234,16 +236,16 @@ describe("parseCcusageOutput", () => { : [], })), }), - ]), { version: "20.0.16" }); + ]), { version: "20.0.18" }); expect(parsed.data).toHaveLength(1); - expect(parsed.data[0]!.agents).toEqual(ALL_BUILT_IN_CCUSAGE_AGENTS); - expect(parsed.agents).toEqual(ALL_BUILT_IN_CCUSAGE_AGENTS); + expect(parsed.data[0]!.agents).toEqual(SOURCE_IDS); + expect(parsed.agents).toEqual(SOURCE_IDS); expect(parsed.collector).toEqual({ claude: CCUSAGE_CLAUDE_COLLECTOR, codex: CCUSAGE_CODEX_COLLECTOR, - ccusage_version: "20.0.16", - ccusage_agents: ALL_BUILT_IN_CCUSAGE_AGENTS, + ccusage_version: "20.0.18", + ccusage_agents: SOURCE_IDS, pricing_mode: "online", }); }); @@ -303,12 +305,186 @@ describe("parseCcusageOutput", () => { ]))).toThrow(/did not produce live pricing/); }); + it.each(["claude", "codex"])( + "fails closed when %s reports a future paid model with tokens but no price", + (agent) => { + const model = agent === "claude" ? "claude-fable-6" : "gpt-6-codex"; + expect(() => parseCcusageOutput(rawOutput([ + row({ + modelsUsed: [model], + totalCost: 0, + modelBreakdowns: [{ + modelName: model, + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1125, + cost: 0, + }], + metadata: { agents: [agent] }, + agents: [{ + agent, + modelsUsed: [model], + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1200, + totalCost: 0, + modelBreakdowns: [{ + modelName: model, + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1125, + cost: 0, + }], + }], + }), + ]))).toThrow(PricingUnavailableError); + }, + ); + + it("fails closed before reasoning-only paid usage can receive residual model tokens", () => { + expect(() => parseCcusageOutput(rawOutput([ + row({ + modelsUsed: ["gpt-future-reasoning"], + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 50, + totalCost: 0, + modelBreakdowns: [{ + modelName: "gpt-future-reasoning", + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + cost: 0, + }], + metadata: { agents: ["codex"] }, + agents: [{ + agent: "codex", + modelsUsed: ["gpt-future-reasoning"], + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 50, + totalCost: 0, + modelBreakdowns: [{ + modelName: "gpt-future-reasoning", + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + cost: 0, + }], + }], + }), + ]))).toThrow(PricingUnavailableError); + }); + + it("fails closed when reasoning allocation gives tokens to an unpriced paid model", () => { + const modelBreakdowns = [ + { + modelName: "priced-model", + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 1, + cost: 0.1, + }, + { + modelName: "aaa-unpriced-model", + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + cost: 0, + }, + ]; + expect(() => parseCcusageOutput(rawOutput([ + row({ + modelsUsed: ["priced-model", "aaa-unpriced-model"], + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 2, + totalCost: 0.1, + modelBreakdowns, + metadata: { agents: ["codex"] }, + agents: [{ + agent: "codex", + modelsUsed: ["priced-model", "aaa-unpriced-model"], + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 2, + totalCost: 0.1, + modelBreakdowns, + }], + }), + ]))).toThrow(PricingUnavailableError); + }); + + it("allows a future source to report legitimately zero-cost usage", () => { + const parsed = parseCcusageOutput(rawOutput([ + row({ + modelsUsed: ["future-free-model"], + totalCost: 0, + modelBreakdowns: [{ + modelName: "future-free-model", + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1125, + cost: 0, + }], + metadata: { agents: ["future-agent"] }, + agents: [{ + agent: "future-agent", + modelsUsed: ["future-free-model"], + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1200, + totalCost: 0, + modelBreakdowns: [{ + modelName: "future-free-model", + inputTokens: 750, + outputTokens: 125, + cacheCreationTokens: 0, + cacheReadTokens: 250, + totalTokens: 1125, + cost: 0, + }], + }], + }), + ])); + + expect(parsed.agents).toEqual(["future-agent"]); + expect(parsed.data[0]!.models).toEqual(["future-free-model"]); + expect(parsed.data[0]!.costUSD).toBe(0); + }); + it("returns empty output for an empty ccusage daily array", () => { - const parsed = parseCcusageOutput(rawOutput([]), { version: "20.0.16" }); + const parsed = parseCcusageOutput(rawOutput([]), { version: "20.0.18" }); expect(parsed.data).toEqual([]); expect(parsed.agents).toEqual([]); expect(parsed.collector).toEqual({ - ccusage_version: "20.0.16", + ccusage_version: "20.0.18", ccusage_agents: [], pricing_mode: "online", }); @@ -388,12 +564,33 @@ describe("version and execution", () => { expect(collected.collector.pricing_mode).toBe("offline"); }); - it("rejects ccusage versions below the v20 accuracy floor", async () => { - _setCcusageCommandForTests({ cmd: "/bundled/ccusage", args: [], version: "20.0.15" }); - - await expect(collectCcusageUsageAsync("20260513", "20260513")).rejects.toThrow( - /fixture-verified ccusage 20\.0\.16/, - ); + it.each([ + ["minimum", "20.0.18", true], + ["later v20 patch", "20.0.99", true], + ["later v20 minor", "20.9.0", true], + ["build metadata", "20.0.18+straude.1", true], + ["below floor", "20.0.17", false], + ["next major", "21.0.0", true], + ["later major", "22.1.0", true], + ["prerelease", "20.0.19-beta.1", false], + ["leading zero", "20.00.18", false], + ["incomplete", "20.0", false], + ["invalid", "latest", false], + ])("%s version %s is supported: %s", async (_case, version, supported) => { + _setCcusageCommandForTests({ cmd: "/bundled/ccusage", args: [], version }); + if (!supported) { + await expect(collectCcusageUsageAsync("20260513", "20260513")).rejects.toThrow( + /stable ccusage version >=20\.0\.18/, + ); + return; + } + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const cb = args.at(-1) as (err: Error | null, stdout: string, stderr: string) => void; + cb(null, rawOutput(), ""); + }); + const collected = await collectCcusageUsageAsync("20260513", "20260513"); + expect(collected.version).toBe(version); + expect(collected.collector.ccusage_version).toBe(version); }); it("retries live pricing fallback failures at most three times", async () => { diff --git a/packages/cli/__tests__/commands/push.test.ts b/packages/cli/__tests__/commands/push.test.ts index ec513fa5..8846edc1 100644 --- a/packages/cli/__tests__/commands/push.test.ts +++ b/packages/cli/__tests__/commands/push.test.ts @@ -166,11 +166,11 @@ function collected(entries = [usageEntry()]) { agents: ["codex"], collector: { codex: "ccusage-codex-v20", - ccusage_version: "20.0.16", + ccusage_version: "20.0.18", ccusage_agents: ["codex"], pricing_mode: "online", }, - version: "20.0.16", + version: "20.0.18", raw: "{}", stderr: "", }; @@ -258,7 +258,7 @@ describe("pushCommand v2", () => { }, collector: { name: "ccusage", - version: "20.0.16", + version: "20.0.18", pricing_mode: "online", }, }); diff --git a/packages/cli/__tests__/flows/cli-sync-flow.test.ts b/packages/cli/__tests__/flows/cli-sync-flow.test.ts index b11de11a..30f5ccfc 100644 --- a/packages/cli/__tests__/flows/cli-sync-flow.test.ts +++ b/packages/cli/__tests__/flows/cli-sync-flow.test.ts @@ -148,7 +148,7 @@ function ccusageJson(date = todayStr()) { }); } -const TEST_CCUSAGE_VERSION = "20.0.16"; +const TEST_CCUSAGE_VERSION = "20.0.18"; function mockCcusage(json = ccusageJson()) { execFileMock.mockImplementation((_cmd: string, _args: string[], _options: unknown, callback: (err: Error | null, stdout: string, stderr: string) => void) => { diff --git a/packages/cli/__tests__/sync-state.test.ts b/packages/cli/__tests__/sync-state.test.ts index 40f34aa4..1e525e7b 100644 --- a/packages/cli/__tests__/sync-state.test.ts +++ b/packages/cli/__tests__/sync-state.test.ts @@ -39,7 +39,7 @@ function batch(): PendingUsageBatch { installation: { id: randomUUID() }, collector: { name: "ccusage", - version: "20.0.16", + version: "20.0.18", pricing_mode: "online", }, entries: [{ diff --git a/packages/cli/package.json b/packages/cli/package.json index d238a5b7..da8657c3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,7 +27,8 @@ "test": "bun run build:shared && vitest run", "test:packaged": "node scripts/packaged-cli-e2e.mjs", "benchmark": "node scripts/benchmark-cli.mjs", - "benchmark:collector": "node scripts/benchmark-collector.mjs" + "benchmark:collector": "node scripts/benchmark-collector.mjs", + "canary:ccusage": "bun scripts/check-ccusage-compatibility.ts" }, "devDependencies": { "@straude/shared": "workspace:*", @@ -39,7 +40,7 @@ }, "dependencies": { "@pppp606/ink-chart": "^0.2.4", - "ccusage": "20.0.16", + "ccusage": ">=20.0.18", "chalk": "^5.6.2", "ink": "^6.8.0", "posthog-node": "^5.29.1", diff --git a/packages/cli/scripts/benchmark-collector.mjs b/packages/cli/scripts/benchmark-collector.mjs index 86a0ba48..a88bc68e 100644 --- a/packages/cli/scripts/benchmark-collector.mjs +++ b/packages/cli/scripts/benchmark-collector.mjs @@ -27,8 +27,21 @@ const iterations = Number.parseInt( ); const anchor = "2026-07-09"; -if (collectorPackage.version !== "20.0.16") { - throw new Error(`Expected ccusage 20.0.16, found ${collectorPackage.version}`); +const collectorVersion = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ + .exec(collectorPackage.version); +if ( + !collectorVersion + || Number(collectorVersion[1]) < 20 + || ( + Number(collectorVersion[1]) === 20 + && Number(collectorVersion[2]) === 0 + && Number(collectorVersion[3]) < 18 + ) +) { + throw new Error( + `Expected stable ccusage >=20.0.18, found ${collectorPackage.version}`, + ); } if (!Number.isInteger(iterations) || iterations < 3) { throw new Error( diff --git a/packages/cli/scripts/check-ccusage-compatibility.ts b/packages/cli/scripts/check-ccusage-compatibility.ts new file mode 100644 index 00000000..ea3351d7 --- /dev/null +++ b/packages/cli/scripts/check-ccusage-compatibility.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + PricingUnavailableError, + _resetCcusageResolver, + _setCcusageCommandForTests, + assertSupportedCcusageVersion, + collectCcusageUsageAsync, + parseCcusageOutput, +} from "../src/lib/ccusage.js"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageDirArgument = process.argv.indexOf("--package-dir"); +const ccusagePackageDir = packageDirArgument === -1 + ? dirname(createRequire(import.meta.url).resolve("ccusage/package.json")) + : resolve(process.argv[packageDirArgument + 1] ?? ""); +const packageJson: unknown = JSON.parse( + readFileSync(join(ccusagePackageDir, "package.json"), "utf8"), +); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +if (!isRecord(packageJson) || typeof packageJson.version !== "string") { + throw new Error("ccusage compatibility canary could not read the installed version."); +} +const ccusageVersion = packageJson.version; +assertSupportedCcusageVersion(ccusageVersion); + +const bin = typeof packageJson.bin === "string" + ? packageJson.bin + : isRecord(packageJson.bin) + ? packageJson.bin.ccusage + : undefined; +if (typeof bin !== "string") { + throw new Error(`ccusage@${ccusageVersion} does not expose the expected ccusage binary.`); +} + +const fixtureRoot = resolve(scriptDir, "../__tests__/fixtures/ccusage-gpt-5.6"); +const isolatedVariables = [ + "CLAUDE_CONFIG_DIR", + "OPENCODE_DATA_DIR", + "AMP_DATA_DIR", + "DROID_SESSIONS_DIR", + "CODEBUFF_DATA_DIR", + "HERMES_HOME", + "PI_AGENT_DIR", + "GOOSE_PATH_ROOT", + "OPENCLAW_DIR", + "KILO_DATA_DIR", + "KIMI_DATA_DIR", + "QWEN_DATA_DIR", + "GEMINI_DATA_DIR", +] as const; +const originalEnvironment = new Map(); + +for (const variable of ["HOME", "CODEX_HOME", ...isolatedVariables]) { + originalEnvironment.set(variable, process.env[variable]); +} +process.env.HOME = fixtureRoot; +process.env.CODEX_HOME = join(fixtureRoot, "codex"); +for (const variable of isolatedVariables) delete process.env[variable]; + +const maximumDurationMs = Number.parseInt( + process.env.STRAUDE_CCUSAGE_CANARY_MAX_MS ?? "60000", + 10, +); +if (!Number.isInteger(maximumDurationMs) || maximumDurationMs <= 0) { + throw new Error("STRAUDE_CCUSAGE_CANARY_MAX_MS must be a positive integer."); +} + +try { + _setCcusageCommandForTests({ + cmd: process.execPath, + args: [resolve(ccusagePackageDir, bin)], + version: ccusageVersion, + }); + const startedAt = performance.now(); + const usage = await collectCcusageUsageAsync( + "20260709", + "20260709", + maximumDurationMs, + { pricingMode: "online", timezone: "UTC" }, + ); + const durationMs = Math.round(performance.now() - startedAt); + if (durationMs > maximumDurationMs) { + throw new Error( + `ccusage@${ccusageVersion} exceeded the ${maximumDurationMs}ms compatibility budget (${durationMs}ms).`, + ); + } + + const day = usage.data[0]; + const expectedModels = ["gpt-5.6", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]; + if ( + usage.data.length !== 1 + || !day + || day.totalTokens !== 440_000 + || day.costUSD <= 0 + || expectedModels.some((model) => !day.models.includes(model)) + ) { + throw new Error( + `ccusage@${ccusageVersion} changed the production fixture result: ${JSON.stringify({ + rows: usage.data.length, + totalTokens: day?.totalTokens, + costUSD: day?.costUSD, + models: day?.models, + })}`, + ); + } + + const unknownPaidModel = JSON.stringify({ + daily: [{ + period: "2026-07-09", + modelsUsed: ["gpt-future-codex"], + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 1, + totalCost: 0, + modelBreakdowns: [{ + modelName: "gpt-future-codex", + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 1, + cost: 0, + }], + metadata: { agents: ["codex"] }, + agents: [{ + agent: "codex", + modelsUsed: ["gpt-future-codex"], + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 1, + totalCost: 0, + modelBreakdowns: [{ + modelName: "gpt-future-codex", + inputTokens: 1, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 1, + cost: 0, + }], + }], + }], + }); + try { + parseCcusageOutput(unknownPaidModel, { + version: ccusageVersion, + pricingMode: "online", + }); + throw new Error("production parser accepted an unpriced Codex model with nonzero tokens."); + } catch (error) { + if (!(error instanceof PricingUnavailableError)) throw error; + } + + console.log( + `ccusage@${ccusageVersion} compatibility passed: production fixture parsed in ${durationMs}ms and unpriced paid usage failed closed.`, + ); +} finally { + for (const [variable, value] of originalEnvironment) { + if (value === undefined) delete process.env[variable]; + else process.env[variable] = value; + } + _resetCcusageResolver(); +} diff --git a/packages/cli/scripts/packaged-cli-e2e.mjs b/packages/cli/scripts/packaged-cli-e2e.mjs index 332ddbf0..3b67f4c6 100644 --- a/packages/cli/scripts/packaged-cli-e2e.mjs +++ b/packages/cli/scripts/packaged-cli-e2e.mjs @@ -9,6 +9,15 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const expectedCcusageRange = ">=20.0.18"; + +function isCompatibleCcusageVersion(version) { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ + .exec(version); + if (!match) return false; + const [, major, minor, patch] = match.map(Number); + return major > 20 || (major === 20 && (minor > 0 || patch >= 18)); +} function readOption(name) { const index = process.argv.indexOf(name); @@ -93,12 +102,22 @@ try { if (packageJson.bin?.straude !== "dist/index.js") { throw new Error(`Packed CLI has an invalid bin entry: ${JSON.stringify(packageJson.bin)}`); } - if (packageJson.dependencies?.ccusage !== "20.0.16") { - throw new Error(`Packed CLI must pin ccusage 20.0.16, got ${packageJson.dependencies?.ccusage}`); + if (packageJson.dependencies?.ccusage !== expectedCcusageRange) { + throw new Error( + `Packed CLI must declare ccusage ${expectedCcusageRange}, got ${packageJson.dependencies?.ccusage}`, + ); } if (packageJson.engines?.node !== ">=20") { throw new Error(`Packed CLI must require Node >=20, got ${packageJson.engines?.node}`); } + const installedCcusage = JSON.parse( + await readFile(join(installDir, "node_modules", "ccusage", "package.json"), "utf8"), + ); + if (!isCompatibleCcusageVersion(installedCcusage.version)) { + throw new Error( + `Packed CLI installed incompatible ccusage ${installedCcusage.version}; expected a stable version >=20.0.18`, + ); + } const cli = join(installedPackageDir, "dist", "index.js"); const childEnvironment = { @@ -125,8 +144,10 @@ try { if (submission.protocol_version !== 2) { throw new Error(`Expected usage protocol v2, got ${submission.protocol_version}`); } - if (submission.collector?.version !== "20.0.16") { - throw new Error(`Expected ccusage 20.0.16, got ${submission.collector?.version}`); + if (submission.collector?.version !== installedCcusage.version) { + throw new Error( + `Expected installed ccusage ${installedCcusage.version}, got ${submission.collector?.version}`, + ); } response.end(JSON.stringify({ request_id: submission.request_id, @@ -202,7 +223,7 @@ try { } console.log( - `straude v${packageJson.version} packed-install scorecard passed on Node ${process.version} (${elapsedMs}ms)`, + `straude v${packageJson.version} with ccusage ${installedCcusage.version} packed-install scorecard passed on Node ${process.version} (${elapsedMs}ms)`, ); } finally { if (server) { diff --git a/packages/cli/src/lib/ccusage.ts b/packages/cli/src/lib/ccusage.ts index d064d765..e9550c58 100644 --- a/packages/cli/src/lib/ccusage.ts +++ b/packages/cli/src/lib/ccusage.ts @@ -3,8 +3,7 @@ import { chmodSync, readFileSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import { DEFAULT_SUBPROCESS_TIMEOUT_MS } from "../config.js"; -export const CCUSAGE_MIN_VERSION = "20.0.16"; -export const CCUSAGE_VERIFIED_VERSION = "20.0.16"; +export const CCUSAGE_MIN_VERSION = "20.0.18"; export const CCUSAGE_CLAUDE_COLLECTOR = "ccusage-claude-v20" as const; export const CCUSAGE_CODEX_COLLECTOR = "ccusage-codex-v20" as const; export const CCUSAGE_DEFAULT_PRICING_MODE = "online" as const; @@ -257,20 +256,46 @@ export function _setCcusageCommandForTests(command: { cmd: string; args: string[ resolvedCommand = undefined; } -function compareSemver(a: string, b: string): number { - const left = a.split(".").map((part) => Number(part)); - const right = b.split(".").map((part) => Number(part)); - for (let i = 0; i < 3; i += 1) { - const diff = (left[i] ?? 0) - (right[i] ?? 0); - if (diff !== 0) return diff; +interface StableSemver { + major: number; + minor: number; + patch: number; +} + +function parseStableSemver(version: string): StableSemver | undefined { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ + .exec(version); + if (!match) return undefined; + const [major, minor, patch] = match.slice(1, 4).map(Number); + if ( + major === undefined + || minor === undefined + || patch === undefined + || !Number.isSafeInteger(major) + || !Number.isSafeInteger(minor) + || !Number.isSafeInteger(patch) + ) { + return undefined; } - return 0; + return { major, minor, patch }; } -function assertSupportedVersion(version: string): void { - if (compareSemver(version, CCUSAGE_VERIFIED_VERSION) !== 0) { +function compareSemver(a: StableSemver, b: StableSemver): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +export function assertSupportedCcusageVersion(version: string): void { + const parsed = parseStableSemver(version); + const minimum = parseStableSemver(CCUSAGE_MIN_VERSION); + if ( + !parsed + || !minimum + || compareSemver(parsed, minimum) < 0 + ) { throw new Error( - `ccusage ${version} is unsupported. Straude requires the fixture-verified ccusage ${CCUSAGE_VERIFIED_VERSION}. Reinstall Straude and retry.`, + `ccusage ${version} is unsupported. Straude requires a stable ccusage version >=${CCUSAGE_MIN_VERSION}. Reinstall Straude and retry.`, ); } } @@ -568,6 +593,17 @@ function parseAgentBreakdown(value: unknown, date: string): CcusageAgentEntry[] const totalTokens = asFiniteNumber(raw.totalTokens, `agents[${index}].totalTokens`, date); const costUSD = asFiniteNumber(raw.totalCost, `agents[${index}].totalCost`, date); const parsedModelBreakdown = parseModelBreakdown(raw.modelBreakdowns, date) ?? []; + const tokensRequirePaidPricing = raw.agent === "claude" || raw.agent === "codex"; + if (tokensRequirePaidPricing && totalTokens > 0 && costUSD === 0) { + throw new PricingUnavailableError( + `ccusage did not provide pricing for ${raw.agent} usage on ${date}.`, + ); + } + if (tokensRequirePaidPricing && totalTokens > 0 && parsedModelBreakdown.length === 0) { + throw new PricingUnavailableError( + `ccusage did not provide model pricing for ${raw.agent} usage on ${date}.`, + ); + } if (costUSD > 0 && parsedModelBreakdown.length === 0) { throw new Error(`Invalid ccusage row for ${date}: agents[${index}] priced usage requires modelBreakdowns.`); } @@ -583,6 +619,13 @@ function parseAgentBreakdown(value: unknown, date: string): CcusageAgentEntry[] parsedModelBreakdown, reasoningOutputTokens, ); + for (const model of modelBreakdown) { + if (tokensRequirePaidPricing && model.totalTokens > 0 && model.cost_usd === 0) { + throw new PricingUnavailableError( + `ccusage did not provide pricing for ${raw.agent} model ${model.model} on ${date}.`, + ); + } + } const models = asStringArray(raw.modelsUsed, `agents[${index}].modelsUsed`, date); const allModels = new Set(models); @@ -815,7 +858,7 @@ export async function collectCcusageUsageAsync( options: CollectOptions = {}, ): Promise { const { version } = resolveInstalledCcusageCommand(); - assertSupportedVersion(version); + assertSupportedCcusageVersion(version); const pricingMode = options.pricingMode ?? CCUSAGE_DEFAULT_PRICING_MODE; const timezone = options.timezone ?? resolveLocalTimezone(); const startedAt = Date.now(); diff --git a/papercuts.md b/papercuts.md index caa0bd03..166f7276 100644 --- a/papercuts.md +++ b/papercuts.md @@ -7,3 +7,7 @@ validating GitHub workflow YAML with Ruby → system Ruby 2.6 rejected YAML.load 2026-07-23T13:56:29.556Z — gpt-5.6-sol — ohong making CLI checks build the shared workspace first → bun with --cwd before run printed help and exited zero instead of running the script; put --cwd after run + +2026-07-23T19:29:59.532Z — gpt-5.6-sol — ohong + +checking the real-Supabase integration test → local Docker/OrbStack daemon is not running, so the integration stack cannot be inspected or started; unit/type checks remain available diff --git a/supabase/migrations/20260723133731_usage_submission_v2.sql b/supabase/migrations/20260723133731_usage_submission_v2.sql index dd1a0971..c74d6417 100644 --- a/supabase/migrations/20260723133731_usage_submission_v2.sql +++ b/supabase/migrations/20260723133731_usage_submission_v2.sql @@ -228,7 +228,23 @@ DECLARE p_is_verified AND p_source = 'cli' AND p_collector ->> 'name' = 'ccusage' - AND p_collector ->> 'version' = '20.0.16' + AND CASE + WHEN COALESCE(p_collector ->> 'version', '') ~ + '^(0|[1-9][0-9]{0,15})\.(0|[1-9][0-9]{0,15})\.(0|[1-9][0-9]{0,15})(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + THEN + split_part(split_part(p_collector ->> 'version', '+', 1), '.', 1)::NUMERIC + <= 9007199254740991 + AND split_part(split_part(p_collector ->> 'version', '+', 1), '.', 2)::NUMERIC + <= 9007199254740991 + AND split_part(split_part(p_collector ->> 'version', '+', 1), '.', 3)::NUMERIC + <= 9007199254740991 + AND ( + split_part(split_part(p_collector ->> 'version', '+', 1), '.', 1)::NUMERIC, + split_part(split_part(p_collector ->> 'version', '+', 1), '.', 2)::NUMERIC, + split_part(split_part(p_collector ->> 'version', '+', 1), '.', 3)::NUMERIC + ) >= (20::NUMERIC, 0::NUMERIC, 18::NUMERIC) + ELSE false + END AND p_collector ->> 'pricing_mode' = 'online'; v_authoritative BOOLEAN := v_trusted_partitioned_snapshot From 6ffb4a8952142f4f88ad117db9b6e7463ffc1c2d Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Thu, 23 Jul 2026 12:42:09 -0700 Subject: [PATCH 3/5] Log GitHub connector papercut --- papercuts.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/papercuts.md b/papercuts.md index 166f7276..5ff1efa9 100644 --- a/papercuts.md +++ b/papercuts.md @@ -11,3 +11,8 @@ making CLI checks build the shared workspace first → bun with --cwd before run 2026-07-23T19:29:59.532Z — gpt-5.6-sol — ohong checking the real-Supabase integration test → local Docker/OrbStack daemon is not running, so the integration stack cannot be inspected or started; unit/type checks remain available + +2026-07-23T19:42:00.064Z — gpt-5.6-sol — ohong + +updating a same-repository pull request through the GitHub connector → maintainer_can_modify=true returned 422 because fork collaboration only applies cross-repository; retrying without that optional field succeeded + From 53430ed485750810d6271fb629a12d82daaa2552 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Thu, 23 Jul 2026 12:48:11 -0700 Subject: [PATCH 4/5] Fix Windows packaged CLI test --- packages/cli/scripts/packaged-cli-e2e.mjs | 20 ++++++++++++++++---- papercuts.md | 4 ++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/cli/scripts/packaged-cli-e2e.mjs b/packages/cli/scripts/packaged-cli-e2e.mjs index 3b67f4c6..090dddac 100644 --- a/packages/cli/scripts/packaged-cli-e2e.mjs +++ b/packages/cli/scripts/packaged-cli-e2e.mjs @@ -8,7 +8,12 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const npmCommand = process.platform === "win32" + ? { + executable: process.execPath, + prefixArgs: [join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js")], + } + : { executable: "npm", prefixArgs: [] }; const expectedCcusageRange = ">=20.0.18"; function isCompatibleCcusageVersion(version) { @@ -29,9 +34,16 @@ function readOption(name) { return value; } +function execNpm(args, options) { + return execFileAsync( + npmCommand.executable, + [...npmCommand.prefixArgs, ...args], + options, + ); +} + async function createTarball(root) { - const { stdout } = await execFileAsync( - npm, + const { stdout } = await execNpm( ["pack", "--json", "--pack-destination", root], { cwd: packageDir, maxBuffer: 10 * 1024 * 1024 }, ); @@ -90,7 +102,7 @@ try { ? await resolveTarball(suppliedTarball) : await createTarball(root); - await execFileAsync(npm, ["install", "--no-audit", "--no-fund", tarball], { + await execNpm(["install", "--no-audit", "--no-fund", tarball], { cwd: installDir, maxBuffer: 10 * 1024 * 1024, }); diff --git a/papercuts.md b/papercuts.md index 5ff1efa9..40d88c92 100644 --- a/papercuts.md +++ b/papercuts.md @@ -16,3 +16,7 @@ checking the real-Supabase integration test → local Docker/OrbStack daemon is updating a same-repository pull request through the GitHub connector → maintainer_can_modify=true returned 422 because fork collaboration only applies cross-repository; retrying without that optional field succeeded +2026-07-23T19:45:47.414Z — gpt-5.6-sol — ohong + +inspecting PR checks with the GitHub CI-fix helper → the documented python command was unavailable on this Nix/macOS PATH; python3 ran the helper successfully + From c3f5cc73df9d7a127edde57ee9f2b0351106f296 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Thu, 23 Jul 2026 13:29:14 -0700 Subject: [PATCH 5/5] Stabilize and streamline CI --- .github/workflows/ccusage-compatibility.yml | 6 +- .github/workflows/ci.yml | 59 +++++++------------ .github/workflows/claude.yml | 3 +- .github/workflows/release-cli.yml | 20 +++---- docs/CHANGELOG.md | 6 +- docs/CLI.md | 9 +-- docs/SECURITY.md | 2 +- .../scripts/check-ccusage-compatibility.ts | 3 +- 8 files changed, 48 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ccusage-compatibility.yml b/.github/workflows/ccusage-compatibility.yml index 99d3b0fb..f6a16989 100644 --- a/.github/workflows/ccusage-compatibility.yml +++ b/.github/workflows/ccusage-compatibility.yml @@ -14,13 +14,13 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 20 @@ -35,8 +35,10 @@ jobs: canary_dir="$(mktemp -d)" npm install --prefix "$canary_dir" --no-audit --no-fund ccusage@latest echo "package_dir=$canary_dir/node_modules/ccusage" >> "$GITHUB_OUTPUT" + echo "node_executable=$(node -p 'process.execPath')" >> "$GITHUB_OUTPUT" - name: Run latest through the production parser run: bun packages/cli/scripts/check-ccusage-compatibility.ts --package-dir "${{ steps.latest.outputs.package_dir }}" env: STRAUDE_CCUSAGE_CANARY_MAX_MS: "60000" + STRAUDE_CCUSAGE_NODE_EXECUTABLE: ${{ steps.latest.outputs.node_executable }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbc3f069..1b4f09d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.3 - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} @@ -56,11 +56,11 @@ jobs: echo "No phantom dependencies." working-directory: apps/web - - name: Typecheck - run: bun run typecheck + - name: Typecheck web and shared + run: bun run typecheck --filter=@straude/web - - name: Build - run: bun run build + - name: Build web and shared + run: bun run build --filter=@straude/web env: NEXT_PUBLIC_SUPABASE_URL: http://localhost:54321 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: sb_publishable_placeholder @@ -75,7 +75,7 @@ jobs: SUPABASE_SECRET_KEY: sb_secret_placeholder - name: Setup Supabase CLI - uses: supabase/setup-cli@v1 + uses: supabase/setup-cli@v3 with: version: latest @@ -104,13 +104,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 20 @@ -125,20 +125,6 @@ jobs: run: bun run test working-directory: packages/cli - - name: Benchmark collector fixture - run: bun run benchmark:collector > ../../collector-benchmark.json - working-directory: packages/cli - env: - STRAUDE_COLLECTOR_BENCH_ITERATIONS: "3" - - - name: Upload collector benchmark - uses: actions/upload-artifact@v4 - with: - name: straude-collector-benchmark-${{ github.sha }} - path: collector-benchmark.json - if-no-files-found: error - retention-days: 14 - - name: Pack release candidate run: | mkdir -p ../../artifacts @@ -146,20 +132,12 @@ jobs: working-directory: packages/cli - name: Upload exact package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: straude-cli-package path: artifacts/*.tgz if-no-files-found: error - - name: Upload CI source map - uses: actions/upload-artifact@v4 - with: - name: straude-cli-sourcemap-${{ github.sha }} - path: packages/cli/dist/index.js.map - if-no-files-found: error - retention-days: 14 - cli-package-matrix: name: CLI package (${{ matrix.os }}, Node ${{ matrix.node }}) needs: cli-package @@ -167,17 +145,24 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - node: [20, 22] + include: + - os: ubuntu-latest + node: 20 + - os: ubuntu-latest + node: 22 + - os: macos-latest + node: 22 + - os: windows-latest + node: 22 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: straude-cli-package path: artifacts diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 6b15fac7..20cbcee3 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr *)' - diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index b979d150..62955deb 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 20 registry-url: https://registry.npmjs.org @@ -49,7 +49,7 @@ jobs: working-directory: packages/cli - name: Upload exact package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: straude-cli-package path: | @@ -58,7 +58,7 @@ jobs: if-no-files-found: error - name: Upload CI source map - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: straude-cli-sourcemap-${{ github.sha }} path: packages/cli/dist/index.js.map @@ -76,13 +76,13 @@ jobs: node: [20, 22] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: straude-cli-package path: artifacts @@ -99,14 +99,14 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 registry-url: https://registry.npmjs.org - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: straude-cli-package path: artifacts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3bca17ef..fea7fd0c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,11 +10,11 @@ - **Bounded failure handling.** API calls retry network failures, 408, 425, 429, and 5xx responses within an absolute deadline, using jitter and `Retry-After` when supplied. Login initialization has a 10-second deadline, noninteractive runs fail with actionable exit codes instead of opening a browser, telemetry shutdown is bounded, and automatic-run logs rotate. - **Live pricing is a commit gate.** Collection keeps ccusage diagnostic output enabled, rejects embedded or incomplete pricing, and makes at most three attempts inside a shared 60-second recovery budget. Pricing failure leaves both the outbox and contiguous watermark unchanged, so stale estimates can never become committed usage. - **Forward-compatible ccusage updates with fail-closed pricing.** The CLI declares `ccusage: >=20.0.18`, accepts any stable version above that floor, records the installed version, and keeps agent/model IDs generic. Nonzero-token Claude or Codex model usage at zero cost now raises `PricingUnavailableError`, while other sources may legitimately be free. Fresh installs can take later stable releases, including a compatible new major; existing installations keep their installed collector until reinstall or upgrade. -- **Reproducible package and release pipeline.** The CLI now requires Node 20+, locks the normal CI graph to `ccusage@20.0.18`, targets Node 20, and emits a source map that is excluded from npm and retained as a CI artifact. Bun is fixed at 1.3.3 and CI uses the frozen lockfile. `npm pack` builds from a clean `tsup` output, while the package allowlist ships only `dist/index.js`. -- **Cross-platform packaged verification.** CI and tag releases build one tarball, install that exact artifact on Linux, macOS, and Windows under Node 20 and 22, then run the real installed compatible collector against the GPT-5.6 fixture and a delayed scorecard server. The check also verifies the CLI version, Node engine, dependency range, actual collector version, bin entry, and absence of source maps or TypeScript build metadata. +- **Reproducible package and release pipeline.** The CLI now requires Node 20+, locks the normal CI graph to `ccusage@20.0.18`, targets Node 20, and emits a source map that is excluded from npm and retained by the release workflow. Bun is fixed at 1.3.3 and CI uses the frozen lockfile. `npm pack` builds from a clean `tsup` output, while the package allowlist ships only `dist/index.js`. +- **Cross-platform packaged verification.** PR CI builds one tarball and tests Ubuntu on Node 20/22 plus macOS and Windows on Node 22. Tag releases retain the full Linux/macOS/Windows Node 20/22 matrix. Every cell runs the real installed compatible collector against the GPT-5.6 fixture and a delayed scorecard server, while checking the CLI version, Node engine, dependency range, actual collector version, bin entry, and absence of source maps or TypeScript build metadata. - **Weekly ccusage latest canary.** A separate scheduled/manual workflow installs `ccusage@latest` in isolation and runs the real fixture through Straude's production parser. New majors are accepted when compatible; schema or pricing drift and runs beyond 60 seconds fail without changing the frozen-lock release gate or downloading latest in the product path. - **Tag-driven publishing.** A `straude@` tag runs CLI typecheck/tests, packages once, waits for the full OS/Node matrix, publishes the tested tarball to npm with provenance, and creates the matching GitHub release with the tarball and its `SHA256SUMS` digest. Publishing remains manual until a matching tag is pushed; this workflow does not create tags. -- **Repeatable performance benchmarks.** `bun run --cwd packages/cli benchmark` measures packed CLI startup, while `benchmark:collector` runs the lockfile-resolved collector over deterministic 1, 3, 7, and 30-day fixtures and reports first-run and warm median/p95 latency. CI archives collector results without imposing a machine-dependent threshold; accuracy fixtures remain a mandatory gate. +- **Repeatable performance benchmarks.** `bun run --cwd packages/cli benchmark` measures packed CLI startup, while `benchmark:collector` runs the lockfile-resolved collector over deterministic 1, 3, 7, and 30-day fixtures and reports first-run and warm median/p95 latency. The harness remains available for manual comparisons without adding benchmark execution or artifacts to every PR. - **CLI documentation corrected.** The documented windows now match behavior: 3 days on a fresh first sync, up to 7 contiguous uncommitted days per normal run, and up to 30 days only when explicitly requested. The reference also documents exit codes, platform support, automatic sync, telemetry, packaged testing, and the real `/api/cli/dashboard` endpoint. ### Fixed diff --git a/docs/CLI.md b/docs/CLI.md index d6c45a5c..f59ef578 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -274,7 +274,8 @@ in a temporary project, checks the declared dependency range and actual compatible collector version, runs that binary against the GPT-5.6 fixture, submits to a local HTTP server, and waits for the scorecard render. CI repeats the installed-tarball check on Linux, -macOS, and Windows with Node 20 and 22. +macOS, and Windows with Node 22, plus Ubuntu with Node 20. The release workflow +keeps the full Linux/macOS/Windows matrix on Node 20 and 22. The normal gate remains frozen to `bun.lock`. A separate weekly/manual `ccusage compatibility` workflow installs `ccusage@latest` in isolation and @@ -311,9 +312,9 @@ The collector harness creates deterministic 1, 3, 7, and 30-day Codex fixture sets and records the first process plus warm median/p95 for the lockfile-resolved ccusage binary. Override its default seven warm samples with `STRAUDE_COLLECTOR_BENCH_ITERATIONS`. It uses offline fixture pricing to isolate -local scan cost from network availability. CI archives these measurements; -accuracy tests gate the release, while benchmark thresholds are compared only -between runs on the same class of machine. +local scan cost from network availability. Run it manually when comparing +collector changes, and compare results only between runs on the same class of +machine. ## Constants diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a8744bbc..90015f9e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -47,7 +47,7 @@ Supabase can check passwords against HaveIBeenPwned to block known-compromised p ### Passed -**CLI release supply chain.** CLI runtime collection accepts any stable `ccusage >=20.0.18`, while CI installs the exact `20.0.18` graph recorded in the frozen Bun lockfile. Fresh installs may resolve a later stable release, including a new major, only to have its output pass the same strict schema, accounting, and pricing checks before submission. The scheduled/manual compatibility canary tests `ccusage@latest` in isolation through the production parser, and the runtime never downloads `latest`. CI builds one npm tarball and installs that exact artifact on Linux, macOS, and Windows under Node 20 and 22. The tag workflow requests npm trusted-publishing credentials over OIDC, carries no long-lived publish token, and publishes only after the package matrix passes; npm must be configured to trust `release-cli.yml` before the first release. Source maps are excluded from npm and GitHub releases, then retained as short-lived GitHub Actions artifacts for production diagnosis. +**CLI release supply chain.** CLI runtime collection accepts any stable `ccusage >=20.0.18`, while CI installs the exact `20.0.18` graph recorded in the frozen Bun lockfile. Fresh installs may resolve a later stable release, including a new major, only to have its output pass the same strict schema, accounting, and pricing checks before submission. The scheduled/manual compatibility canary tests `ccusage@latest` in isolation through the production parser, and the runtime never downloads `latest`. PR CI builds one npm tarball and checks Ubuntu Node 20/22 plus macOS and Windows Node 22; tag releases retain all six OS/Node combinations. The tag workflow requests npm trusted-publishing credentials over OIDC, carries no long-lived publish token, and publishes only after the package matrix passes; npm must be configured to trust `release-cli.yml` before the first release. Source maps are excluded from npm and GitHub releases, then retained as short-lived release-workflow artifacts for production diagnosis. **RLS enabled on all 9 tables with appropriate policies.** Live database confirmed: diff --git a/packages/cli/scripts/check-ccusage-compatibility.ts b/packages/cli/scripts/check-ccusage-compatibility.ts index ea3351d7..76932a29 100644 --- a/packages/cli/scripts/check-ccusage-compatibility.ts +++ b/packages/cli/scripts/check-ccusage-compatibility.ts @@ -68,13 +68,14 @@ const maximumDurationMs = Number.parseInt( process.env.STRAUDE_CCUSAGE_CANARY_MAX_MS ?? "60000", 10, ); +const nodeExecutable = process.env.STRAUDE_CCUSAGE_NODE_EXECUTABLE ?? "node"; if (!Number.isInteger(maximumDurationMs) || maximumDurationMs <= 0) { throw new Error("STRAUDE_CCUSAGE_CANARY_MAX_MS must be a positive integer."); } try { _setCcusageCommandForTests({ - cmd: process.execPath, + cmd: nodeExecutable, args: [resolve(ccusagePackageDir, bin)], version: ccusageVersion, });