Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ Each variant carries a lazy `getValue: Task[T]` so callers can branch on `isUnch
- **JobScheduler:** Polling daemon (configurable interval) that checks enabled `JobSchedule` entries and submits due jobs to the `JobRunner`.
- **ServerTables:** Ensures both analysis and server database tables exist on startup.

### CLI

`ccas` (`ccas.cli.Main`, a `ZIOAppDefault`) is a zio-cli binary. `CliCommand` defines the tree, `Dispatcher` turns each parsed command into HTTP calls against a running server, and `CompletionSpec`/`CompletionEmitter` generate the shell completions. Exit codes: 0 success/help, 1 job failure, 2 usage error.

**Tree:** 5 groups (`server`, `blacklist`, `schedule`, `club`, `config`) and 9 leaves (`use-club`, `membership`, `history`, `recruit`, `stats`, `jobs`, `logs`, `cancel`, `completion`).

**The locality invariant.** Every command is either a `CliCommand.ServerCommand` — carrying `--server`, dispatched by `Dispatcher` through `CcasApiClient` against a user-specified server — or a local command handled in-process by `Main.execute`, with no `--server` and no `Dispatcher` round-trip. (Local does not mean network-free: `server status`/`down`, and `up --detach`'s readiness wait, all probe fixed loopback `/health` endpoints via `HealthProbe`; `use-club` on a *set* makes a short best-effort `/api/managed-clubs` fetch to refresh the completion cache and sharpen its advisory — its guaranteed effect, the local pointer write, still lands offline. `config` genuinely touches nothing.) **Every group today is locality-homogeneous:** `server` + `config` are wholly local, `blacklist` + `schedule` + `club` wholly server-backed. Preserve this when adding commands. It is why `use-club` is a top-level leaf and not `ccas club use`: grouping it would make `club` the first mixed group and create an availability trap, where `ccas club add` fails with the server down but `ccas club use` succeeds, with nothing in the UI explaining the difference (the `git switch` / `kubectl config use-context` shape, decided in #123 / #86; the `club` group's help text points at `use-club` so discoverability doesn't depend on tree position).

**`current_club` vs `managed_club` are orthogonal, not hierarchical.** `current_club` is a *per-device local pointer* in the CLI config; `managed_club` is a *shared DB row* naming the clubs this deployment runs CCAS for. `ClubResolver.single` never consults the managed set — only `--all` expands from it (`ClubResolver.multi`) — so a `current_club` naming an unmanaged club still resolves and still submits jobs. Job submission likewise gates on the `club` row, not managed status (#177 tracks closing that gap). Because unmanaging leaves the `club` row intact, `ccas club remove` clears `current_club` when it names the removed club, so the next bare command fails loudly on `ClubResolver.NoClubError` rather than silently running against a disowned club.

**Two config files, deliberately separate.** `${XDG_CONFIG_HOME:-~/.config}/ccas/config.conf` holds CLI *client* settings (`api_url`, `default_clubs`, `log_dir`, `current_club`) — read by `CliConfig` via zio-config, written by `ConfigWriter` (a surgical line edit; zio-config has no HOCON writer). `${XDG_CONFIG_HOME:-~/.config}/ccas/ccas.env` holds what the *server* needs to boot (`CCAS_CONTACT_EMAIL`, `DATABASE_URL`, …) and is what `ccas config get|set|unset|list|path` manages, applied at boot by `ServerEnvOverlay`. `ccas config` does **not** see `current_club`; that is by contract, not a bug. Both paths resolve through `XdgPaths`, which reads environment variables — so any code a test must exercise against a temp path needs an explicit-path form (see `CompletionCache`'s `…In` variants) rather than relying on rebinding the environment.

**Completion cache.** `${XDG_CACHE_HOME:-~/.cache}/ccas/{clubs.txt,recent-jobs.txt}`, read by the generated scripts with a bare `cat` — no JVM, no network. `Dispatcher.refreshClubsCache` repopulates it after any successful server-touching command, gated on a 6h TTL. It is sourced from `/api/managed-clubs` (falling back to `/api/clubs` only when nothing is managed yet): `/api/clubs` is every club ever ingested, which a few history crawls push into the thousands. `CompletionCache.invalidate` bypasses the TTL and is called when the managed set changes (`club add`/`remove`) and on a `Club not found` submit failure, which is the shape a Chess.com club-slug rename takes — the server gates on an exact-slug `Club.selectBySlug` *before* any rename recovery can run, so `ClubSlugRenameResolver` is unreachable from that path (#176).

**Argument parsing gotchas.** zio-cli swallows an option written *after* a positional as another positional value (documented at the top of `CliCommand`), and `Args.atMost(n)` silently *truncates* extra values rather than rejecting them. Together those turn `ccas use-club team-alpha --clear` into a silent set of the club the user asked to clear, so `use-club` captures every positional with `.repeat` and validates the arity in `UseClub`, pointing at the working order. Prefer that shape over `atMost` wherever a dropped argument would change what the command does.

`completions/ccas.bash` is committed and asserted byte-equal to `CompletionEmitter.bash` by `TestCcasCompletion` — regenerate with `sbt -batch -error 'runMain ccas.cli.Main completion bash' > completions/ccas.bash` after any tree change. `CompletionSpec.summaries` is *intended* to mirror the `withHelp` strings in `CliCommand` but this is not asserted, and some have drifted.

### Database

Uses Magnum (`com.augustnagro.magnum`) for SQL access with PostgreSQL. `PostgresClient` (`ccas.utils.sql`) wraps a Magnum `Transactor` backed by HikariCP and adds connection-pool hardening (keepalive probes, validation queries, lazy initialization) and transient-error retry (exponential backoff on SQLState `08xxx`). `PostgresClient.live` reads config from `application.conf` under the `database` prefix and provides a `PostgresClient` ZLayer; all app and server code depends on `PostgresClient` rather than `Transactor` directly. Custom `DbCodec` instances handle `Instant` (via `TIMESTAMPTZ`), `URL`, and `List[String]` (PostgreSQL arrays). Table names are derived from case class names via `CamelToSnakeCase` naming strategy. Server tables (`JobRun`, `JobSchedule`) reference clubs by `club_id` FK; route handlers resolve the slug from HTTP requests to a `ClubId` before submitting jobs. Analysis run tables (`MembershipRun`, `RecruitmentRun`, `HistoryRun`) have an optional `job_run_id` column linking back to the server-level job. Schema migrations for existing databases are managed via manual SQL scripts in the `sql/` directory.
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,22 @@ ccas --help # full command tree
ccas <command> --help # per-command flags
```

