From 769982e5c91dc972c275838cd9be991e19142578 Mon Sep 17 00:00:00 2001 From: ocd Date: Thu, 30 Jul 2026 01:07:14 +0300 Subject: [PATCH] Add design doc for ocd limits/balance dashboard --- DESIGN.md | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..71463c7 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,265 @@ +# ocd design + +`ocd` is a console application. It reads a config file. The file lists opencode +accounts and auth cookies. `ocd` scrapes the opencode Zen and Go pages. It +returns limits and balance data. Limits cover three time frames: 5 hours, week, +and month. The program reports usage, totals, reset times, and the next update +time. It also reports remaining account balance. + + +## Goals + +1. Speed. +2. Polite defaults. We do not hurt opencode. +3. Ease of use. + + +## Stack + +The program is written in Rust. + +| Concern | Crate | Reason | +|---|---|---| +| HTTP and cookies | `reqwest`, rustls, no openssl | One client reuses connections. One TLS handshake serves all workspaces. | +| Async runtime | `tokio` | Paced sequential pipeline. Two requests run at once per workspace. | +| HTML parse | `scraper` on `html5ever` | Fast CSS selectors. This is the only opencode-coupled module. | +| Config | `serde` and `toml` | Human-editable account list. | +| CLI | `clap` derive | Subcommands and global flags. | +| Pretty output | Custom renderer and `comfy-table` | Unicode blitter bars. The program degrades to ASCII. | +| Color | `anstyle` | Color turns off when output is not a terminal. | +| Raw output | `serde_json` | Stable schema. One `schema_version` field. | +| Cache path | `directories` | XDG cache dir. | +| Time | `chrono` now, `jiff` later | Reset times and relative strings. | +| Errors | `anyhow` and `thiserror` | Typed fetch and parse errors. | +| Diagnostics | `tracing` | `RUST_LOG` controls verbosity. | + + +## Workspaces + +An opencode account holds multiple workspaces. The Go subscription is +per-workspace. So config carries one `default_workspace` plus an +`extra_workspaces` list. Reports key by account and workspace. + +`default_workspace` is a required id. The program fetches it by default. +`extra_workspaces` is optional. The program fetches all listed workspaces. + +The program cannot auto-discover the default workspace. That path is not +reliable. So the user supplies the id. A future `ocd workspace list` command +can add discovery later. + + +## Two pages per workspace + +Each workspace needs two HTTP requests. + +1. The Zen page returns balance. +2. The Go page returns limits. + +The program fires both at once per workspace. It uses `tokio::join`. Then it +waits about 500 ms. Then it moves to the next workspace. The workspace walk is +sequential. Per-workspace concurrency is two. Cross-workspace concurrency is +one. This pacing keeps opencode load low. + + +## Reset times + +Each time frame resets on its own rule. + +- 5 hours: window based. The window starts on activity. +- Week: calendar based. It resets every Monday. +- Month: calendar based. It resets 31 days from the subscription date. + +The model keeps one `resets_at` field per frame. The scrape step reads this from +the page when present. Calendar resets can fall back to a computed value when the +scrape lacks the time but knows the rule. + + +## Config format + +```toml +# ~/.config/ocd/config.toml +default_concurrency = 1 +default_ttl_secs = 180 + +[[account]] +name = "work" +cookie = "${OCD_COOKIE_WORK}" +default_workspace = "ws_123" +extra_workspaces = ["ws_456"] + +[[account]] +name = "personal" +cookie = "ocd_session=...." +default_workspace = "ws_789" +``` + +Config load order: `-c` flag, then `$OCD_CONFIG`, then `./ocd.toml`, then +`$XDG_CONFIG_HOME/ocd/config.toml`. + +`${VAR}` expands in string values. This keeps cookies out of the file. + +Commands: + +- `ocd config init` writes a template. +- `ocd config check` parses the file and lists accounts. It does no network. + + +## CLI surface + +``` +ocd pretty default; bare `ocd` is an alias +ocd raw json one stable JSON object to stdout +ocd raw plain TSV-ish lines for shell and awk widgets +ocd fetch run network fetch and update cache; print nothing; for cron +ocd cache ls|clear inspect or clear the cache +ocd config init|check +``` + +Global flags: + +- `-c`, `--config PATH` +- `--no-cache` +- `--ttl SECS` +- `--force-refresh` +- `-a`, `--account NAME...` +- `-w`, `--workspace ID...` +- `-j`, `--concurrency N` (default 1; workspace walk stays sequential) +- `--timeout SECS` +- `--retries N` +- `--user-agent` +- `-v`, `--verbose` (repeat for more) +- `--no-color` +- `--ascii` +- `-q`, `--quiet` + + +## Data model + +```rust +struct Envelope { + schema_version: u32, // 1 + generated_at: String, // ISO 8601 UTC + accounts: Vec, +} + +struct AccountReport { + account: String, // config name + workspace: String, // workspace id + fetched_at: Option, // ISO 8601 UTC + status: ReportStatus, // Ok | Cached | RateLimited | Timeout | Error(msg) + limits: Option, + balance: Option, +} + +struct Limits { + usage_5h: UsageFrame, + usage_week: UsageFrame, + usage_month: UsageFrame, + next_update: Option, +} + +struct UsageFrame { + used: f64, + total: f64, + unit: String, // "credits" | "requests" | "$" + resets_at: Option, +} + +struct Balance { + remaining: f64, + unit: String, +} +``` + +## Run flow + +1. Parse flags and subcommand. +2. Load config. Expand `${VAR}`. Filter by `--account`. +3. Flatten targets. Each target is one account and one workspace. +4. Filter targets by `--workspace`. +5. Open the cache store. +6. Walk targets in order. + - Cache fresh and present. Emit `Cached`. No network. + - Else fire Zen and Go at once. Use conditional GET headers when cached. + On 304, reuse cache. On 200, parse both pages in `scrape.rs`, build the + report, and write cache. On 429, back off or honor `Retry-After`. On 5xx + or timeout, retry with backoff and jitter. On hard fail, emit `Error` + and continue other workspaces. + - Sleep about 500 ms before the next workspace. +7. Collect reports in config order. +8. Detect terminal width. Render. +9. Exit 0 when any account is Ok or Cached. Exit 1 only when all fail. + + +## Politeness defaults + +| Knob | Default | +|---|---| +| per-workspace requests | 2, run at once | +| cross-workspace concurrency | 1 | +| workspace pacing | 500 ms between workspaces | +| request timeout | 15 s | +| retries | 3, transient only | +| backoff | 500 ms, double per try, plus jitter | +| cache TTL | 180 s | +| fetches per workspace per run | 1, no loop | +| 429 | honor `Retry-After`, else back off; mark `RateLimited` | + +Conditional GET sends `If-Modified-Since` and `If-None-Match` when cached. A 304 +reuses cache at zero bandwidth. This is the cheapest hit for us and opencode. + + +## bleak project layout + +``` +src/ + main.rs clap entry and dispatch + cli.rs clap definitions + config.rs TOML load, ${VAR} expansion, Account + model.rs Envelope, AccountReport, Limits, UsageFrame, Balance + client.rs reqwest client, get with retry and backoff + fetch.rs paced workspace walk, join Zen and Go, cache read and write + cache.rs XDG file store, TTL, etag and last-modified + scrape.rs HTML to model; the only opencode-coupled module + render/pretty.rs Unicode blitter-bar renderer and table + render/raw.rs json and plain serializers + timefmt.rs relative and absolute time strings + error.rs typed fetch and parse errors +``` + +`scrape.rs` holds the only opencode HTML selectors. When the page changes, swap +this file. Transport, cache, and render stay untouched. + + +## Pretty renderer + +Blitter blocks fill a bar. + +- Full: `█`. +- Eighths: `▏▎▍▌▋▊▉`. +- Shaded: `▒`, `▓`. +- Empty: `░`. + +Bar width comes from terminal width. Filled cells are `floor(pct * W)`. The +trailing cell uses the matching eighths block. The remainder is `░`. + +Color zones. Color is off when output is not a terminal. + +- Under 70 per cent: green. +- 70 to 90 per cent: amber. +- Over 90 per cent: red. + +Each row shows name, `used / total unit`, percentage, and `resets in 3h12m`. + +The next-update row shows `2025-08-30 17:00 UTC (in 47m)`. + +`--ascii` or a non-UTF-8 locale prints `#` repeats. + + +## Open items + +- Crate name: `ocd`. +- Live watch mode for `htop`-like refresh. A later task. +- Confirm the Zen and Go page URLs and selectors. Fill `scrape.rs`. +- Confirm conditional GET support. OpenCode may not send ETag. Cache still works + from TTL alone. \ No newline at end of file