Add design doc for ocd limits/balance dashboard

This commit is contained in:
ocd 2026-07-30 01:07:14 +03:00
parent 02390c668d
commit 769982e5c9

265
DESIGN.md Normal file
View File

@ -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<AccountReport>,
}
struct AccountReport {
account: String, // config name
workspace: String, // workspace id
fetched_at: Option<String>, // ISO 8601 UTC
status: ReportStatus, // Ok | Cached | RateLimited | Timeout | Error(msg)
limits: Option<Limits>,
balance: Option<Balance>,
}
struct Limits {
usage_5h: UsageFrame,
usage_week: UsageFrame,
usage_month: UsageFrame,
next_update: Option<String>,
}
struct UsageFrame {
used: f64,
total: f64,
unit: String, // "credits" | "requests" | "$"
resets_at: Option<String>,
}
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.