Commands: `server {up|down|status}`, `use`, `membership`, `history`, `recruit`, `stats`, `jobs`, `logs`, `cancel`, `blacklist {add|list|remove}`, `schedule {list|add|remove}`, `club {add|remove|list}`.
Commands: `server {up|down|status}`, `use-club`, `membership`, `history`, `recruit`, `stats`, `jobs`, `logs`, `cancel`, `blacklist {add|list|remove}`, `schedule {list|add|remove}`, `club {add|remove|list}`.

**Cancelling a job.** `ccas cancel <job-id>` requests cancellation of a running job — it interrupts the job's fiber on the server, which records the run as `Cancelled`. Cancellation is best-effort: an in-flight blocking database statement runs to completion first, so the job stops at its next interruptible point rather than instantly. A cancelled recruitment run leaves the candidates it found so far as **deferred** (nothing invited), so you can review and confirm them afterwards. Cancel is single-server-scoped — it reaches jobs running on the server it is sent to.

**Club targeting.** Slug-requiring commands take the club via `--club <slug>` rather than a positional argument; `membership`/`history` accept a comma-separated `--club a,b` or `--all` (every managed club) — but not both at once (`--all` with `--club` is rejected as a likely mistake). When neither is given, the command falls back to the **current club** set with `ccas use-club <slug>` (stored as `current_club` in the [config file](#cli-config-file)); an explicit `--club`/`--all` always wins. `ccas use-club` is a local config write — no server call — so it works offline and only warns (does not reject) if the slug isn't among the cached clubs.
**Club targeting.** Slug-requiring commands take the club via `--club <slug>` rather than a positional argument; `membership`/`history` accept a comma-separated `--club a,b` or `--all` (every managed club) — but not both at once (`--all` with `--club` is rejected as a likely mistake). When neither is given, the command falls back to the **current club** set with `ccas use-club <slug>` (stored as `current_club` in the [config file](#cli-config-file)); an explicit `--club`/`--all` always wins. `ccas use-club` is a local config write that always succeeds — even with the server down — so it works offline. When a server *is* reachable it additionally makes a short best-effort check: it refreshes the completion cache and, if the slug isn't one of your managed clubs, notes so (it never rejects — an unmanaged club is a valid target). If the server can't be reached in a second or two it falls back to a cache-based hint and sets the club anyway.

```bash
ccas use-club # print the current club (exit 2 if none is set)
ccas use-club team-alpha # set it
ccas use-club --clear # unset it
ccas club list # the managed set; '*' marks the current club
```

The current club and the managed set are separate things: `ccas club {add,remove,list}` edits the server-side set of clubs you run CCAS for (shared by every client of that server), while `ccas use-club` is a local per-machine pointer at which of them your commands target by default. Removing the club you're currently using clears the pointer, so the next bare command asks you to pick one instead of silently running against a club you no longer manage.

**Flags go before positional arguments.** `ccas use-club --clear` works; `ccas use-club team-alpha --clear` does not — the underlying CLI library swallows an option written after a positional and treats it as another argument. `use-club` detects this and fails with the working order rather than acting on a misread command, but elsewhere in the tree a trailing flag is silently ignored, so keep options first: `ccas blacklist add --club team-alpha alice bob`.

The server URL resolves in order: a global `--server <url>` flag, else `api_url` from the [config file](#cli-config-file), else the built-in default `http://127.0.0.1:8080`.

Expand Down Expand Up @@ -108,7 +119,7 @@ eval "$(ccas completion zsh)"
ccas completion fish > ~/.config/fish/completions/ccas.fish
```

Dynamic candidates come from `${XDG_CACHE_HOME:-~/.cache}/ccas/clubs.txt` and `recent-jobs.txt`, refreshed automatically as you run normal commands (the club list is fetched from `GET /api/clubs` at most every few hours; each submitted job id is recorded). On a fresh install these are empty, so only subcommands and flags complete until the first command populates them — unless `default_clubs` is set in the [config file](#cli-config-file), which seeds the club list so completion works immediately.
Dynamic candidates come from `${XDG_CACHE_HOME:-~/.cache}/ccas/clubs.txt` and `recent-jobs.txt`, refreshed automatically as you run normal commands (the club list is fetched from `GET /api/managed-clubs` at most every few hours — falling back to `GET /api/clubs` only while you manage no clubs yet; each submitted job id is recorded). On a fresh install these are empty, so only subcommands and flags complete until the first command populates them — unless `default_clubs` is set in the [config file](#cli-config-file), which seeds the club list so completion works immediately.

The committed `completions/ccas.bash` is the generated output of `ccas completion bash`; `TestCcasCompletion` fails if it drifts from the emitter or if a new subcommand/flag isn't covered. Regenerate it with `ccas completion bash > completions/ccas.bash`.

Expand Down Expand Up @@ -234,7 +245,7 @@ current_club = "team-alpha" # default club when a command omi
```

- **Server URL** resolves `--server <url>` flag → `api_url` → built-in `http://127.0.0.1:8080`.
- **`default_clubs`** seeds the completion club list on a fresh install (before any `GET /api/clubs` round-trip); real slugs replace it on the next command.
- **`default_clubs`** seeds the completion club list on a fresh install (before any `GET /api/managed-clubs` round-trip); real slugs replace it on the next command.
- **`current_club`** is the club a slug-requiring command targets when neither `--club` nor `--all` is given. Set it with `ccas use-club <slug>` (which rewrites just this key, preserving your other keys and comments) rather than editing by hand.
- A missing file is fine — the CLI falls back to built-in defaults. A malformed file fails fast with `error: invalid config file <path>: …` (exit 2).

Expand Down
2 changes: 1 addition & 1 deletion completions/ccas.bash
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ _ccas() {
status) ;;
*) COMPREPLY=(); return ;;
esac ;;
use-club) pos="slug" ;;
use-club) opts="--clear"; pos="slug" ;;
membership) opts="--server --trust-usernames --no-trust-usernames --club --all --no-progress --detach" ;;
history) opts="--server --full --include-finished --refresh --refresh-min-hours --club --all --no-progress --detach" ;;
recruit) opts="--server --alias --target --cumulative --source-clubs --time-limit-minutes --explore --no-explore --club --stdout --report --no-progress" ;;
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/ccas/analysis/apps/ManagedClubApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ccas.utils.errors.NotFoundException
import ccas.utils.sql.PostgresClient

