Add design doc for ocd limits/balance dashboard
This commit is contained in:
parent
02390c668d
commit
769982e5c9
265
DESIGN.md
Normal file
265
DESIGN.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user