/** Synchronous CRUD for the [[ManagedClub]] marker — the explicit "I manage this club" act. Invoked from
* `ManagedClubRoutes` (and so `ccas manage|unmanage|managed`). No `ChessComClient`: the club must already exist
* `ManagedClubRoutes` (and so `ccas club add|remove|list`). No `ChessComClient`: the club must already exist
* locally; this never fetches Chess.com.
*/
object ManagedClubApp {
Expand Down
33 changes: 24 additions & 9 deletions src/main/scala/ccas/cli/CliCommand.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import zio.cli.*
*
* Club targeting is via the `--club <slug>` option (comma-separated on `membership`/`history`), with `--all` on those
* two to mean every managed club; when neither is given the command falls back to the config's `current_club` (set
* with `ccas use-club`). There are no positional club slugs — the remaining positionals are `<username>...` on the
* blacklist commands, the `<slug>` of `ccas use-club`, and the optional `[run-id]` on `ccas recruit --report`.
* with `ccas use-club`). There are no positional club slugs on the operation commands — the remaining positionals are
* `<username>...` on the blacklist commands, the `<slug>` of `ccas club add`/`remove`, the optional `[slug]` of
* `ccas use-club` (absent = print the current club), and the optional `[run-id]` on `ccas recruit --report`.
*
* IMPORTANT — option/argument ordering: zio-cli expects all options BEFORE positional arguments, e.g.
* `ccas blacklist add --club team-alpha alice bob`. Options placed AFTER a positional are NOT errors — they are
Expand Down Expand Up @@ -39,7 +40,7 @@ object CliCommand {
case object Stop extends CliCommand
case object ServerStatus extends CliCommand
final case class Completion(shell: String) extends CliCommand
final case class Use(slug: String) extends CliCommand
final case class Use(slugs: List[String], clear: Boolean) extends CliCommand
final case class ConfigGet(key: String) extends CliCommand
final case class ConfigSet(key: String, value: String) extends CliCommand
final case class ConfigUnset(key: String) extends CliCommand
Expand Down Expand Up @@ -156,11 +157,11 @@ object CliCommand {

// Single-club target. Absent → Dispatcher falls back to the config's `current_club`.
private val clubOpt: Options[Option[String]] =
Options.text("club").optional ?? "Club slug (URL name); falls back to the current club (set with 'ccas use')"
Options.text("club").optional ?? "Club slug (URL name); falls back to the current club (set with 'ccas use-club')"

// Multi-club target: comma-separated slugs. Absent (and without --all) → Dispatcher falls back to `current_club`.
private val clubsOpt: Options[List[String]] =
(Options.text("club").optional ?? "Comma-separated club slugs; falls back to the current club (set with 'ccas use')")
(Options.text("club").optional ?? "Comma-separated club slugs; falls back to the current club (set with 'ccas use-club')")
.map(_.fold(List.empty[String])(_.split(",").toList.map(_.trim).filter(_.nonEmpty)))

private val allOpt: Options[Boolean] =
Expand Down Expand Up @@ -192,10 +193,22 @@ object CliCommand {
.withHelp("Run and manage the ccas backend HTTP server")
.subcommands(serverUp, serverDown, serverStatus)

// The slug is optional so a bare `ccas use-club` PRINTS the current club rather than erroring — the only read path
// for a value that is otherwise write-only (`git branch` / `kubectl config current-context` shape). `--clear` drops
// it; passing both is rejected as conflicting intent, mirroring `--all` vs `--club`.
//
// `repeat` rather than `atMost(1)` because `atMost(1)` silently TRUNCATES extra positionals instead of rejecting
// them. Combined with the zio-cli ordering bug documented at the top of this file — an option written after a
// positional is swallowed as another positional — `ccas use-club team-alpha --clear` would parse as a plain set and
// silently set the very club the user asked to clear. Capturing every positional lets `UseClub` reject the arity and
// point at the right ordering.
private val useClub: Command[CliCommand] =
Command("use-club", Args.text("slug") ?? "Club slug (URL name) to set as the current club")
.withHelp("Set the current club used by commands that omit --club")
.map(Use.apply)
Command(
"use-club",
Options.boolean("clear") ?? "Clear the current club instead of setting one",
(Args.text("slug") ?? "Club slug (URL name) to set as the current club; omit to print the current one").repeat
).withHelp("Show, set, or clear the current club used by commands that omit --club")
.map { case (clear, slugs) => Use(slugs, clear) }

private def membership(default: String): Command[CliCommand] =
Command("membership", serverOpt(default) ++ trustOpt ++ clubsOpt ++ allOpt ++ noProgressOpt ++ detachOpt)
Expand Down Expand Up @@ -354,9 +367,11 @@ object CliCommand {
.withHelp("List the clubs you manage with CCAS")
.map(ClubsList.apply)

// The group help names `use-club` because the two are easy to confuse and live on opposite sides of the tree: this
// group edits the server-side managed set, while `use-club` is a local pointer at which of them commands target.
private def clubs(default: String): Command[CliCommand] =
Command("club")
.withHelp("Manage the set of clubs you run CCAS for")
.withHelp("Manage the set of clubs you run CCAS for (pick the one commands target with 'ccas use-club')")
.subcommands(clubsAdd(default), clubsRemove(default), clubsList(default))

private val completion: Command[CliCommand] =
Expand Down
Loading