Rename setups and add provider reauthentication
This commit is contained in:
1 parent
e9a0f1b9da
commit
7d9df5d572
36 files changed
+1863
-681
No files matched your search
@@ -72,13 +72,15 @@ Each exists because something was invisible without it.
|
||||
`usage::Fixture`'s, since those are its states. With none set an echo
|
||||
session meters nothing, which is the ordinary case and draws no bar.
|
||||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||||
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||||
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
|
||||
`claude_cli` provider's `command` at a script that ordinarily runs
|
||||
`cat > /dev/null` and it behaves the way the lifecycle code cares about:
|
||||
it holds the fifo open, records a real pid, writes nothing, and dies on a
|
||||
signal. So adopt, stop, restart and start are all drivable without a real
|
||||
`--resume` and without spending a turn on somebody's account. Reach for
|
||||
this when what is under test is *whether a process is running*, and for
|
||||
`debug-transcript.sh` when it is *what the transcript draws*.
|
||||
`debug-transcript.sh` when it is *what the transcript draws*. The sandbox's
|
||||
version also handles `auth login`: it prints an inert Anthropic-shaped URL,
|
||||
rejects any code except `sandbox-code`, and exits successfully for that one.
|
||||
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
|
||||
the first session (or `-k` keeps the current screen), scrolls a fixed
|
||||
gesture loop, and prints the app's render report — the same one the in-app
|
||||
@@ -157,12 +159,12 @@ is how to tell the two apart in a hurry.
|
||||
There is no second machine, so **ssh this VM to itself**. That is set up
|
||||
too: the key is `~/.config/ai-app/ssh-self` (its public half is in
|
||||
`~/.ssh/authorized_keys`, labelled removable), and the real config carries a
|
||||
setup called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||
machine called **"this vm over ssh"** — `bob@127.0.0.1` with that
|
||||
`identityFile` plus
|
||||
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]`
|
||||
so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the
|
||||
whole rig for "does a remote llama session work", since the far machine is
|
||||
this one and the model file is the same file. For a throwaway setup of your
|
||||
this one and the model file is the same file. For a throwaway machine of your
|
||||
own, point a provider's `command` at something harmless like `/bin/echo`
|
||||
rather than at `claude`: the transport is what is under test, the process
|
||||
exiting immediately is the signal, and it costs no tokens. The remote login
|
||||
|
||||
@@ -34,7 +34,7 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
|
||||
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
||||
comment is the HTTP table and the surface's source of truth.
|
||||
**A llama.cpp session runs on whatever machine its setup names** (built
|
||||
**A llama.cpp session runs on its configured machine** (built
|
||||
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
||||
port the server binds *there* and the port that reaches it *here*, and
|
||||
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
||||
@@ -43,7 +43,7 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
because `llama-server` never reads the stdin whose closing ends a CLI and
|
||||
the same kill left it loaded on the far machine; the model is looked for on
|
||||
the machine that will serve it, so the spawn screen offers
|
||||
`GET /setups/{id}/models` rather than the backend's own downloads; and the
|
||||
`GET /machines/{id}/models` rather than the backend's own downloads; and the
|
||||
readiness poll watches the process as well as the port, since a model that
|
||||
will not load exits in a second and was being reported as "gave up after
|
||||
300s". See PLAN.md's "Transport" and "llama-server management".
|
||||
@@ -53,7 +53,7 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
protocol.
|
||||
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
||||
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
||||
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||
(sessions, import, models, machines); `Api.kt`/`EventStream.kt` the REST + SSE
|
||||
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
||||
the Keystore-sealed token.
|
||||
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
|
||||
@@ -64,7 +64,7 @@ Module-by-module intent is in PLAN.md's "Backend layout".
|
||||
build without it, since it is a path dependency, which is what keeps the two
|
||||
projects version-locked to the commit this repo pins. What deliberately did
|
||||
**not** move is the API surface and the config *schema*: routes, drivers,
|
||||
sessions and setups are what makes this project itself.
|
||||
sessions and machines are what makes this project itself.
|
||||
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
||||
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
||||
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
||||
@@ -175,7 +175,7 @@ means here:
|
||||
|
||||
- **`ai-server` belongs on the host in production.** That is where the LAN
|
||||
address the phone can reach is, and where WireGuard terminates.
|
||||
`wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||
`wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
|
||||
there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||
- **The tunnel and the real phone can never terminate in the VM**, because
|
||||
nothing outside can open a connection into it. Phone bring-up is host work.
|
||||
|
||||
+13
-13
@@ -14,7 +14,7 @@ AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` /
|
||||
## What it is, in one paragraph
|
||||
|
||||
A machine's filesystem, seen from the phone through the backend. The explorer
|
||||
belongs to a **setup** (a machine), not to a session: a session only says
|
||||
belongs to a **machine** (a machine), not to a session: a session only says
|
||||
where to start. Every operation — list, read, write, create — is one shell
|
||||
script run through `Transport`, exactly the way the import listing and the
|
||||
usage fetch already work, so the local and the ssh case are one
|
||||
@@ -25,16 +25,16 @@ message. The phone draws what came back.
|
||||
|
||||
### 1. Keyed on the machine, opened from the session
|
||||
|
||||
Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem
|
||||
Routes live under `/machines/{id}/…`, beside `importable`, because a filesystem
|
||||
is a property of a machine. The session screen's folder button opens the
|
||||
explorer with the session's setup and its `cwd`; a session with no `cwd`
|
||||
explorer with the session's machine and its `cwd`; a session with no `cwd`
|
||||
opens at the machine's home, which the **machine** resolves (`cd` with no
|
||||
argument and `pwd -P`), never a path the phone guessed. Nothing in the
|
||||
explorer knows what a session is, so a later entry point from the setups tab
|
||||
explorer knows what a session is, so a later entry point from the machines tab
|
||||
is one more caller and no new code.
|
||||
|
||||
Rejected: routes under `/sessions/{id}/`. The session would be a detour to
|
||||
find the setup, and "browse this machine" from anywhere else would need a
|
||||
find the machine, and "browse this machine" from anywhere else would need a
|
||||
session to exist first.
|
||||
|
||||
### 2. One shell script per operation, over `Transport`, on both transports
|
||||
@@ -68,7 +68,7 @@ Elsewhere the phone picks an **id** and the server resolves which file it
|
||||
names, so an enrolled token cannot become "read me an arbitrary file". The
|
||||
explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's
|
||||
Security section in these terms: the token already gates spawning a
|
||||
bypass-permissions agent in any directory on any machine a setup names, and
|
||||
bypass-permissions agent in any directory on any configured machine, and
|
||||
that agent can already read and write every file its user can. The explorer
|
||||
is a shorter path to authority the token already holds, not new authority.
|
||||
The import rule stands where it is, because there a path was unnecessary and
|
||||
@@ -89,7 +89,7 @@ is. The phone never resolves `..` itself.
|
||||
|
||||
### 5. A read is capped and typed, and every state it can be in has a word
|
||||
|
||||
`GET /setups/{id}/file` answers with one of `text` (content, size, mtime,
|
||||
`GET /machines/{id}/file` answers with one of `text` (content, size, mtime,
|
||||
sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over
|
||||
`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking
|
||||
at), or the machine's own error.
|
||||
@@ -102,7 +102,7 @@ what it is.
|
||||
|
||||
### 6. A write is conditional on what the reader saw
|
||||
|
||||
`PUT /setups/{id}/file` carries the sha256 the read reported. The script
|
||||
`PUT /machines/{id}/file` carries the sha256 the read reported. The script
|
||||
compares it against the file as it is now and exits distinctly if it differs;
|
||||
the server answers **409**. Agents edit files while people read them; this is
|
||||
the common case, not the exotic one, and silently overwriting an agent's edit
|
||||
@@ -123,9 +123,9 @@ precondition is fresh without a second read.
|
||||
|
||||
### 7. Create refuses to overwrite
|
||||
|
||||
`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
|
||||
`POST /machines/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a
|
||||
name that exists fails with the shell's own message rather than truncating
|
||||
somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same
|
||||
somebody's file; `POST /machines/{id}/dir` is `mkdir --` with the same
|
||||
property. The modal names one thing in the current directory and has a switch
|
||||
for "directory"; a created file opens straight into edit mode, because an
|
||||
empty file is not something to look at.
|
||||
@@ -275,14 +275,14 @@ already are. **Moving it is where the no-coordinate-taps rule got enforced**
|
||||
### 14. File links in a session open in the explorer
|
||||
|
||||
A markdown destination that is an absolute path or a local `file:` URI opens that document in the
|
||||
session's explorer, on the session's setup. A trailing editor line and optional column are removed;
|
||||
session's explorer, on the session's machine. A trailing editor line and optional column are removed;
|
||||
the viewer opens the file but does not yet scroll to a line. Web links, relative links and `file:`
|
||||
URIs naming another host keep their ordinary external behaviour. The distinction is deliberately
|
||||
narrow: a relative link might be a web reference, and the phone must not silently reinterpret it as
|
||||
a path on another machine.
|
||||
|
||||
The markdown link handler is provided around the session rather than taught about setups. That
|
||||
keeps the renderer reusable and makes the explorer's existing setup target the one navigation path.
|
||||
The markdown link handler is provided around the session rather than taught about machines. That
|
||||
keeps the renderer reusable and makes the explorer's existing machine target the one navigation path.
|
||||
|
||||
## HTTP surface
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ backend (Rust/Axum, desktop)
|
||||
│ ├─ LlamaDriver (llama-server over HTTP)
|
||||
│ └─ EchoDriver (the test rig)
|
||||
│ each driver's process is spawned through a Transport,
|
||||
│ locally or as `ssh host …`, decided by the setup it names
|
||||
│ locally or as `ssh host …`, decided by the configured machine
|
||||
├─ usage.rs (provider usage meters, per machine)
|
||||
├─ models.rs (HuggingFace browsing and GGUF downloads)
|
||||
├─ files.rs (the file explorer's half of the backend)
|
||||
@@ -42,11 +42,22 @@ backend (Rust/Axum, desktop)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Setups and providers (2026-08-28)
|
||||
### Machines and providers (2026-08-28; terminology corrected 2026-09-12)
|
||||
|
||||
**A setup is a machine, and it carries the providers that machine has.**
|
||||
Optional ssh details, plus the list of what can be run there. Spawning is
|
||||
two choices in order: pick a setup, then pick one of its providers.
|
||||
**A machine is an execution environment, and it carries the providers that
|
||||
environment has.** It may be a physical computer, a VM, or an SSH target;
|
||||
"host" is reserved for the address used to reach one. A session is therefore
|
||||
the pair of a machine and one provider installed there. Optional ssh details,
|
||||
plus the list of what can be run there. Spawning is two choices in order: pick
|
||||
a machine, then pick one of its providers.
|
||||
|
||||
The first implementation called this whole object a "setup", even though the
|
||||
word was originally meant to name the machine/provider pair. The public model,
|
||||
Machines tab, source names and HTTP surface now consistently say `machine` and
|
||||
`/machines`. Existing RON accepts `setups` and a session's `setup` as read-only
|
||||
aliases so an update cannot orphan configured environments or conversations;
|
||||
the next write uses `machines` and `machine`. There is deliberately no legacy
|
||||
HTTP alias: the server and APK are versioned together.
|
||||
|
||||
This replaced an independent providers × hosts cross-product, because the
|
||||
two axes are not independent: a provider is only real on a machine where
|
||||
@@ -55,7 +66,7 @@ work — `claude-cli` on a machine with no `claude`, and every provider paired
|
||||
with a host the driver ignores (`EchoDriver` takes no host, so "Run on" was
|
||||
a control that silently did nothing).
|
||||
|
||||
- **Echo is seeded, not implicit.** It lives in the setup with no ssh,
|
||||
- **Echo is seeded, not implicit.** It lives in the machine with no ssh,
|
||||
because it runs in-process and has no transport to cross. It is written
|
||||
into `config.ron` on first run rather than conjured at read time — a
|
||||
provider nobody can see in the file is one nobody can edit from the phone.
|
||||
@@ -78,7 +89,7 @@ axum 0.8, axum-server + rustls, tokio, serde, clap, tracing. Rust edition
|
||||
comment is the surface's source of truth**; this file does not repeat it.
|
||||
- `auth.rs` — the bearer-token middleware.
|
||||
- `config.rs` — the persisted schema.
|
||||
- `setups.rs` — machines and provider discovery.
|
||||
- `machines.rs` — machines and provider discovery.
|
||||
- `files.rs` — the file explorer (`EXPLORER.md`).
|
||||
- `usage.rs` — provider usage polling, per machine.
|
||||
- `models.rs` — HuggingFace browsing and GGUF downloads.
|
||||
@@ -223,16 +234,16 @@ is applied once Codex supplies that turn's id, so it cannot leak forward and
|
||||
hide a later failure.
|
||||
|
||||
Codex subscription limits come from the CLI's `account/rateLimits/read`
|
||||
app-server request on the machine whose setup runs Codex. This keeps login and
|
||||
app-server request on the machine that runs Codex. This keeps login and
|
||||
token refresh inside the CLI. Its primary and secondary windows are normalized
|
||||
into the existing usage snapshot shape, under provider name `codex`, so the
|
||||
phone and auto-resume need no Codex branch. This is the CLI's local protocol
|
||||
and is treated defensively for the same reason as Claude's undocumented usage
|
||||
endpoint: missing fields or a refusal degrade to an unavailable snapshot.
|
||||
|
||||
The model picker asks the selected setup's Codex app-server for `model/list`
|
||||
The model picker asks the selected machine's Codex app-server for `model/list`
|
||||
when it is opened. The catalog is account- and CLI-version-specific, so it is
|
||||
never copied into the app or inferred from another setup; a lookup failure is
|
||||
never copied into the app or inferred from another machine; a lookup failure is
|
||||
shown as unavailable while the free-text escape remains. Permission choices
|
||||
are likewise reported per provider: Codex offers its read-only,
|
||||
workspace-write and full-access modes, while Claude keeps its own modes.
|
||||
@@ -249,7 +260,7 @@ not exist on that machine. `thread/tokenUsage/updated.last.inputTokens` is the
|
||||
measured context (cached input is already included), while `last.totalTokens`
|
||||
remains the turn's usage. If an older common transcript has no such event yet,
|
||||
the server seeds the same measurement from the last `token_count` in Codex's
|
||||
own rollout, including when that rollout is on an SSH setup.
|
||||
own rollout, including when that rollout is on an SSH machine.
|
||||
|
||||
App-server's `item/agentMessage/delta` notifications are provisional: safety
|
||||
buffering can revise their words before `item/completed` supplies the durable
|
||||
@@ -270,7 +281,7 @@ deliberate and easy to undo by accident:
|
||||
when the process restarts. That leaves the Claude driver as the odd one
|
||||
out rather than this one — the CLI's memory is a cache in front of the same
|
||||
transcript. Resolve any inconsistency in this direction.
|
||||
- **A llama session runs on whatever machine its setup names** (2026-09-04,
|
||||
- **A llama session runs on its configured machine** (2026-09-04,
|
||||
the last of phase 5). A transport is "run this" plus "reach this port", and
|
||||
the second half is `Transport::reserve_port` — the port the server binds
|
||||
*there* and the port that reaches it *here*, the same number locally —
|
||||
@@ -281,12 +292,12 @@ deliberate and easy to undo by accident:
|
||||
avoids racing the bind anyway; a collision is not silent, since the server
|
||||
fails to bind and the readiness poll reports what its log said.
|
||||
- **The model file lives on the machine that serves it** (2026-09-04). Each
|
||||
setup names its own models directory (`SshConfig::models_dir`, default
|
||||
machine has its own models directory (`SshConfig::models_dir`, default
|
||||
`~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the
|
||||
key on that machine — one round trip answering "at /abs/path" or "missing",
|
||||
so a model that is not there is refused at the spawn rather than becoming a
|
||||
server that never becomes ready. The spawn screen offers
|
||||
`GET /setups/{id}/models`, that machine's list, rather than `GET /models`,
|
||||
`GET /machines/{id}/models`, that machine's list, rather than `GET /models`,
|
||||
which is this backend's downloads. Downloading *to* another machine is
|
||||
deliberately not built: a multi-gigabyte transfer with no progress
|
||||
anywhere, and the file gets there however anything else on that machine
|
||||
@@ -344,7 +355,7 @@ deliberate and easy to undo by accident:
|
||||
upload is streamed to disk under the session's attachments on this machine,
|
||||
and the message ends with `Attached file: /abs/path`. For a session on
|
||||
another machine the upload also copies the file there in the same request,
|
||||
over one `ssh` invocation, landing in the setup's `attachmentsDir` if set,
|
||||
over one `ssh` invocation, landing in the machine's `attachmentsDir` if set,
|
||||
else the session's cwd, else the login home. The resolved remote path is
|
||||
recorded beside the file (`<name>.remote`) and is what the driver names. A
|
||||
copy that fails fails the upload, so no message ever names a file that is
|
||||
@@ -611,7 +622,7 @@ prompt id, and the adopted copy billed for re-reading all of it. It ended at
|
||||
the account's session limit.
|
||||
|
||||
**Importing and deleting run on the server, and a batch is handed over in one
|
||||
call.** `POST /setups/{id}/importable/{delete,import}` each take a list of
|
||||
call.** `POST /machines/{id}/importable/{delete,import}` each take a list of
|
||||
ids, answer 202, and do the work in spawned tasks — the phone that asked is
|
||||
free to leave, and used to cancel its own batch by doing so. A list rather
|
||||
than a route per session because one request per row made a handover only as
|
||||
@@ -679,15 +690,36 @@ account being billed — and in the layout this aims at, `ai-server` is on the
|
||||
host, the host has no `claude`, and the CLI machine is a remote. So
|
||||
credentials are read through the session `Transport` (`$HOME` expanded by the
|
||||
far shell, because a path built locally is the wrong home), one snapshot per
|
||||
setup that offers Claude. The HTTP call stays on the backend, so the far end
|
||||
machine that offers Claude. The HTTP call stays on the backend, so the far end
|
||||
needs nothing but a shell.
|
||||
|
||||
The snapshot says which of four things happened rather than carrying a flag
|
||||
and a message: `ok`, `notLoggedIn`, `unreachable`, `failed`. `notLoggedIn` is
|
||||
The snapshot says what happened rather than carrying a flag and a message:
|
||||
`ok`, `notLoggedIn`, `authenticating`, `loginRequired`, `unreachable`, or
|
||||
`failed`. `notLoggedIn` is
|
||||
the one that matters — a machine nobody put an account on is working as
|
||||
configured, and collapsing it into an error string made a healthy setup read
|
||||
configured, and collapsing it into an error string made a healthy machine read
|
||||
as broken. A machine with no Claude provider is not asked at all.
|
||||
|
||||
**Refresh and sign-in are serialized per machine and metered provider
|
||||
(2026-09-12).** OAuth credentials rotate in a store shared by every CLI process
|
||||
on that environment. Concurrent `/usage` requests used to be able to run two
|
||||
refresh probes against the same file, while globally serializing would make an
|
||||
unreachable machine stall unrelated accounts. `UsageMonitor` therefore has one
|
||||
gate for each `(machine id, usage provider)` and rechecks the cache after taking
|
||||
it. A concurrent read serves the last answer when there is one; a first read
|
||||
waits for the single producer. Different machines and providers proceed
|
||||
independently.
|
||||
|
||||
When Claude cannot renew an expired login, the phone offers sign-in from both
|
||||
the Machines tab and the usage dialog. The backend starts the configured
|
||||
Claude CLI's headless `auth login` on that machine, returns only its Anthropic
|
||||
authorization URL, and accepts the one code copied back from the browser. The
|
||||
CLI remains the OAuth client and the only credential writer: the backend keeps
|
||||
the URL and code only for the live attempt and never sees or persists an access
|
||||
token or refresh token. An explicit login holds
|
||||
the same machine/provider gate as usage refresh, is cancelable, expires after
|
||||
ten minutes, and is killed when the backend stops.
|
||||
|
||||
**The five-hour window has no reset time between blocks, and that is not a
|
||||
missing value.** Measured 2026-08-31: the API anchors the window to the block
|
||||
it started in, and when no block is running there is nothing to reset, so
|
||||
@@ -890,7 +922,7 @@ time.
|
||||
### The file explorer (2026-09-03)
|
||||
|
||||
**`EXPLORER.md` holds this design.** The one-line version: a machine's
|
||||
filesystem, seen from the phone through the backend, keyed on the **setup**
|
||||
filesystem, seen from the phone through the backend, keyed on the **machine**
|
||||
rather than on a session (a session only says where to start), with every
|
||||
operation one fixed shell script run through `Transport` so the local and the
|
||||
ssh case are one implementation.
|
||||
@@ -942,7 +974,7 @@ ssh case are one implementation.
|
||||
"read me an arbitrary file" — the import listing is written that way. The
|
||||
explorer is different because the path is the whole feature, and it
|
||||
grants nothing new: the same token already spawns a bypass-permissions
|
||||
agent in any directory on any machine a setup names. The import rule
|
||||
agent in any directory on any configured machine. The import rule
|
||||
stands where it is, because there a path was unnecessary.
|
||||
- **Generation**: 256 bits from the OS CSPRNG, base64url. A machine
|
||||
credential, never typed twice, so at this entropy no stretching is needed.
|
||||
@@ -988,7 +1020,7 @@ ssh case are one implementation.
|
||||
tunnel once DDNS catches up. The symptom is obvious and lossless — the SSE
|
||||
cursor design replays whatever was missed.
|
||||
- Rejected: **Tailscale** and **Headscale**, which add a coordination
|
||||
service this setup does not need at two or three devices; **forwarding
|
||||
service this machine does not need at two or three devices; **forwarding
|
||||
the HTTPS port directly**, which puts every internet scanner one pre-auth
|
||||
bug away from RCE on a machine holding SSH keys.
|
||||
- The server refuses to start without TLS, so the token cannot travel
|
||||
@@ -1002,7 +1034,7 @@ ssh case are one implementation.
|
||||
Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
|
||||
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
|
||||
|
||||
1. **Session list** — kind icon, title, setup, model, status, last activity.
|
||||
1. **Session list** — kind icon, title, machine, model, status, last activity.
|
||||
Sessions awaiting an answer sort to the top: the "your turn" inbox.
|
||||
2. **Import** — Claude Code sessions the machine already has, selected in
|
||||
batches (hold to enter, tap to add), with Delete and Import along the
|
||||
@@ -1013,7 +1045,7 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
|
||||
not been imported, and tapping it starts a second CLI on the same
|
||||
transcript. That makes rows below slide up under the reader's finger, so a
|
||||
row that has just moved ignores taps for `SETTLE_MS`.
|
||||
3. **Models** and **Setups** — browsing and downloading GGUFs; adding,
|
||||
3. **Models** and **Machines** — browsing and downloading GGUFs; adding,
|
||||
renaming, re-probing and removing machines.
|
||||
4. **Session screen** — the core:
|
||||
- The transcript rendered from the event stream: markdown, inline images,
|
||||
@@ -1174,8 +1206,8 @@ verified by running it, matching dev-updater's posture.
|
||||
- **Remote llama-server** needs its port forwarded (`ssh -L`) and is not
|
||||
built; such a session is refused rather than misdirected.
|
||||
- **Claude sessions over ssh need the remote machine logged in to Claude.**
|
||||
Usage reporting reads each machine's own credentials, so this is visible
|
||||
rather than silent.
|
||||
Usage reporting reads each machine's own credentials, and the Machines tab
|
||||
can run that machine's CLI login without requiring an interactive SSH shell.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ card and readable in the same transcript view the session has. Designed
|
||||
|
||||
**A subagent is a second transcript owned by a session, in the same event
|
||||
model, with no process and no controls.** It is not a session: it cannot be
|
||||
messaged, stopped or started, and it has no setup, model or usage of its
|
||||
messaged, stopped or started, and it has no machine, model or usage of its
|
||||
own. Everything it shares with a session -- the transcript file format, the
|
||||
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
||||
by addressing, not by copying.
|
||||
|
||||
@@ -28,7 +28,7 @@ class ApiException(message: String, val status: Int? = null, cause: Throwable? =
|
||||
Exception(message, cause)
|
||||
|
||||
/**
|
||||
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
|
||||
* Runs one request against the backend, with the pinned TLS machine, the bearer token, and the
|
||||
* failure translation every call needs. [readBody] gets the connected, already-status-checked
|
||||
* connection.
|
||||
*
|
||||
@@ -122,12 +122,12 @@ data class SessionSummary(
|
||||
val id: String,
|
||||
/**
|
||||
* Id of the machine this session runs on. Only ever used to *address* that machine -- to pick
|
||||
* this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a
|
||||
* reader sees, and holding both invites showing the wrong one.
|
||||
* this session's row out of the per-machine usage snapshots. Never shown; [machineName] is what
|
||||
* a reader sees, and holding both invites showing the wrong one.
|
||||
*/
|
||||
val setup: String,
|
||||
/** The machine's current label. This is the one to display; [setup] is never shown. */
|
||||
val setupName: String,
|
||||
val machine: String,
|
||||
/** The machine's current label. This is the one to display; [machine] is never shown. */
|
||||
val machineName: String,
|
||||
val provider: String,
|
||||
val title: String,
|
||||
val model: String?,
|
||||
@@ -238,10 +238,10 @@ data class SessionSummary(
|
||||
private fun parseSession(session: JSONObject) =
|
||||
SessionSummary(
|
||||
id = session.getString("id"),
|
||||
setup = session.getString("setup"),
|
||||
machine = session.getString("machine"),
|
||||
keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false),
|
||||
ownTranscriptName = session.optString("ownTranscriptName").ifEmpty { null },
|
||||
setupName = session.getString("setupName"),
|
||||
machineName = session.getString("machineName"),
|
||||
provider = session.getString("provider"),
|
||||
title = session.getString("title"),
|
||||
model = session.optString("model").ifEmpty { null },
|
||||
@@ -328,7 +328,8 @@ fun deleteSubagents(settings: ServerSettings, sessionId: String, subagentIds: Li
|
||||
) {}
|
||||
}
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a machine added to the
|
||||
// server's
|
||||
// config.ron appears here with no app rebuild.
|
||||
//
|
||||
// One list rather than two. A provider only exists on a machine that has it installed, so offering
|
||||
@@ -345,9 +346,9 @@ data class Provider(
|
||||
* A machine, and what it can run. [address] is absent for the backend itself.
|
||||
*
|
||||
* [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that
|
||||
* refers to a setup uses the id and everything a person reads uses the name.
|
||||
* refers to a machine uses the id and everything a person reads uses the name.
|
||||
*/
|
||||
data class Setup(
|
||||
data class Machine(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val address: String?,
|
||||
@@ -366,16 +367,91 @@ private fun parseProvider(provider: JSONObject): Provider {
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseSetup(setup: JSONObject) =
|
||||
Setup(
|
||||
id = setup.getString("id"),
|
||||
name = setup.getString("name"),
|
||||
address = setup.optString("address").ifEmpty { null },
|
||||
providers = setup.getJSONArray("providers").mapObjects(::parseProvider),
|
||||
private fun parseMachine(machine: JSONObject) =
|
||||
Machine(
|
||||
id = machine.getString("id"),
|
||||
name = machine.getString("name"),
|
||||
address = machine.optString("address").ifEmpty { null },
|
||||
providers = machine.getJSONArray("providers").mapObjects(::parseProvider),
|
||||
)
|
||||
|
||||
fun fetchSetups(settings: ServerSettings): List<Setup> =
|
||||
requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) }
|
||||
fun fetchMachines(settings: ServerSettings): List<Machine> =
|
||||
requestFromServer(settings, "/machines") { it.jsonObjects(::parseMachine) }
|
||||
|
||||
/** One CLI-owned provider sign-in. The browser URL and pasted code are never persisted. */
|
||||
data class ProviderLogin(
|
||||
val attempt: String,
|
||||
val state: String,
|
||||
val authorizationUrl: String?,
|
||||
val detail: String?,
|
||||
)
|
||||
|
||||
private fun parseProviderLogin(login: JSONObject) =
|
||||
ProviderLogin(
|
||||
attempt = login.getString("attempt"),
|
||||
state = login.getString("state"),
|
||||
authorizationUrl = login.optString("authorizationUrl").ifEmpty { null },
|
||||
detail = login.optString("detail").ifEmpty { null },
|
||||
)
|
||||
|
||||
private fun providerLoginPath(machine: String, provider: String) =
|
||||
"/machines/${machine.urlEncoded()}/providers/${provider.urlEncoded()}/auth"
|
||||
|
||||
fun startProviderLogin(
|
||||
settings: ServerSettings,
|
||||
machine: String,
|
||||
provider: String,
|
||||
): ProviderLogin =
|
||||
requestFromServer(
|
||||
settings,
|
||||
providerLoginPath(machine, provider),
|
||||
method = "POST",
|
||||
readTimeoutMs = 25_000,
|
||||
) {
|
||||
parseProviderLogin(it.jsonObject())
|
||||
}
|
||||
|
||||
fun fetchProviderLogin(
|
||||
settings: ServerSettings,
|
||||
machine: String,
|
||||
provider: String,
|
||||
attempt: String,
|
||||
): ProviderLogin =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
|
||||
) {
|
||||
parseProviderLogin(it.jsonObject())
|
||||
}
|
||||
|
||||
fun submitProviderLoginCode(
|
||||
settings: ServerSettings,
|
||||
machine: String,
|
||||
provider: String,
|
||||
attempt: String,
|
||||
code: String,
|
||||
): ProviderLogin =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}/code",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("code", code).toString(),
|
||||
) {
|
||||
parseProviderLogin(it.jsonObject())
|
||||
}
|
||||
|
||||
fun cancelProviderLogin(
|
||||
settings: ServerSettings,
|
||||
machine: String,
|
||||
provider: String,
|
||||
attempt: String,
|
||||
) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"${providerLoginPath(machine, provider)}/${attempt.urlEncoded()}",
|
||||
method = "DELETE",
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Claude Code session already on a machine, which can be continued here.
|
||||
@@ -426,7 +502,7 @@ data class Importable(
|
||||
)
|
||||
|
||||
/**
|
||||
* One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing.
|
||||
* One frame of `GET /machines/{id}/importable/events`: an operation starting, finishing or failing.
|
||||
*
|
||||
* [operation] is only set by a start and [message] only by a failure -- the three states are every
|
||||
* way an operation can be, and each carries exactly what that state knows.
|
||||
@@ -461,8 +537,8 @@ fun parseImportableChange(payload: String): ImportableChange? =
|
||||
* reading every transcript Claude Code has ever written: about four seconds against a gigabyte of
|
||||
* them before the tunnel adds anything. A timeout is for a server that has stopped answering.
|
||||
*/
|
||||
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) {
|
||||
fun fetchImportable(settings: ServerSettings, machine: String): List<Importable> =
|
||||
requestFromServer(settings, "/machines/$machine/importable", readTimeoutMs = 60000) {
|
||||
it.jsonObjects { session ->
|
||||
Importable(
|
||||
id = session.getString("id"),
|
||||
@@ -515,10 +591,10 @@ private fun SshDetails.toJson() =
|
||||
}
|
||||
|
||||
/** What a machine turns out to have, without saving anything. */
|
||||
fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
|
||||
fun probeMachine(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/probe",
|
||||
"/machines/probe",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(),
|
||||
readTimeoutMs = 40000,
|
||||
@@ -526,10 +602,10 @@ fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
|
||||
it.jsonObjects(::parseProvider)
|
||||
}
|
||||
|
||||
fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
|
||||
fun addMachine(settings: ServerSettings, name: String, ssh: SshDetails?): Machine =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups",
|
||||
"/machines",
|
||||
method = "POST",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
@@ -538,19 +614,19 @@ fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
|
||||
.toString(),
|
||||
readTimeoutMs = 40000,
|
||||
) {
|
||||
parseSetup(it.jsonObject())
|
||||
parseMachine(it.jsonObject())
|
||||
}
|
||||
|
||||
/** Renames a machine, and optionally asks it again what it has. */
|
||||
fun updateSetup(
|
||||
fun updateMachine(
|
||||
settings: ServerSettings,
|
||||
id: String,
|
||||
name: String? = null,
|
||||
rediscover: Boolean = false,
|
||||
): Setup =
|
||||
): Machine =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${id.urlEncoded()}",
|
||||
"/machines/${id.urlEncoded()}",
|
||||
method = "PUT",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
@@ -561,20 +637,20 @@ fun updateSetup(
|
||||
.toString(),
|
||||
readTimeoutMs = 40000,
|
||||
) {
|
||||
parseSetup(it.jsonObject())
|
||||
parseMachine(it.jsonObject())
|
||||
}
|
||||
|
||||
fun deleteSetup(settings: ServerSettings, id: String) {
|
||||
requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {}
|
||||
fun deleteMachine(settings: ServerSettings, id: String) {
|
||||
requestFromServer(settings, "/machines/${id.urlEncoded()}", method = "DELETE") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a session and returns it as the list would show it. [setup] names the machine and
|
||||
* Spawns a session and returns it as the list would show it. [machine] names the machine and
|
||||
* [provider] one of the things that machine offers.
|
||||
*/
|
||||
fun spawnSession(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
machine: String,
|
||||
provider: String,
|
||||
title: String,
|
||||
model: String? = null,
|
||||
@@ -592,7 +668,7 @@ fun spawnSession(
|
||||
method = "POST",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
.put("setup", setup)
|
||||
.put("machine", machine)
|
||||
.put("provider", provider)
|
||||
.put("title", title)
|
||||
.apply {
|
||||
@@ -705,7 +781,7 @@ fun uploadAttachment(
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of a directory on the machine a setup names.
|
||||
* One entry of a directory on a configured machine.
|
||||
*
|
||||
* [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link]
|
||||
* still says it is one. Neither is worked out here -- the machine answers both.
|
||||
@@ -762,11 +838,11 @@ sealed class FileContent {
|
||||
/** What a file is after a write, so the editor's precondition is fresh without a second read. */
|
||||
data class Written(val size: Long, val modified: Long, val sha256: String)
|
||||
|
||||
/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */
|
||||
fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
|
||||
/** Everything in [path] on the machine [machine] names, and what [path] resolved to. */
|
||||
fun fetchDir(settings: ServerSettings, machine: String, path: String): Listing =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}",
|
||||
"/machines/${machine.urlEncoded()}/dir?path=${path.urlEncoded()}",
|
||||
readTimeoutMs = 30000,
|
||||
) { connection ->
|
||||
val body = connection.jsonObject()
|
||||
@@ -786,10 +862,10 @@ fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing =
|
||||
}
|
||||
|
||||
/** One file's content, or which of the reasons there is none to show. */
|
||||
fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent =
|
||||
fun fetchFile(settings: ServerSettings, machine: String, path: String): FileContent =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}",
|
||||
"/machines/${machine.urlEncoded()}/file?path=${path.urlEncoded()}",
|
||||
// A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any
|
||||
// of it moves. Well clear of that rather than just above it.
|
||||
readTimeoutMs = 60000,
|
||||
@@ -826,14 +902,14 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten
|
||||
*/
|
||||
fun writeFile(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
machine: String,
|
||||
path: String,
|
||||
content: String,
|
||||
ifSha256: String,
|
||||
): Written =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/file",
|
||||
"/machines/${machine.urlEncoded()}/file",
|
||||
method = "PUT",
|
||||
jsonBody =
|
||||
JSONObject()
|
||||
@@ -848,10 +924,10 @@ fun writeFile(
|
||||
}
|
||||
|
||||
/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */
|
||||
fun createFile(settings: ServerSettings, setup: String, path: String) {
|
||||
fun createFile(settings: ServerSettings, machine: String, path: String) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/file",
|
||||
"/machines/${machine.urlEncoded()}/file",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("path", path).toString(),
|
||||
readTimeoutMs = 30000,
|
||||
@@ -859,10 +935,10 @@ fun createFile(settings: ServerSettings, setup: String, path: String) {
|
||||
}
|
||||
|
||||
/** Creates a directory, with the same refusal as [createFile]. */
|
||||
fun createDir(settings: ServerSettings, setup: String, path: String) {
|
||||
fun createDir(settings: ServerSettings, machine: String, path: String) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setup.urlEncoded()}/dir",
|
||||
"/machines/${machine.urlEncoded()}/dir",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("path", path).toString(),
|
||||
readTimeoutMs = 30000,
|
||||
@@ -893,22 +969,23 @@ data class UsageWindow(
|
||||
data class UsageSnapshot(
|
||||
val provider: String,
|
||||
/** Stable id of the machine these numbers belong to. */
|
||||
val setup: String,
|
||||
val machine: String,
|
||||
/** That machine's current label. */
|
||||
val setupName: String,
|
||||
val machineName: String,
|
||||
/** Provider-specific billing pool, such as Codex's regular or Luna Reserve pool. */
|
||||
val limitId: String?,
|
||||
/** Provider-specific human-facing pool name, when supplied. */
|
||||
val limitName: String?,
|
||||
/**
|
||||
* What came back: "ok", "notLoggedIn", "unreachable" or "failed".
|
||||
* What came back: "ok", "notLoggedIn", "authenticating", "loginRequired", "unreachable" or
|
||||
* "failed".
|
||||
*
|
||||
* Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a
|
||||
* machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made
|
||||
* a healthy setup read as broken.
|
||||
* Named states rather than a flag, because the screen has to treat them differently.
|
||||
* "notLoggedIn" is a machine somebody chose not to put an account on -- a fact, not a fault.
|
||||
* Collapsing them made a healthy machine read as broken.
|
||||
*/
|
||||
val state: String,
|
||||
/** Why, for the two states that are faults. Absent otherwise. */
|
||||
/** Why, for states that have a useful explanation. Absent otherwise. */
|
||||
val detail: String?,
|
||||
val windows: List<UsageWindow>,
|
||||
)
|
||||
@@ -919,8 +996,8 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
|
||||
connection.jsonObjects { snapshot ->
|
||||
UsageSnapshot(
|
||||
provider = snapshot.getString("provider"),
|
||||
setup = snapshot.optString("setup"),
|
||||
setupName = snapshot.optString("setupName"),
|
||||
machine = snapshot.optString("machine"),
|
||||
machineName = snapshot.optString("machineName"),
|
||||
limitId = snapshot.optString("limitId").ifEmpty { null },
|
||||
limitName = snapshot.optString("limitName").ifEmpty { null },
|
||||
// Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would
|
||||
@@ -1000,10 +1077,10 @@ fun startSession(settings: ServerSettings, sessionId: String) {
|
||||
* One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant
|
||||
* a batch could half-arrive, and the rows that were missed looked exactly like rows not picked.
|
||||
*/
|
||||
fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<String>) {
|
||||
fun deleteImportable(settings: ServerSettings, machine: String, sessionIds: List<String>) {
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/$setup/importable/delete",
|
||||
"/machines/$machine/importable/delete",
|
||||
method = "POST",
|
||||
jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(),
|
||||
) {}
|
||||
@@ -1019,7 +1096,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List<S
|
||||
*/
|
||||
fun startImport(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
machine: String,
|
||||
sessionIds: List<String>,
|
||||
provider: String,
|
||||
permissionMode: String? = null,
|
||||
@@ -1034,7 +1111,7 @@ fun startImport(
|
||||
}
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/$setup/importable/import",
|
||||
"/machines/$machine/importable/import",
|
||||
method = "POST",
|
||||
jsonBody = body.toString(),
|
||||
) {}
|
||||
@@ -1297,8 +1374,8 @@ private fun parseDownload(o: JSONObject) =
|
||||
* offering the backend's would name files that are not there, turning a choice that cannot work
|
||||
* into a session that fails when it tries to load one.
|
||||
*/
|
||||
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
|
||||
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
|
||||
fun fetchMachineModels(settings: ServerSettings, machineId: String): List<LocalModel> =
|
||||
requestFromServer(settings, "/machines/${machineId.urlEncoded()}/models") { connection ->
|
||||
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
|
||||
LocalModel(
|
||||
key = m.getString("key"),
|
||||
@@ -1312,12 +1389,12 @@ fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel
|
||||
/** The current model catalog for one CLI provider on the machine where it runs. */
|
||||
fun fetchProviderModels(
|
||||
settings: ServerSettings,
|
||||
setupId: String,
|
||||
machineId: String,
|
||||
provider: String,
|
||||
): List<String> =
|
||||
requestFromServer(
|
||||
settings,
|
||||
"/setups/${setupId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
|
||||
"/machines/${machineId.urlEncoded()}/providers/${provider.urlEncoded()}/models",
|
||||
) { connection ->
|
||||
JSONArray(connection.inputStream.bufferedReader().readText()).strings()
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
|
||||
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
||||
* and the back button the only other way between them.
|
||||
*
|
||||
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
|
||||
* them a step down from another -- and what is left here is only what genuinely is a step down: one
|
||||
* session, spawning one, and settings.
|
||||
* Import, models and machines are tabs inside [MainScreen] -- four views of the same backend, none
|
||||
* of them a step down from another -- and what is left here is only what genuinely is a step down:
|
||||
* one session, spawning one, and settings.
|
||||
*/
|
||||
private sealed class Screen {
|
||||
/**
|
||||
|
||||
@@ -44,13 +44,13 @@ import kotlinx.coroutines.withContext
|
||||
/**
|
||||
* Which machine's files to show, and where to start.
|
||||
*
|
||||
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
|
||||
* where it was working. That is what makes a second way in -- from the setups tab -- one more
|
||||
* A **machine**, not a session: a filesystem is a property of a machine, and a session only says
|
||||
* where it was working. That is what makes a second way in -- from the machines tab -- one more
|
||||
* caller rather than any new code here.
|
||||
*/
|
||||
data class FilesTarget(
|
||||
val setup: String,
|
||||
val setupName: String,
|
||||
val machine: String,
|
||||
val machineName: String,
|
||||
val start: String,
|
||||
/** A document to open immediately; [start] remains the fallback directory. */
|
||||
val file: String? = null,
|
||||
@@ -59,8 +59,8 @@ data class FilesTarget(
|
||||
/** The explorer target for this session's machine, optionally opened on [file]. */
|
||||
fun SessionSummary.filesTarget(file: String? = null) =
|
||||
FilesTarget(
|
||||
setup = setup,
|
||||
setupName = setupName,
|
||||
machine = machine,
|
||||
machineName = machineName,
|
||||
start = cwd?.takeIf { it.isNotBlank() } ?: "~",
|
||||
file = file,
|
||||
)
|
||||
@@ -131,7 +131,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
listings[path] =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
LoadState.Loaded(fetchDir(settings, target.setup, path))
|
||||
LoadState.Loaded(fetchDir(settings, target.machine, path))
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
@@ -162,7 +162,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
// A file link can open without visiting the project first, but Back still needs to know where
|
||||
// the project is. Home is likewise resolved by the machine rather than guessed on the phone;
|
||||
// it is what lets every path beneath it be displayed with `~`, including over ssh.
|
||||
LaunchedEffect(target.setup, target.start) {
|
||||
LaunchedEffect(target.machine, target.start) {
|
||||
if (target.file != null) load(target.start, again = false)
|
||||
if (target.start != "~") load("~", again = false)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
FilesHeader(
|
||||
title = baseName(shownAt),
|
||||
path = shownAt,
|
||||
machine = target.setupName,
|
||||
machine = target.machineName,
|
||||
onBack = { leave(UnsavedDestination.Session) },
|
||||
) {
|
||||
GlyphButton(
|
||||
@@ -241,7 +241,7 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un
|
||||
if (creating && dir != null && listing != null) {
|
||||
CreateDialog(
|
||||
settings = settings,
|
||||
setup = target.setup,
|
||||
machine = target.machine,
|
||||
directory = listing.path,
|
||||
onDismiss = { creating = false },
|
||||
onCreated = { path, isDirectory ->
|
||||
@@ -444,7 +444,7 @@ private fun ColumnScope.DocPane(
|
||||
state = LoadState.Loading
|
||||
state =
|
||||
try {
|
||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.machine, path) }
|
||||
if (got is FileContent.Text) draft = TextFieldValue(got.content)
|
||||
LoadState.Loaded(got)
|
||||
} catch (e: ApiException) {
|
||||
@@ -467,7 +467,7 @@ private fun ColumnScope.DocPane(
|
||||
try {
|
||||
val written =
|
||||
withContext(Dispatchers.IO) {
|
||||
writeFile(settings, target.setup, path, draft.text, against)
|
||||
writeFile(settings, target.machine, path, draft.text, against)
|
||||
}
|
||||
state =
|
||||
LoadState.Loaded(
|
||||
@@ -496,7 +496,7 @@ private fun ColumnScope.DocPane(
|
||||
FilesHeader(
|
||||
title = name,
|
||||
path = tildePath(path, homeDirectory),
|
||||
machine = target.setupName,
|
||||
machine = target.machineName,
|
||||
onBack = onBack,
|
||||
) {
|
||||
if (editing) {
|
||||
@@ -595,7 +595,9 @@ private fun ColumnScope.DocPane(
|
||||
scope.launch {
|
||||
val fresh =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchFile(settings, target.machine, path)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
saveError = e.message
|
||||
conflict = null
|
||||
@@ -639,7 +641,7 @@ private fun Note(text: String) {
|
||||
@Composable
|
||||
private fun CreateDialog(
|
||||
settings: ServerSettings,
|
||||
setup: String,
|
||||
machine: String,
|
||||
directory: String,
|
||||
onDismiss: () -> Unit,
|
||||
onCreated: (String, Boolean) -> Unit,
|
||||
@@ -659,8 +661,8 @@ private fun CreateDialog(
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (isDirectory) createDir(settings, setup, path)
|
||||
else createFile(settings, setup, path)
|
||||
if (isDirectory) createDir(settings, machine, path)
|
||||
else createFile(settings, machine, path)
|
||||
}
|
||||
onCreated(path, isDirectory)
|
||||
} catch (e: ApiException) {
|
||||
|
||||
@@ -82,8 +82,8 @@ private const val SETTLE_MS = 500L
|
||||
@Composable
|
||||
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var chosen by remember { mutableStateOf<Setup?>(null) }
|
||||
var machines by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||
var chosen by remember { mutableStateOf<Machine?>(null) }
|
||||
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
|
||||
|
||||
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
|
||||
@@ -113,9 +113,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* Taken from the answer rather than kept across the load: the server is what knows what is
|
||||
* running, and this screen may be opening on work another phone started.
|
||||
*/
|
||||
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
|
||||
suspend fun fetchInto(machine: Machine): LoadState<List<Importable>> =
|
||||
try {
|
||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
|
||||
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, machine.id) }
|
||||
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
|
||||
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
|
||||
LoadState.Loaded(rows)
|
||||
@@ -123,10 +123,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
LoadState.Error(err.message ?: "Couldn't list sessions")
|
||||
}
|
||||
|
||||
fun loadSessions(setup: Setup) {
|
||||
fun loadSessions(machine: Machine) {
|
||||
sessions = LoadState.Loading
|
||||
selected = emptySet()
|
||||
scope.launch { sessions = fetchInto(setup) }
|
||||
scope.launch { sessions = fetchInto(machine) }
|
||||
}
|
||||
|
||||
/** Takes a row out of the list, once the machine no longer has it to offer. */
|
||||
@@ -140,9 +140,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
}
|
||||
|
||||
LaunchedEffect(reloadToken) {
|
||||
setups =
|
||||
machines =
|
||||
try {
|
||||
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||
val found = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||
found.firstOrNull()?.let {
|
||||
chosen = it
|
||||
loadSessions(it)
|
||||
@@ -170,7 +170,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
selected = emptySet()
|
||||
running = running + targets.associate { it.id to WAITING }
|
||||
rowErrors = rowErrors - targets.map { it.id }.toSet()
|
||||
val setup = chosen
|
||||
val machine = chosen
|
||||
val ids = targets.map { it.id }
|
||||
scope.launch {
|
||||
// One request for the whole batch, not one per row. Sent row by row, a handover was
|
||||
@@ -197,11 +197,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
// The listing is the repair, because it carries the same state the events do. Only when
|
||||
// something still looks outstanding, so the ordinary case does not pay for a second
|
||||
// listing, which is the most expensive call this screen makes.
|
||||
if (setup != null && targets.any { running.containsKey(it.id) }) {
|
||||
if (machine != null && targets.any { running.containsKey(it.id) }) {
|
||||
// Quietly: no Loading, because blanking the list to report on rows that are already
|
||||
// saying what is happening to them is the flicker this screen avoids everywhere
|
||||
// else.
|
||||
sessions = fetchInto(setup)
|
||||
sessions = fetchInto(machine)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,12 +213,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
|
||||
/** Continues [targets] in the background, leaving the screen where it is. */
|
||||
fun importAll(targets: List<Importable>) {
|
||||
val setup = chosen ?: return
|
||||
val machine = chosen ?: return
|
||||
val useProvider = provider ?: return
|
||||
handOver(targets) { ids ->
|
||||
startImport(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
machine = machine.id,
|
||||
sessionIds = ids,
|
||||
provider = useProvider.name,
|
||||
permissionMode = permissionMode,
|
||||
@@ -234,7 +234,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
* it, which is the case where waiting is the right thing anyway.
|
||||
*/
|
||||
fun importAndOpen(target: Importable) {
|
||||
val setup = chosen ?: return
|
||||
val machine = chosen ?: return
|
||||
val useProvider = provider ?: return
|
||||
running = running + (target.id to IMPORTING)
|
||||
rowErrors = rowErrors - target.id
|
||||
@@ -244,7 +244,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
withContext(Dispatchers.IO) {
|
||||
spawnSession(
|
||||
settings,
|
||||
setup = setup.id,
|
||||
machine = machine.id,
|
||||
provider = useProvider.name,
|
||||
// Nothing to say: the server titles it from the session it continues.
|
||||
title = "",
|
||||
@@ -272,10 +272,10 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
|
||||
}
|
||||
LaunchedEffect(chosen?.id) {
|
||||
val setup = chosen?.id ?: return@LaunchedEffect
|
||||
val machine = chosen?.id ?: return@LaunchedEffect
|
||||
try {
|
||||
while (true) {
|
||||
val stream = ImportableStream(settings, setup)
|
||||
val stream = ImportableStream(settings, machine)
|
||||
liveChanges.set(stream)
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -343,24 +343,24 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
when (val loaded = setups) {
|
||||
when (val loaded = machines) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded -> {
|
||||
// Only worth choosing when there is a choice.
|
||||
if (loaded.value.size > 1) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
loaded.value.forEach { setup ->
|
||||
loaded.value.forEach { machine ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
chosen = setup
|
||||
loadSessions(setup)
|
||||
chosen = machine
|
||||
loadSessions(machine)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
setup.name,
|
||||
machine.name,
|
||||
color =
|
||||
if (setup.id == chosen?.id)
|
||||
if (machine.id == chosen?.id)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -441,9 +441,9 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val setup = chosen ?: return@TextButton
|
||||
val machine = chosen ?: return@TextButton
|
||||
confirming = null
|
||||
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
|
||||
handOver(targets) { ids -> deleteImportable(settings, machine.id, ids) }
|
||||
}
|
||||
) {
|
||||
// Coloured by consequence: this takes something away, wherever it appears.
|
||||
|
||||
@@ -12,13 +12,13 @@ package com.example.aiapp
|
||||
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
|
||||
* the next listing.
|
||||
*/
|
||||
class ImportableStream(settings: ServerSettings, private val setup: String) {
|
||||
class ImportableStream(settings: ServerSettings, private val machine: String) {
|
||||
private val stream = Sse(settings)
|
||||
|
||||
fun close() = stream.close()
|
||||
|
||||
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
|
||||
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
|
||||
stream.run("/machines/$machine/importable/events", onOpen) { _, data ->
|
||||
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
|
||||
}
|
||||
}
|
||||
|
||||
+65
-35
@@ -37,19 +37,20 @@ import kotlinx.coroutines.withContext
|
||||
* which is what keeps the enrolled token from being able to introduce commands.
|
||||
*/
|
||||
@Composable
|
||||
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
fun MachinesScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var state by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
var renaming by remember { mutableStateOf<Setup?>(null) }
|
||||
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
|
||||
var renaming by remember { mutableStateOf<Machine?>(null) }
|
||||
var confirmingDelete by remember { mutableStateOf<Machine?>(null) }
|
||||
var signingIn by remember { mutableStateOf<Pair<Machine, Provider>?>(null) }
|
||||
var busy by remember { mutableStateOf<String?>(null) }
|
||||
var actionError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun reload() {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchMachines(settings)) }
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
@@ -82,19 +83,19 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
|
||||
is LoadState.Loaded ->
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
uniqueItems(current.value, key = { it.id }) { setup ->
|
||||
SetupCard(
|
||||
setup = setup,
|
||||
onRename = { renaming = setup },
|
||||
uniqueItems(current.value, key = { it.id }) { machine ->
|
||||
MachineCard(
|
||||
machine = machine,
|
||||
onRename = { renaming = machine },
|
||||
onRediscover = {
|
||||
scope.launch {
|
||||
busy = "Asking ${setup.name} what it has…"
|
||||
busy = "Asking ${machine.name} what it has…"
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
updateSetup(
|
||||
updateMachine(
|
||||
settings,
|
||||
setup.id,
|
||||
machine.id,
|
||||
rediscover = true,
|
||||
)
|
||||
}
|
||||
@@ -105,7 +106,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
reload()
|
||||
}
|
||||
},
|
||||
onDelete = { confirmingDelete = setup },
|
||||
onDelete = { confirmingDelete = machine },
|
||||
onSignIn = { provider -> signingIn = machine to provider },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +115,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
}
|
||||
|
||||
if (adding) {
|
||||
AddSetupDialog(
|
||||
AddMachineDialog(
|
||||
onDismiss = { adding = false },
|
||||
onAdd = { name, ssh ->
|
||||
adding = false
|
||||
@@ -121,7 +123,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
busy = "Asking $name what it has…"
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
|
||||
withContext(Dispatchers.IO) { addMachine(settings, name, ssh) }
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
@@ -129,13 +131,13 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
reload()
|
||||
}
|
||||
},
|
||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
|
||||
onTest = { ssh -> withContext(Dispatchers.IO) { probeMachine(settings, ssh) } },
|
||||
)
|
||||
}
|
||||
|
||||
renaming?.let { setup ->
|
||||
renaming?.let { machine ->
|
||||
RenameDialog(
|
||||
setup = setup,
|
||||
machine = machine,
|
||||
onDismiss = { renaming = null },
|
||||
onRename = { name ->
|
||||
renaming = null
|
||||
@@ -143,7 +145,7 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
updateSetup(settings, setup.id, name = name)
|
||||
updateMachine(settings, machine.id, name = name)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
@@ -154,10 +156,10 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
)
|
||||
}
|
||||
|
||||
confirmingDelete?.let { setup ->
|
||||
confirmingDelete?.let { machine ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmingDelete = null },
|
||||
title = { Text("Remove \"${setup.name}\"?") },
|
||||
title = { Text("Remove \"${machine.name}\"?") },
|
||||
text = {
|
||||
Text(
|
||||
"The machine is left alone -- this only stops this app offering it. " +
|
||||
@@ -171,7 +173,9 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
scope.launch {
|
||||
actionError =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
|
||||
withContext(Dispatchers.IO) {
|
||||
deleteMachine(settings, machine.id)
|
||||
}
|
||||
}
|
||||
.exceptionOrNull()
|
||||
?.message
|
||||
@@ -187,34 +191,60 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
signingIn?.let { (machine, provider) ->
|
||||
ProviderLoginDialog(
|
||||
settings = settings,
|
||||
machineId = machine.id,
|
||||
machineName = machine.name,
|
||||
provider = provider.name,
|
||||
onDismiss = { signingIn = null },
|
||||
onSignedIn = {
|
||||
signingIn = null
|
||||
scope.launch { reload() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupCard(
|
||||
setup: Setup,
|
||||
private fun MachineCard(
|
||||
machine: Machine,
|
||||
onRename: () -> Unit,
|
||||
onRediscover: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSignIn: (Provider) -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(setup.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(machine.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
// Not "this machine": the seeded setup is *called* that, and the card read "this
|
||||
// Not "this machine": the seeded machine is *called* that, and the card read "this
|
||||
// machine / this machine".
|
||||
setup.address ?: "runs where the backend does",
|
||||
machine.address ?: "runs where the backend does",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (machine.providers.isEmpty()) {
|
||||
Text(
|
||||
if (setup.providers.isEmpty()) {
|
||||
"Nothing found on it. Install something and rediscover."
|
||||
} else {
|
||||
setup.providers.joinToString(" · ") { it.name }
|
||||
},
|
||||
"Nothing found on it. Install something and rediscover.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
machine.providers.forEach { provider ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(provider.name, style = MaterialTheme.typography.bodySmall)
|
||||
if (provider.kind == "claude_cli") {
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = { onSignIn(provider) }) { Text("Sign in") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onRename) { Text("Rename") }
|
||||
TextButton(onClick = onRediscover) { Text("Rediscover") }
|
||||
@@ -226,7 +256,7 @@ private fun SetupCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddSetupDialog(
|
||||
private fun AddMachineDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String, SshDetails?) -> Unit,
|
||||
onTest: suspend (SshDetails?) -> List<Provider>,
|
||||
@@ -349,8 +379,8 @@ private fun AddSetupDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf(setup.name) }
|
||||
private fun RenameDialog(machine: Machine, onDismiss: () -> Unit, onRename: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf(machine.name) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename") },
|
||||
@@ -38,7 +38,7 @@ private enum class MainTab(val label: String) {
|
||||
Sessions("Sessions"),
|
||||
Import("Import"),
|
||||
Models("Models"),
|
||||
Setups("Setups"),
|
||||
Machines("Machines"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -147,7 +147,7 @@ fun MainScreen(
|
||||
MainTab.Import ->
|
||||
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
|
||||
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
|
||||
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
|
||||
MainTab.Machines -> MachinesScreen(settings = settings, reloadToken = token)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Relays a provider CLI's headless browser login without ever owning its credentials.
|
||||
*
|
||||
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
|
||||
* OAuth client and the only writer of its credential file.
|
||||
*/
|
||||
@Composable
|
||||
fun ProviderLoginDialog(
|
||||
settings: ServerSettings,
|
||||
machineId: String,
|
||||
machineName: String,
|
||||
provider: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSignedIn: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
|
||||
var code by remember(machineId, provider) { mutableStateOf("") }
|
||||
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
|
||||
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
|
||||
|
||||
suspend fun follow(initial: ProviderLogin): ProviderLogin {
|
||||
var current = initial
|
||||
val wasSubmitting = initial.state == "submitting"
|
||||
while (current.state == "starting" || current.state == "submitting") {
|
||||
delay(400)
|
||||
current =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderLogin(
|
||||
settings,
|
||||
machineId,
|
||||
provider,
|
||||
current.attempt,
|
||||
)
|
||||
}
|
||||
login = current
|
||||
}
|
||||
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
|
||||
current =
|
||||
current.copy(
|
||||
detail = "That code was not accepted. Copy the complete code and try again."
|
||||
)
|
||||
login = current
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
LaunchedEffect(machineId, provider, retry) {
|
||||
error = null
|
||||
code = ""
|
||||
login = null
|
||||
try {
|
||||
val started =
|
||||
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
|
||||
login = started
|
||||
if (follow(started).state == "succeeded") {
|
||||
onSignedIn()
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
login
|
||||
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
|
||||
?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = ::dismiss,
|
||||
title = { Text("Sign in to Claude") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Claude will sign in on $machineName. Open the authorization page, then " +
|
||||
"paste the code it gives you here."
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (val current = login) {
|
||||
null ->
|
||||
if (error == null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator()
|
||||
Text("Starting sign-in…")
|
||||
}
|
||||
}
|
||||
else ->
|
||||
when (current.state) {
|
||||
"starting",
|
||||
"submitting" ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
if (current.state == "submitting") "Checking code…"
|
||||
else "Starting sign-in…"
|
||||
)
|
||||
}
|
||||
"waitingForCode" -> {
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
current.authorizationUrl?.let(uriHandler::openUri)
|
||||
}
|
||||
.onFailure {
|
||||
error = "Couldn't open the authorization page."
|
||||
}
|
||||
},
|
||||
enabled = current.authorizationUrl != null,
|
||||
) {
|
||||
Text("Open authorization page")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
label = { Text("Authorization code") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
current.detail?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
"succeeded" -> Text("Signed in on $machineName.")
|
||||
"cancelled" -> Text("Sign-in was cancelled.")
|
||||
else ->
|
||||
Text(
|
||||
current.detail ?: "Sign-in failed.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val current = login
|
||||
when {
|
||||
current?.state == "waitingForCode" ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
error = null
|
||||
try {
|
||||
val submitted =
|
||||
withContext(Dispatchers.IO) {
|
||||
submitProviderLoginCode(
|
||||
settings,
|
||||
machineId,
|
||||
provider,
|
||||
current.attempt,
|
||||
code,
|
||||
)
|
||||
}
|
||||
login = submitted
|
||||
if (follow(submitted).state == "succeeded") {
|
||||
onSignedIn()
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = code.isNotBlank(),
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
error != null || current?.state == "failed" || current?.state == "cancelled" ->
|
||||
TextButton(onClick = { retry++ }) { Text("Try again") }
|
||||
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (login?.state != "succeeded") {
|
||||
TextButton(onClick = ::dismiss) { Text("Cancel") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -562,7 +562,7 @@ private fun SessionCard(
|
||||
// separator as the session screen's header and the usage dialog, so one
|
||||
// pair of facts is not written three ways.
|
||||
listOfNotNull(
|
||||
session.setupName,
|
||||
session.machineName,
|
||||
session.provider,
|
||||
session.model?.let { modelLabel(it) },
|
||||
)
|
||||
|
||||
@@ -1117,11 +1117,11 @@ fun SessionScreen(
|
||||
|
||||
// Only for the model picker, which a subagent does not have.
|
||||
if (!isSubagent) {
|
||||
LaunchedEffect(summary.setup, summary.provider) {
|
||||
LaunchedEffect(summary.machine, summary.provider) {
|
||||
val provider = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchSetups(settings)
|
||||
.firstOrNull { it.id == summary.setup }
|
||||
fetchMachines(settings)
|
||||
.firstOrNull { it.id == summary.machine }
|
||||
?.providers
|
||||
?.firstOrNull { it.name == summary.provider }
|
||||
}
|
||||
@@ -1133,7 +1133,7 @@ fun SessionScreen(
|
||||
?.let {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderModels(settings, summary.setup, summary.provider)
|
||||
fetchProviderModels(settings, summary.machine, summary.provider)
|
||||
}
|
||||
}
|
||||
.getOrDefault(emptyList())
|
||||
@@ -1412,7 +1412,7 @@ fun SessionScreen(
|
||||
// to, and showing it twice means two things to keep in step -- they
|
||||
// disagreed for a moment on every model change.
|
||||
Text(
|
||||
"${summary.setupName} · ${summary.provider}",
|
||||
"${summary.machineName} · ${summary.provider}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -2044,7 +2044,12 @@ fun SessionScreen(
|
||||
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
||||
if (usageOpen) {
|
||||
usageFeed?.let {
|
||||
UsageDialog(feed = it, session = summary, onDismiss = { usageOpen = false })
|
||||
UsageDialog(
|
||||
settings = settings,
|
||||
feed = it,
|
||||
session = summary,
|
||||
onDismiss = { usageOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (settingsOpen) {
|
||||
|
||||
@@ -83,7 +83,7 @@ class UsageFeed(
|
||||
return when (val state = snapshots) {
|
||||
is LoadState.Loading -> SessionUsage.Waiting
|
||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||
is LoadState.Loaded -> usageFor(state.value, session.setup, provider, session.model)
|
||||
is LoadState.Loaded -> usageFor(state.value, session.machine, provider, session.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||
// a problem about a setup somebody chose, on every screen, forever.
|
||||
// a problem about a machine somebody chose, on every screen, forever.
|
||||
//
|
||||
// And nothing while the first fetch is out, which is a different silence. A request in flight
|
||||
// is not a state to report -- and the session that meters nothing is exactly the one this
|
||||
@@ -250,7 +250,7 @@ private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
}
|
||||
|
||||
/**
|
||||
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
|
||||
* One meter's snapshot, out of every machine's: [machine]'s row for [provider].
|
||||
*
|
||||
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
|
||||
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
|
||||
@@ -262,19 +262,27 @@ private fun usageWindowLabel(window: UsageWindow, now: OffsetDateTime): String {
|
||||
*/
|
||||
fun usageFor(
|
||||
snapshots: List<UsageSnapshot>,
|
||||
setup: String,
|
||||
machine: String,
|
||||
provider: String,
|
||||
model: String?,
|
||||
): SessionUsage {
|
||||
// No snapshot at all means the backend never asked, which it only does where there is nothing
|
||||
// to ask about. That is a different answer from having asked and failed.
|
||||
val pools = usageSnapshotsFor(snapshots, setup, provider)
|
||||
val pools = usageSnapshotsFor(snapshots, machine, provider)
|
||||
if (pools.isEmpty()) return SessionUsage.NotMetered
|
||||
val mine =
|
||||
usagePoolFor(pools, model)
|
||||
?: return SessionUsage.Unavailable("couldn't tell which usage pool this session uses")
|
||||
if (mine.state != "ok") {
|
||||
return SessionUsage.Unavailable(mine.detail ?: mine.state)
|
||||
val why =
|
||||
mine.detail
|
||||
?: when (mine.state) {
|
||||
"notLoggedIn" -> "no Claude account is signed in on this machine"
|
||||
"authenticating" -> "Claude sign-in is in progress"
|
||||
"loginRequired" -> "Claude sign-in is required"
|
||||
else -> mine.state
|
||||
}
|
||||
return SessionUsage.Unavailable(why)
|
||||
}
|
||||
return SessionUsage.Known(mine.windows)
|
||||
}
|
||||
@@ -282,11 +290,11 @@ fun usageFor(
|
||||
/** Every billing pool reported for one provider on one machine. */
|
||||
internal fun usageSnapshotsFor(
|
||||
snapshots: List<UsageSnapshot>,
|
||||
setup: String,
|
||||
machine: String,
|
||||
provider: String?,
|
||||
): List<UsageSnapshot> =
|
||||
if (provider == null) emptyList()
|
||||
else snapshots.filter { it.setup == setup && it.provider == provider }
|
||||
else snapshots.filter { it.machine == machine && it.provider == provider }
|
||||
|
||||
/** The pool an explicit model names, or the provider's generic pool for every other model. */
|
||||
internal fun usagePoolFor(pools: List<UsageSnapshot>, model: String?): UsageSnapshot? {
|
||||
|
||||
@@ -48,12 +48,13 @@ fun SpawnScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
// What the form is made of, and whether we have it yet. A failure here is not the same as a
|
||||
// server with nothing to offer, so it must not reach the pickers as empty lists.
|
||||
var options by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
|
||||
var options by remember { mutableStateOf<LoadState<List<Machine>>>(LoadState.Loading) }
|
||||
|
||||
// Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the
|
||||
// provider is stored by name and resolved against the current setup rather than held as an
|
||||
// Machine first, then one of its providers. Choosing a machine can invalidate the provider, so
|
||||
// the
|
||||
// provider is stored by name and resolved against the current machine rather than held as an
|
||||
// object that could outlive the list it came from.
|
||||
var setupName by remember { mutableStateOf<String?>(null) }
|
||||
var machineName by remember { mutableStateOf<String?>(null) }
|
||||
var providerName by remember { mutableStateOf<String?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("") }
|
||||
@@ -80,7 +81,7 @@ fun SpawnScreen(
|
||||
var temperature by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
|
||||
// Separate from the machines fetch below and deliberately not fatal: failing to learn the
|
||||
// default must leave a screen you can still spawn from, so the picker stays on "default"
|
||||
// and says so rather than the whole form refusing to draw.
|
||||
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
|
||||
@@ -88,9 +89,9 @@ fun SpawnScreen(
|
||||
defaultsAsked = true
|
||||
options =
|
||||
try {
|
||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||
val fetched = withContext(Dispatchers.IO) { fetchMachines(settings) }
|
||||
val first = fetched.firstOrNull()
|
||||
setupName = first?.name
|
||||
machineName = first?.name
|
||||
providerName = first?.providers?.firstOrNull()?.name
|
||||
LoadState.Loaded(fetched)
|
||||
} catch (e: ApiException) {
|
||||
@@ -112,7 +113,7 @@ fun SpawnScreen(
|
||||
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
|
||||
// no form worth showing -- so this reports and stops, rather than offering empty pickers
|
||||
// under an error message.
|
||||
val setups =
|
||||
val machines =
|
||||
when (val state = options) {
|
||||
is LoadState.Loading -> {
|
||||
CircularProgressIndicator()
|
||||
@@ -124,19 +125,19 @@ fun SpawnScreen(
|
||||
}
|
||||
is LoadState.Loaded -> state.value
|
||||
}
|
||||
val setup = setups.firstOrNull { it.name == setupName }
|
||||
val machine = machines.firstOrNull { it.name == machineName }
|
||||
// Whichever machine is chosen now, asked again when that changes. The old machine's list
|
||||
// is dropped first rather than left on screen: a file name from another machine looks
|
||||
// exactly like one from this one.
|
||||
LaunchedEffect(setup?.id) {
|
||||
LaunchedEffect(machine?.id) {
|
||||
models = emptyList()
|
||||
modelKey = null
|
||||
val id = setup?.id ?: return@LaunchedEffect
|
||||
val id = machine?.id ?: return@LaunchedEffect
|
||||
models =
|
||||
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
|
||||
runCatching { withContext(Dispatchers.IO) { fetchMachineModels(settings, id) } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
val current = setup?.providers?.firstOrNull { it.name == providerName }
|
||||
val current = machine?.providers?.firstOrNull { it.name == providerName }
|
||||
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
|
||||
// the extra fields on the kind rather than the provider name keeps a second installation
|
||||
// from needing anything here.
|
||||
@@ -145,7 +146,7 @@ fun SpawnScreen(
|
||||
val isCodingCli = isClaude || isCodex
|
||||
val isLlama = current?.kind == "llama_cpp"
|
||||
|
||||
LaunchedEffect(setup?.id, current?.name) {
|
||||
LaunchedEffect(machine?.id, current?.name) {
|
||||
model = ""
|
||||
providerModels = emptyList()
|
||||
providerModelsError = null
|
||||
@@ -155,7 +156,7 @@ fun SpawnScreen(
|
||||
try {
|
||||
providerModels =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderModels(settings, setup.id, current.name)
|
||||
fetchProviderModels(settings, machine.id, current.name)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
providerModelsError = e.message
|
||||
@@ -169,41 +170,41 @@ fun SpawnScreen(
|
||||
|
||||
// The machine first, because it decides what can be run at all.
|
||||
ChipGroup(
|
||||
label = "Setup",
|
||||
options = setups.map { it.name },
|
||||
selected = setupName,
|
||||
label = "Machine",
|
||||
options = machines.map { it.name },
|
||||
selected = machineName,
|
||||
onSelect = { name ->
|
||||
setupName = name
|
||||
machineName = name
|
||||
// The provider list changes with the machine, so a name carried over from the
|
||||
// previous one would be a selection that isn't in the picker. Take that machine's
|
||||
// first.
|
||||
providerName =
|
||||
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||
machines.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
|
||||
},
|
||||
)
|
||||
setup?.address?.let {
|
||||
machine?.address?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// The address belongs to the setup above it, not to the provider label below; without
|
||||
// The address belongs to the machine above it, not to the provider label below; without
|
||||
// this they read as one block.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Only what this machine actually has. A setup with none says so rather than showing an
|
||||
// Only what this machine actually has. A machine with none says so rather than showing an
|
||||
// empty row that reads as a failure.
|
||||
if (setup != null && setup.providers.isEmpty()) {
|
||||
if (machine != null && machine.providers.isEmpty()) {
|
||||
Text(
|
||||
"\"${setup.name}\" has no providers configured.",
|
||||
"\"${machine.name}\" has no providers configured.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Provider",
|
||||
options = setup?.providers?.map { it.name }.orEmpty(),
|
||||
options = machine?.providers?.map { it.name }.orEmpty(),
|
||||
selected = providerName,
|
||||
onSelect = { providerName = it },
|
||||
)
|
||||
@@ -225,7 +226,7 @@ fun SpawnScreen(
|
||||
// disk is a session that cannot start.
|
||||
if (models.isEmpty()) {
|
||||
Text(
|
||||
"No models on ${setup.name}. The Models screen downloads " +
|
||||
"No models on ${machine.name}. The Models screen downloads " +
|
||||
"to the backend; another machine needs the file put there itself.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -277,7 +278,7 @@ fun SpawnScreen(
|
||||
)
|
||||
providerModels.isEmpty() ->
|
||||
Text(
|
||||
"This setup reported no selectable models.",
|
||||
"This machine reported no selectable models.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -358,8 +359,8 @@ fun SpawnScreen(
|
||||
settings,
|
||||
// The id, not the label: labels are editable and the server
|
||||
// resolves by id. Non-null here, since `chosen` came from
|
||||
// `setup`'s own provider list.
|
||||
setup = setup.id,
|
||||
// `machine`'s own provider list.
|
||||
machine = machine.id,
|
||||
provider = chosen.name,
|
||||
title = title.trim(),
|
||||
model =
|
||||
|
||||
@@ -15,6 +15,10 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -30,7 +34,13 @@ import java.time.OffsetDateTime
|
||||
* own, so the only thing its Back could ever have meant was "put this away".
|
||||
*/
|
||||
@Composable
|
||||
fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit) {
|
||||
fun UsageDialog(
|
||||
settings: ServerSettings,
|
||||
feed: UsageFeed,
|
||||
session: SessionSummary,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var signingIn by remember { mutableStateOf(false) }
|
||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
||||
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
||||
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
||||
@@ -73,12 +83,12 @@ fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit)
|
||||
LoadState.Loaded(
|
||||
usageSnapshotsFor(
|
||||
snapshots.value,
|
||||
session.setup,
|
||||
session.machine,
|
||||
session.usageProvider,
|
||||
)
|
||||
)
|
||||
}
|
||||
UsageBody(state)
|
||||
UsageBody(state, onSignIn = { signingIn = true })
|
||||
}
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||
Text("Close")
|
||||
@@ -86,11 +96,24 @@ fun UsageDialog(feed: UsageFeed, session: SessionSummary, onDismiss: () -> Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (signingIn) {
|
||||
ProviderLoginDialog(
|
||||
settings = settings,
|
||||
machineId = session.machine,
|
||||
machineName = session.machineName,
|
||||
provider = session.provider,
|
||||
onDismiss = { signingIn = false },
|
||||
onSignedIn = {
|
||||
signingIn = false
|
||||
feed.refresh()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
||||
@Composable
|
||||
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
private fun UsageBody(state: LoadState<List<UsageSnapshot>>, onSignIn: () -> Unit) {
|
||||
Column {
|
||||
when (val current = state) {
|
||||
is LoadState.Loading -> CircularProgressIndicator()
|
||||
@@ -122,7 +145,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SnapshotState(snapshot)
|
||||
SnapshotState(snapshot, onSignIn)
|
||||
snapshot.windows.forEachIndexed { windowIndex, window ->
|
||||
// Between the bars, not after the last one: a trailing gap here is what
|
||||
// put a band of empty dialog above the Close button.
|
||||
@@ -138,7 +161,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
|
||||
}
|
||||
|
||||
private fun usageSectionTitle(snapshot: UsageSnapshot): String {
|
||||
val machine = snapshot.setupName.ifEmpty { snapshot.setup }
|
||||
val machine = snapshot.machineName.ifEmpty { snapshot.machine }
|
||||
val provider = snapshot.provider
|
||||
val pool =
|
||||
if (provider == "codex" && snapshot.limitId != "codex") {
|
||||
@@ -156,15 +179,25 @@ private fun usageSectionTitle(snapshot: UsageSnapshot): String {
|
||||
*
|
||||
* The distinction the old single message could not draw. A machine nobody has logged in on is
|
||||
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
|
||||
* the interface nagging about a decision already made. Only the two faults are coloured as faults.
|
||||
* the interface nagging about a decision already made. It still offers the direct sign-in action;
|
||||
* unreachable and provider failures are the states coloured as faults.
|
||||
*/
|
||||
@Composable
|
||||
private fun SnapshotState(snapshot: UsageSnapshot) {
|
||||
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
|
||||
when (snapshot.state) {
|
||||
"ok" -> {}
|
||||
"notLoggedIn" ->
|
||||
"notLoggedIn",
|
||||
"loginRequired" -> {
|
||||
Text(
|
||||
"No Claude account on this machine.",
|
||||
snapshot.detail ?: "No Claude account on this machine.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = onSignIn) { Text("Sign in") }
|
||||
}
|
||||
"authenticating" ->
|
||||
Text(
|
||||
"Claude sign-in is in progress.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ class SessionUsageTest {
|
||||
listOf(codex, reserve),
|
||||
usageSnapshotsFor(
|
||||
listOf(claude, codex, reserve, elsewhere),
|
||||
setup = "machine",
|
||||
machine = "machine",
|
||||
provider = "codex",
|
||||
),
|
||||
)
|
||||
@@ -27,7 +27,7 @@ class SessionUsageTest {
|
||||
emptyList(),
|
||||
usageSnapshotsFor(
|
||||
listOf(snapshot("machine", "claude", null)),
|
||||
setup = "machine",
|
||||
machine = "machine",
|
||||
provider = null,
|
||||
),
|
||||
)
|
||||
@@ -62,15 +62,15 @@ class SessionUsageTest {
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
setup: String,
|
||||
machine: String,
|
||||
provider: String,
|
||||
limitId: String?,
|
||||
limitName: String? = null,
|
||||
) =
|
||||
UsageSnapshot(
|
||||
provider = provider,
|
||||
setup = setup,
|
||||
setupName = setup,
|
||||
machine = machine,
|
||||
machineName = machine,
|
||||
limitId = limitId,
|
||||
limitName = limitName,
|
||||
state = "ok",
|
||||
|
||||
@@ -120,10 +120,10 @@ TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f
|
||||
api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||
|
||||
echo "==> Importing"
|
||||
SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
MACHINE=$(api "https://127.0.0.1:$PORT/machines" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
SESSION=$(api -H 'Content-Type: application/json' -X POST \
|
||||
"https://127.0.0.1:$PORT/sessions" \
|
||||
-d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
|
||||
-d "{\"machine\":\"$MACHINE\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \
|
||||
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events"
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# emulator" is three places for the memory check that was missing from all of
|
||||
# them.
|
||||
#
|
||||
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||
# Environment machine (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||
# which can also be sourced directly for one-off commands.
|
||||
set -eu
|
||||
|
||||
|
||||
+17
-6
@@ -110,7 +110,7 @@ api) # ./ui-sandbox.sh api /path [curl args...]
|
||||
;;
|
||||
spawn) # ./ui-sandbox.sh spawn [title] -- an echo session; prints its id
|
||||
api /sessions -X POST -H 'content-type: application/json' \
|
||||
-d "{\"setup\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" |
|
||||
-d "{\"machine\":\"local\",\"provider\":\"echo\",\"title\":\"${2:-test}\"}" |
|
||||
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'
|
||||
exit 0
|
||||
;;
|
||||
@@ -150,7 +150,7 @@ if [ -f "$ROOT/config.ron" ]; then
|
||||
/^tokens: \[/ { in_tokens = 1; next }
|
||||
# The server writes the list back compactly, with the last entry
|
||||
# and the close on one line: " ),],". Reading the close only
|
||||
# at a line start ran past it into `setups`, and the salvage then
|
||||
# at a line start ran past it into `machines`, and the salvage then
|
||||
# carried a second copy of that block into the new config.
|
||||
in_tokens && /\],/ {
|
||||
sub(/\],.*/, "")
|
||||
@@ -213,13 +213,24 @@ while [ "$i" -le 8 ]; do
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# A CLI that does nothing, so importing one of these is free and safe.
|
||||
# Everything the spawn path cares about is here: it holds the fifo open,
|
||||
# records a real pid, writes nothing, and dies on a signal. A real
|
||||
# A CLI that does nothing during a session and offers one deterministic login
|
||||
# during `auth login`, so both paths are free and safe. Everything the spawn
|
||||
# path cares about is here: it holds the fifo open, records a real pid, writes
|
||||
# nothing, and dies on a signal. A real
|
||||
# `claude --resume` against an invented session id would either fail in a
|
||||
# way that tests nothing or start a turn on somebody's account.
|
||||
cat >"$ROOT/fake-claude" <<FAKE
|
||||
#!/bin/sh
|
||||
if [ "\${1:-}" = auth ] && [ "\${2:-}" = login ]; then
|
||||
echo 'https://claude.com/cai/oauth/authorize?state=ai-app-sandbox'
|
||||
while IFS= read -r code; do
|
||||
if [ "\$code" = sandbox-code ]; then
|
||||
exit 0
|
||||
fi
|
||||
echo 'Invalid code' >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
# Slow to start, on purpose. An import against this finishes in
|
||||
# milliseconds otherwise, so every state on the way -- the row marked
|
||||
# "importing", the queue behind it, the event that clears them -- is over
|
||||
@@ -346,7 +357,7 @@ tokens: [
|
||||
sha256: "$hash",
|
||||
),
|
||||
$salvaged],
|
||||
setups: [
|
||||
machines: [
|
||||
(
|
||||
id: "local",
|
||||
name: "sandbox",
|
||||
|
||||
+78
-38
@@ -28,7 +28,11 @@ pub struct Config {
|
||||
/// credential. A list (of one, today) so per-device tokens with individual
|
||||
/// revocation are a config entry later, not a migration.
|
||||
pub tokens: Vec<TokenEntry>,
|
||||
pub setups: Vec<SetupConfig>,
|
||||
/// `setups` is the persisted spelling before the machine/provider boundary
|
||||
/// was named correctly. Read it once so an update does not discard the
|
||||
/// machines already configured; every subsequent write uses `machines`.
|
||||
#[serde(alias = "setups")]
|
||||
pub machines: Vec<MachineConfig>,
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
/// What a new session's thinking level is when nothing chose one.
|
||||
///
|
||||
@@ -52,8 +56,8 @@ pub struct Config {
|
||||
/// host and offered the whole cross-product.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetupConfig {
|
||||
/// Stable identifier, minted when the setup is added and never
|
||||
pub struct MachineConfig {
|
||||
/// Stable identifier, minted when the machine is added and never
|
||||
/// changed. Sessions reference this rather than the label, so
|
||||
/// renaming a machine on the phone does not orphan its sessions --
|
||||
/// which is the whole reason the two are separate fields.
|
||||
@@ -62,19 +66,19 @@ pub struct SetupConfig {
|
||||
/// How to reach it, absent for this machine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<SshConfig>,
|
||||
/// What can be spawned here. Names are unique within a setup, and only
|
||||
/// What can be spawned here. Names are unique within a machine, and only
|
||||
/// within it: two machines may each have a `claude-cli`, which is the point.
|
||||
#[serde(default)]
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
}
|
||||
|
||||
impl SetupConfig {
|
||||
impl MachineConfig {
|
||||
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
||||
self.providers.iter().find(|provider| provider.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// One thing a setup can run: which driver, and how to invoke it.
|
||||
/// One thing a machine can run: which driver, and how to invoke it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderConfig {
|
||||
@@ -100,7 +104,7 @@ impl ProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// How to reach a setup that isn't this machine, with the system `ssh` client
|
||||
/// How to reach a machine that isn't this machine, with the system `ssh` client
|
||||
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
|
||||
/// one place to configure connections. A remote session is the identical
|
||||
/// command with `ssh host …` in front, and nothing downstream knows.
|
||||
@@ -302,12 +306,15 @@ pub struct TokenEntry {
|
||||
pub struct SessionConfig {
|
||||
/// Stable identifier; names the session's directory and its routes.
|
||||
pub id: String,
|
||||
/// Id of the [`SetupConfig`] this session runs on -- the id, not the label,
|
||||
/// Id of the [`MachineConfig`] this session runs on -- the id, not the label,
|
||||
/// so the machine can be renamed without losing its sessions.
|
||||
pub setup: String,
|
||||
/// Name of the provider within that setup. Both stored by name rather than
|
||||
/// resolved, so an edited setup takes effect on the next relaunch; a session
|
||||
/// whose setup or provider is gone reports as exited and can still be
|
||||
/// `setup` is accepted only as the on-disk migration from builds that used
|
||||
/// that word for a machine. The API and newly written records say `machine`.
|
||||
#[serde(alias = "setup")]
|
||||
pub machine: String,
|
||||
/// Name of the provider within that machine. Both stored by name rather than
|
||||
/// resolved, so an edited machine takes effect on the next relaunch; a session
|
||||
/// whose machine or provider is gone reports as exited and can still be
|
||||
/// deleted.
|
||||
pub provider: String,
|
||||
pub title: String,
|
||||
@@ -419,17 +426,17 @@ fn not_set(flag: &bool) -> bool {
|
||||
!*flag
|
||||
}
|
||||
|
||||
/// The name of the echo provider, and of the setup this machine gets on first
|
||||
/// The name of the echo provider, and of the local machine created on first
|
||||
/// run.
|
||||
///
|
||||
/// Echo is seeded into the config rather than conjured at read time. An
|
||||
/// implicit provider is one a person cannot see in the file or edit from the
|
||||
/// phone; if somebody deletes it, that was a choice.
|
||||
pub const ECHO_PROVIDER: &str = "echo";
|
||||
pub const LOCAL_SETUP: &str = "this machine";
|
||||
/// The id of the setup a fresh install seeds. Fixed rather than random so a
|
||||
pub const LOCAL_MACHINE: &str = "this machine";
|
||||
/// The id of the machine a fresh install seeds. Fixed rather than random so a
|
||||
/// hand-written config can name it without looking one up.
|
||||
pub const LOCAL_SETUP_ID: &str = "local";
|
||||
pub const LOCAL_MACHINE_ID: &str = "local";
|
||||
|
||||
/// Where `ai-server --enroll-link` leaves a token for the running server to
|
||||
/// adopt: beside the config, since it is config in transit.
|
||||
@@ -438,15 +445,15 @@ pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn setup(&self, id: &str) -> Option<&SetupConfig> {
|
||||
self.setups.iter().find(|setup| setup.id == id)
|
||||
pub fn machine(&self, id: &str) -> Option<&MachineConfig> {
|
||||
self.machines.iter().find(|machine| machine.id == id)
|
||||
}
|
||||
|
||||
/// A setup by the label a person sees, for messages and for the one place a
|
||||
/// A machine by the label a person sees, for messages and for the one place a
|
||||
/// name still arrives from outside. Nothing else should look one up this
|
||||
/// way, since labels are editable and ids are not.
|
||||
pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> {
|
||||
self.setups.iter().find(|setup| setup.name == name)
|
||||
pub fn machine_named(&self, name: &str) -> Option<&MachineConfig> {
|
||||
self.machines.iter().find(|machine| machine.name == name)
|
||||
}
|
||||
|
||||
/// This machine, offering whatever was found on it.
|
||||
@@ -455,10 +462,10 @@ impl Config {
|
||||
/// be *discovered*: a hardcoded list is a claim about what is installed, and
|
||||
/// this one was wrong -- every fresh install asserted a `claude-cli`
|
||||
/// provider whether or not `claude` existed.
|
||||
pub fn seed(providers: Vec<ProviderConfig>) -> SetupConfig {
|
||||
SetupConfig {
|
||||
id: LOCAL_SETUP_ID.to_string(),
|
||||
name: LOCAL_SETUP.to_string(),
|
||||
pub fn seed(providers: Vec<ProviderConfig>) -> MachineConfig {
|
||||
MachineConfig {
|
||||
id: LOCAL_MACHINE_ID.to_string(),
|
||||
name: LOCAL_MACHINE.to_string(),
|
||||
ssh: None,
|
||||
providers,
|
||||
}
|
||||
@@ -533,11 +540,11 @@ mod tests {
|
||||
let path = dir.path().join("config.ron");
|
||||
|
||||
// A missing file is the ordinary first-run state, not an error.
|
||||
// Nothing is conjured to fill it: the seed setup is written by the
|
||||
// Nothing is conjured to fill it: the seed machine is written by the
|
||||
// manager, so the file always says what there is.
|
||||
let first_run = Config::load(&path).expect("load");
|
||||
assert!(first_run.tokens.is_empty());
|
||||
assert!(first_run.setups.is_empty());
|
||||
assert!(first_run.machines.is_empty());
|
||||
assert!(first_run.sessions.is_empty());
|
||||
|
||||
let config = Config {
|
||||
@@ -545,7 +552,7 @@ mod tests {
|
||||
name: "phone".to_string(),
|
||||
sha256: "ab".repeat(32),
|
||||
}],
|
||||
setups: vec![
|
||||
machines: vec![
|
||||
Config::seed(vec![
|
||||
Config::echo_provider(),
|
||||
ProviderConfig {
|
||||
@@ -555,7 +562,7 @@ mod tests {
|
||||
models: Vec::new(),
|
||||
},
|
||||
]),
|
||||
SetupConfig {
|
||||
MachineConfig {
|
||||
id: "vm".to_string(),
|
||||
name: "the vm".to_string(),
|
||||
ssh: Some(SshConfig {
|
||||
@@ -577,7 +584,7 @@ mod tests {
|
||||
default_effort: Some("low".to_string()),
|
||||
sessions: vec![SessionConfig {
|
||||
id: "abc123".to_string(),
|
||||
setup: "vm".to_string(),
|
||||
machine: "vm".to_string(),
|
||||
provider: "claude-cli".to_string(),
|
||||
title: "test".to_string(),
|
||||
model: None,
|
||||
@@ -597,14 +604,14 @@ mod tests {
|
||||
|
||||
let loaded = Config::load(&path).expect("reload");
|
||||
assert_eq!(loaded.tokens[0].name, "phone");
|
||||
assert_eq!(loaded.sessions[0].setup, "vm");
|
||||
assert_eq!(loaded.sessions[0].machine, "vm");
|
||||
// The label and the id are separate, and the session holds the id.
|
||||
assert_eq!(loaded.setup("vm").expect("setup").name, "the vm");
|
||||
assert_eq!(loaded.machine("vm").expect("machine").name, "the vm");
|
||||
assert_eq!(loaded.sessions[0].provider, "claude-cli");
|
||||
assert_eq!(
|
||||
loaded
|
||||
.setup("vm")
|
||||
.expect("setup")
|
||||
.machine("vm")
|
||||
.expect("machine")
|
||||
.ssh
|
||||
.as_ref()
|
||||
.expect("ssh")
|
||||
@@ -612,15 +619,21 @@ mod tests {
|
||||
Some(2222),
|
||||
);
|
||||
// The same provider name on two machines is the point, not a
|
||||
// collision: names are unique within a setup and only within one.
|
||||
// collision: names are unique within a machine and only within one.
|
||||
assert!(
|
||||
loaded
|
||||
.setup(LOCAL_SETUP_ID)
|
||||
.machine(LOCAL_MACHINE_ID)
|
||||
.expect("local")
|
||||
.provider("claude-cli")
|
||||
.is_some()
|
||||
);
|
||||
assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none());
|
||||
assert!(
|
||||
loaded
|
||||
.machine(LOCAL_MACHINE_ID)
|
||||
.expect("local")
|
||||
.ssh
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// The house rule both halves of `format` depend on: what is written
|
||||
// is the *body* of the struct, with no outer parentheses and
|
||||
@@ -642,6 +655,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_setup_spelling_from_existing_configs_but_writes_machine_spelling() {
|
||||
let old = r#"
|
||||
setups: [(
|
||||
id: "vm",
|
||||
name: "the vm",
|
||||
providers: [],
|
||||
)],
|
||||
sessions: [(
|
||||
id: "abc123",
|
||||
setup: "vm",
|
||||
provider: "echo",
|
||||
title: "old words",
|
||||
created: 1234.5,
|
||||
)],
|
||||
"#;
|
||||
let config: Config = format::parse(old).expect("old setup spelling still loads");
|
||||
assert_eq!(config.machines[0].id, "vm");
|
||||
assert_eq!(config.sessions[0].machine, "vm");
|
||||
|
||||
let written = format::render(&config).expect("render migrated config");
|
||||
assert!(written.contains("machines:"), "{written}");
|
||||
assert!(written.contains("machine: \"vm\""), "{written}");
|
||||
assert!(!written.contains("setups:"), "{written}");
|
||||
assert!(!written.contains("setup: \"vm\""), "{written}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The seed is this machine and nothing more: a name, no ssh, and
|
||||
/// exactly the providers it was handed.
|
||||
@@ -653,7 +693,7 @@ mod tests {
|
||||
/// this can check is that the seed does not invent anything.
|
||||
fn the_seed_is_this_machine_and_claims_only_what_it_was_given() {
|
||||
let seed = Config::seed(vec![Config::echo_provider()]);
|
||||
assert_eq!(seed.name, LOCAL_SETUP);
|
||||
assert_eq!(seed.name, LOCAL_MACHINE);
|
||||
assert!(seed.ssh.is_none());
|
||||
assert_eq!(
|
||||
seed.provider(ECHO_PROVIDER).expect("echo").kind,
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
//! Reading and changing files on the machine a setup names.
|
||||
//! Reading and changing files on a configured machine.
|
||||
//!
|
||||
//! Every operation here is one small POSIX shell script handed to `Transport`,
|
||||
//! the way `setups::discover` and `import::list` already ask a machine a
|
||||
//! the way `machines::discover` and `import::list` already ask a machine a
|
||||
//! question. That is what makes the local and the ssh case one implementation:
|
||||
//! a second one written against `std::fs` would be the one that gets tested,
|
||||
//! and the remote half -- the ordering of entries, what a symlink reports, how
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! itself which of the known programs it has, and the answer becomes its
|
||||
//! providers. That is a security property, not a convenience: **no route accepts
|
||||
//! a command from the phone.** If it did, the enrolled token could introduce
|
||||
//! arbitrary programs to run on every machine a setup names.
|
||||
//! arbitrary programs to run on every machine already configured here.
|
||||
//!
|
||||
//! It is also the better interface: nobody wants to type an absolute path on a
|
||||
//! phone keyboard, and a machine that has moved its binaries answers correctly
|
||||
@@ -28,7 +28,7 @@ const PROBES: &[(&str, &str, DriverKind)] = &[
|
||||
("claude-cli", "claude", DriverKind::ClaudeCli),
|
||||
("codex-cli", "codex", DriverKind::CodexCli),
|
||||
// Named for the program rather than for where it runs: it runs
|
||||
// wherever the setup is, and "local" was true only while a llama
|
||||
// wherever the machine is, and "local" was true only while a llama
|
||||
// session could not be spawned on another machine.
|
||||
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
|
||||
];
|
||||
@@ -171,7 +171,7 @@ fn explain(err: anyhow::Error) -> anyhow::Error {
|
||||
}
|
||||
|
||||
/// A short, stable, filename-safe id derived from a label. Derived once when a
|
||||
/// setup is added and then fixed, so the label stays editable. Collisions are
|
||||
/// machine is added and then fixed, so the label stays editable. Collisions are
|
||||
/// resolved by the caller, which is the only place that knows what exists.
|
||||
pub fn id_from(label: &str) -> String {
|
||||
let slug: String = label
|
||||
@@ -233,7 +233,7 @@ pub fn shorten_home(path: &str) -> String {
|
||||
///
|
||||
/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a
|
||||
/// failure reported as the machine's own words (ssh's "Permission denied" is the
|
||||
/// useful half of why a setup cannot be reached), and the output read as text
|
||||
/// useful half of why a machine cannot be reached), and the output read as text
|
||||
/// because every caller here is asking a question whose answer is words.
|
||||
impl Transport {
|
||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||
+23
-13
@@ -14,12 +14,13 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod files;
|
||||
mod machines;
|
||||
mod media;
|
||||
mod models;
|
||||
mod provider_auth;
|
||||
mod resume;
|
||||
mod routes;
|
||||
mod session;
|
||||
mod setups;
|
||||
mod ssh;
|
||||
mod usage;
|
||||
|
||||
@@ -143,7 +144,7 @@ async fn main() -> Result<()> {
|
||||
let config_path = args
|
||||
.config
|
||||
.unwrap_or_else(|| config_home("ai-app").join("config.ron"));
|
||||
// Before the manager exists, on purpose: constructing it and seeding setups
|
||||
// Before the manager exists, on purpose: constructing it and seeding machines
|
||||
// touches sessions and subprocesses this invocation has no business with
|
||||
// while another instance is serving. Only the hash reaches disk, in the
|
||||
// spool `auth.rs` reads; the link goes to stdout alone.
|
||||
@@ -188,19 +189,19 @@ async fn main() -> Result<()> {
|
||||
// After construction rather than inside it: seeding asks this machine what
|
||||
// it has, and a constructor that quietly runs a subprocess is a surprise to
|
||||
// every caller including the tests.
|
||||
manager.seed_setup().await?;
|
||||
manager.seed_machine().await?;
|
||||
|
||||
tracing::info!("config: {}", config_path.display());
|
||||
tracing::info!("models: {}", models_dir.display());
|
||||
for setup in manager.setups() {
|
||||
match &setup.ssh {
|
||||
Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address),
|
||||
// No parenthetical naming the local machine: the default setup is
|
||||
// *called* "this machine", and the line read "setup this machine
|
||||
// (this machine)".
|
||||
None => tracing::info!(" setup \"{}\" runs here", setup.name),
|
||||
for machine in manager.machines() {
|
||||
match &machine.ssh {
|
||||
Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
|
||||
// No parenthetical naming the local machine: the default machine is
|
||||
// *called* "this machine", so repeating a local qualifier read
|
||||
// like a stutter.
|
||||
None => tracing::info!(" machine \"{}\" runs here", machine.name),
|
||||
}
|
||||
for provider in &setup.providers {
|
||||
for provider in &machine.providers {
|
||||
tracing::info!(" provider {} ({:?})", provider.name, provider.kind);
|
||||
}
|
||||
}
|
||||
@@ -263,11 +264,12 @@ async fn main() -> Result<()> {
|
||||
.context("failed to load TLS cert/key")?;
|
||||
|
||||
// No providers listed here any more: which machines can be asked, and about
|
||||
// what, comes from the setups at the moment the screen is opened -- so a
|
||||
// what, comes from the machines at the moment the screen is opened -- so a
|
||||
// machine added from the phone reports its limits without a restart.
|
||||
// The fixture is the manager's, because that is where the `/usage` command
|
||||
// that sets it is typed; the monitor is what serves it.
|
||||
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
|
||||
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
|
||||
|
||||
// The one thing in here that acts without a request behind it: a session
|
||||
// switched to auto-resume waits out its account's usage limit and picks
|
||||
@@ -279,7 +281,14 @@ async fn main() -> Result<()> {
|
||||
// The bearer-token middleware wraps the entire router -- routes and fallback
|
||||
// alike -- here and only here, so a new route can't forget auth.
|
||||
let app = routes::router(Arc::clone(&manager))
|
||||
.merge(routes::usage_router(monitor, Arc::clone(&manager)))
|
||||
.merge(routes::usage_router(
|
||||
Arc::clone(&monitor),
|
||||
Arc::clone(&manager),
|
||||
))
|
||||
.merge(routes::provider_auth_router(
|
||||
Arc::clone(&provider_logins),
|
||||
Arc::clone(&manager),
|
||||
))
|
||||
.merge(routes::models_router(Arc::clone(&models)))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
Arc::clone(&manager),
|
||||
@@ -322,6 +331,7 @@ async fn main() -> Result<()> {
|
||||
// above: a throwaway session is one nobody meant to keep, and the whole point
|
||||
// is that nothing has to remember to clean it up.
|
||||
manager.stop_throwaway_sessions();
|
||||
provider_logins.cancel_all();
|
||||
manager.detach_all();
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -523,13 +523,13 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a machine reached over ssh keeps its models, when its setup does
|
||||
/// Where a machine reached over ssh keeps its models, when its machine does
|
||||
/// not say.
|
||||
///
|
||||
/// The same place this backend puts its own downloads, written out rather
|
||||
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
|
||||
/// environment, and the far machine's is the far machine's business. A
|
||||
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
|
||||
/// machine whose models are elsewhere says so (`SshConfig::models_dir`).
|
||||
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
|
||||
|
||||
/// Which directory holds the models on the machine `transport` reaches.
|
||||
@@ -550,11 +550,11 @@ pub fn dir_on(transport: &Transport, local: &Path) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every GGUF on the machine a setup names, which is the machine that
|
||||
/// Every GGUF on a configured machine, which is the machine that
|
||||
/// would have to serve it.
|
||||
///
|
||||
/// The local half of this is [`ModelStore::list`], reading the same shape
|
||||
/// off this machine's disk; a caller picks by transport, since a setup
|
||||
/// off this machine's disk; a caller picks by transport, since a machine
|
||||
/// with no ssh *is* this machine and asking a shell about it would be a
|
||||
/// slower way to the same answer. What must not happen is offering this
|
||||
/// backend's downloads for a session on another machine: the file has to
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Interactive provider login carried between a CLI on a configured machine
|
||||
//! and the phone. The CLI remains the only credential writer: this layer keeps
|
||||
//! its short-lived process and relays only the authorization URL and the code
|
||||
//! a person copies back from the browser.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rand::Rng;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{MachineConfig, ProviderConfig};
|
||||
use crate::session::transport::Transport;
|
||||
use crate::usage::UsageMonitor;
|
||||
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const AUTHORIZATION_URL_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const OUTPUT_POLL: Duration = Duration::from_millis(50);
|
||||
const START_WAIT: Duration = Duration::from_secs(16);
|
||||
|
||||
type Key = (String, String);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum LoginState {
|
||||
Starting,
|
||||
WaitingForCode {
|
||||
#[serde(rename = "authorizationUrl")]
|
||||
authorization_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
},
|
||||
Submitting,
|
||||
Succeeded,
|
||||
Failed {
|
||||
detail: String,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl LoginState {
|
||||
fn terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded | Self::Failed { .. } | Self::Cancelled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginInfo {
|
||||
pub attempt: String,
|
||||
#[serde(flatten)]
|
||||
pub state: LoginState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Attempt {
|
||||
id: String,
|
||||
state: Arc<Mutex<LoginState>>,
|
||||
input: mpsc::Sender<Input>,
|
||||
}
|
||||
|
||||
impl Attempt {
|
||||
fn info(&self) -> LoginInfo {
|
||||
LoginInfo {
|
||||
attempt: self.id.clone(),
|
||||
state: self.state.lock().unwrap().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Input {
|
||||
Code(String),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The active login per machine and provider. Completed attempts stay until a
|
||||
/// new one replaces them, so a phone that briefly loses its connection can ask
|
||||
/// how the operation ended rather than being handed an ambiguous 404.
|
||||
pub struct LoginManager {
|
||||
attempts: Mutex<HashMap<Key, Attempt>>,
|
||||
usage: Arc<UsageMonitor>,
|
||||
}
|
||||
|
||||
impl LoginManager {
|
||||
pub fn new(usage: Arc<UsageMonitor>) -> Self {
|
||||
Self {
|
||||
attempts: Mutex::new(HashMap::new()),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self, machine: MachineConfig, provider: ProviderConfig) -> LoginInfo {
|
||||
let provider_key = provider
|
||||
.kind
|
||||
.usage_provider()
|
||||
.expect("a login route only accepts a metered provider")
|
||||
.to_string();
|
||||
let key = (machine.id.clone(), provider_key);
|
||||
let mut attempts = self.attempts.lock().unwrap();
|
||||
if let Some(attempt) = attempts.get(&key)
|
||||
&& !attempt.state.lock().unwrap().terminal()
|
||||
{
|
||||
return attempt.info();
|
||||
}
|
||||
|
||||
let id = attempt_id();
|
||||
let state = Arc::new(Mutex::new(LoginState::Starting));
|
||||
let (input, commands) = mpsc::channel();
|
||||
let attempt = Attempt {
|
||||
id: id.clone(),
|
||||
state: Arc::clone(&state),
|
||||
input,
|
||||
};
|
||||
attempts.insert(key, attempt.clone());
|
||||
drop(attempts);
|
||||
|
||||
// Seed the cache before the worker takes the gate. A usage request in
|
||||
// that small handoff window sees a fresh, truthful state and cannot
|
||||
// start a second CLI against the same credential.
|
||||
let gate = self.usage.claude_authentication_started(&machine);
|
||||
let usage = Arc::clone(&self.usage);
|
||||
std::thread::spawn(move || {
|
||||
let _guard = gate.lock().unwrap();
|
||||
run_login(&machine, &provider, commands, &state);
|
||||
usage.claude_authentication_finished(&machine.id);
|
||||
});
|
||||
|
||||
attempt.info()
|
||||
}
|
||||
|
||||
pub fn wait_until_ready(&self, machine: &str, provider: &str, attempt: &str) -> LoginInfo {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
let info = self.read(machine, provider, attempt).unwrap_or(LoginInfo {
|
||||
attempt: attempt.to_string(),
|
||||
state: LoginState::Failed {
|
||||
detail: "the sign-in attempt disappeared".to_string(),
|
||||
},
|
||||
});
|
||||
if !matches!(info.state, LoginState::Starting) || started.elapsed() >= START_WAIT {
|
||||
return info;
|
||||
}
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, machine: &str, provider: &str, attempt: &str) -> Option<LoginInfo> {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts.get(&(machine.to_string(), provider.to_string()))?;
|
||||
(found.id == attempt).then(|| found.info())
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&self,
|
||||
machine: &str,
|
||||
provider: &str,
|
||||
attempt: &str,
|
||||
code: &str,
|
||||
) -> Result<LoginInfo> {
|
||||
let code = valid_code(code)?;
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts
|
||||
.get(&(machine.to_string(), provider.to_string()))
|
||||
.filter(|found| found.id == attempt)
|
||||
.context("no such sign-in attempt")?;
|
||||
if found.state.lock().unwrap().terminal() {
|
||||
return Ok(found.info());
|
||||
}
|
||||
*found.state.lock().unwrap() = LoginState::Submitting;
|
||||
if found.input.send(Input::Code(code.to_string())).is_err() {
|
||||
*found.state.lock().unwrap() = LoginState::Failed {
|
||||
detail: "the sign-in process has stopped".to_string(),
|
||||
};
|
||||
anyhow::bail!("the sign-in process has stopped");
|
||||
}
|
||||
Ok(found.info())
|
||||
}
|
||||
|
||||
pub fn cancel(&self, machine: &str, provider: &str, attempt: &str) -> Result<LoginInfo> {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
let found = attempts
|
||||
.get(&(machine.to_string(), provider.to_string()))
|
||||
.filter(|found| found.id == attempt)
|
||||
.context("no such sign-in attempt")?;
|
||||
if !found.state.lock().unwrap().terminal() {
|
||||
let _ = found.input.send(Input::Cancel);
|
||||
}
|
||||
Ok(found.info())
|
||||
}
|
||||
|
||||
/// Interactive helpers are unlike sessions: nothing adopts them after a
|
||||
/// server restart. End every one while the process is still here to reap
|
||||
/// the child it launched.
|
||||
pub fn cancel_all(&self) {
|
||||
let attempts = self.attempts.lock().unwrap();
|
||||
for attempt in attempts.values() {
|
||||
if !attempt.state.lock().unwrap().terminal() {
|
||||
let _ = attempt.input.send(Input::Cancel);
|
||||
}
|
||||
}
|
||||
let pending: Vec<_> = attempts
|
||||
.values()
|
||||
.map(|attempt| Arc::clone(&attempt.state))
|
||||
.collect();
|
||||
drop(attempts);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline
|
||||
&& pending
|
||||
.iter()
|
||||
.any(|state| !state.lock().unwrap().terminal())
|
||||
{
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_login(
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
commands: mpsc::Receiver<Input>,
|
||||
state: &Arc<Mutex<LoginState>>,
|
||||
) {
|
||||
if let Err(err) = run_login_inner(machine, provider, commands, state) {
|
||||
*state.lock().unwrap() = LoginState::Failed {
|
||||
detail: format!("couldn't sign in to Claude on {}: {err:#}", machine.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn run_login_inner(
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
commands: mpsc::Receiver<Input>,
|
||||
state: &Arc<Mutex<LoginState>>,
|
||||
) -> Result<()> {
|
||||
let transport = Transport::for_machine(machine);
|
||||
let args = vec![
|
||||
"BROWSER=/bin/false".to_string(),
|
||||
provider.program().to_string(),
|
||||
"auth".to_string(),
|
||||
"login".to_string(),
|
||||
"--claudeai".to_string(),
|
||||
];
|
||||
let host = match &transport {
|
||||
Transport::Here => None,
|
||||
Transport::Ssh { ssh, .. } => Some(ssh),
|
||||
};
|
||||
let mut command = crate::ssh::command(host, "env", &args, None, None);
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("couldn't run {} auth login", provider.program()))?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("the login process has no stdin")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("the login process has no stdout")?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.context("the login process has no stderr")?;
|
||||
let (output, lines) = mpsc::channel();
|
||||
read_lines(stdout, output.clone());
|
||||
read_lines(stderr, output);
|
||||
|
||||
let started = Instant::now();
|
||||
let mut authorization_url = None;
|
||||
let mut last_line = None;
|
||||
loop {
|
||||
while let Ok(line) = lines.try_recv() {
|
||||
if let Some(url) = authorization_url_in(&line) {
|
||||
authorization_url = Some(url.to_string());
|
||||
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||
authorization_url: url.to_string(),
|
||||
detail: None,
|
||||
};
|
||||
} else if line.to_ascii_lowercase().contains("invalid code") {
|
||||
if let Some(url) = &authorization_url {
|
||||
*state.lock().unwrap() = LoginState::WaitingForCode {
|
||||
authorization_url: url.clone(),
|
||||
detail: Some(
|
||||
"That code was not accepted. Copy the complete code and try again."
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if !line.trim().is_empty() {
|
||||
last_line = Some(line.trim().chars().take(500).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
match commands.recv_timeout(OUTPUT_POLL) {
|
||||
Ok(Input::Code(code)) => {
|
||||
*state.lock().unwrap() = LoginState::Submitting;
|
||||
writeln!(stdin, "{code}").context("couldn't send the login code")?;
|
||||
stdin.flush().context("couldn't send the login code")?;
|
||||
}
|
||||
Ok(Input::Cancel) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
*state.lock().unwrap() = LoginState::Cancelled;
|
||||
return Ok(());
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the phone disconnected from the sign-in attempt");
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.context("couldn't check the login process")?
|
||||
{
|
||||
*state.lock().unwrap() = if status.success() {
|
||||
LoginState::Succeeded
|
||||
} else {
|
||||
LoginState::Failed {
|
||||
detail: last_line
|
||||
.unwrap_or_else(|| format!("Claude's login process exited with {status}")),
|
||||
}
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
if authorization_url.is_none() && started.elapsed() >= AUTHORIZATION_URL_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the Claude CLI did not provide an authorization URL");
|
||||
}
|
||||
if started.elapsed() >= LOGIN_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("the sign-in attempt expired; start it again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lines(reader: impl Read + Send + 'static, output: mpsc::Sender<String>) {
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(reader).lines().map_while(Result::ok) {
|
||||
let _ = output.send(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn authorization_url_in(line: &str) -> Option<&str> {
|
||||
let start = line.find("https://")?;
|
||||
let tail = &line[start..];
|
||||
let end = tail
|
||||
.find(|character: char| character.is_whitespace() || character == '\u{1b}')
|
||||
.unwrap_or(tail.len());
|
||||
let url = &tail[..end];
|
||||
(url.starts_with("https://claude.com/") || url.starts_with("https://platform.claude.com/"))
|
||||
.then_some(url)
|
||||
}
|
||||
|
||||
fn valid_code(code: &str) -> Result<&str> {
|
||||
let code = code.trim();
|
||||
anyhow::ensure!(!code.is_empty(), "the login code is empty");
|
||||
anyhow::ensure!(code.len() <= 4096, "the login code is too long");
|
||||
anyhow::ensure!(
|
||||
!code.chars().any(char::is_control),
|
||||
"the login code contains a line break or control character"
|
||||
);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn attempt_id() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::DriverKind;
|
||||
|
||||
#[test]
|
||||
fn extracts_only_anthropics_https_login_url() {
|
||||
assert_eq!(
|
||||
authorization_url_in("visit: https://claude.com/cai/oauth/authorize?state=x"),
|
||||
Some("https://claude.com/cai/oauth/authorize?state=x")
|
||||
);
|
||||
assert!(authorization_url_in("visit: http://claude.com/nope").is_none());
|
||||
assert!(authorization_url_in("visit: https://example.com/nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_code_is_one_bounded_line() {
|
||||
assert_eq!(valid_code(" abc#state ").unwrap(), "abc#state");
|
||||
assert!(valid_code("\n").is_err());
|
||||
assert!(valid_code("a\nb").is_err());
|
||||
assert!(valid_code(&"x".repeat(4097)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn relays_a_headless_cli_login_without_taking_over_its_credentials() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cli = dir.path().join("fake-claude");
|
||||
std::fs::write(
|
||||
&cli,
|
||||
"#!/bin/sh\necho 'https://claude.com/cai/oauth/authorize?state=test'\nIFS= read -r code\n[ \"$code\" = 'the-code' ]\n",
|
||||
)
|
||||
.expect("write fake CLI");
|
||||
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700))
|
||||
.expect("make fake CLI executable");
|
||||
|
||||
let monitor = Arc::new(UsageMonitor::new(Default::default()));
|
||||
let logins = LoginManager::new(monitor);
|
||||
let machine = MachineConfig {
|
||||
id: "vm".to_string(),
|
||||
name: "test vm".to_string(),
|
||||
ssh: None,
|
||||
providers: Vec::new(),
|
||||
};
|
||||
let provider = ProviderConfig {
|
||||
name: "claude-cli".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
command: Some(cli.display().to_string()),
|
||||
models: Vec::new(),
|
||||
};
|
||||
|
||||
let started = logins.start(machine, provider);
|
||||
let ready = logins.wait_until_ready("vm", "claude", &started.attempt);
|
||||
assert!(matches!(ready.state, LoginState::WaitingForCode { .. }));
|
||||
let wire = serde_json::to_value(&ready).expect("serialize login state");
|
||||
assert!(wire.get("authorizationUrl").is_some(), "{wire}");
|
||||
assert!(wire.get("authorization_url").is_none(), "{wire}");
|
||||
let submitted = logins
|
||||
.submit("vm", "claude", &started.attempt, "the-code")
|
||||
.expect("submit code");
|
||||
assert!(matches!(submitted.state, LoginState::Submitting));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let finished = logins
|
||||
.read("vm", "claude", &started.attempt)
|
||||
.expect("attempt remains readable");
|
||||
if matches!(finished.state, LoginState::Succeeded) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"login did not finish: {finished:?}"
|
||||
);
|
||||
std::thread::sleep(OUTPUT_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -48,7 +48,7 @@ const AT_LEAST: f64 = 60.0;
|
||||
/// How long after the limit was hit to stop waiting.
|
||||
///
|
||||
/// Something has to bound it, or a machine that can never be asked -- an
|
||||
/// unplugged laptop, a setup somebody edited away -- is retried for ever with
|
||||
/// unplugged laptop, a machine somebody edited away -- is retried for ever with
|
||||
/// nothing on screen saying so. A day is past the longest window Claude
|
||||
/// reports, so reaching this means the wait was never going to end on its own.
|
||||
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
|
||||
@@ -123,7 +123,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
|
||||
Step::Send => match manager.resume_now(&owed.session_id) {
|
||||
Ok(message) => tracing::info!(
|
||||
"the limit on {} has lifted; sent \"{message}\" to {}",
|
||||
owed.setup,
|
||||
owed.machine,
|
||||
owed.session_id
|
||||
),
|
||||
Err(err) => {
|
||||
@@ -144,7 +144,7 @@ async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
|
||||
// to read on a phone.
|
||||
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
|
||||
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
|
||||
_ => format!("{} could not be asked for a day", owed.setup),
|
||||
_ => format!("{} could not be asked for a day", owed.machine),
|
||||
};
|
||||
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
|
||||
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
|
||||
@@ -164,18 +164,18 @@ async fn snapshot_for(
|
||||
manager: &SessionManager,
|
||||
owed: &OwedResume,
|
||||
) -> Option<UsageSnapshot> {
|
||||
let setups: Vec<_> = manager
|
||||
.setups()
|
||||
let machines: Vec<_> = manager
|
||||
.machines()
|
||||
.into_iter()
|
||||
.filter(|setup| setup.id == owed.setup)
|
||||
.filter(|machine| machine.id == owed.machine)
|
||||
.collect();
|
||||
if setups.is_empty() {
|
||||
if machines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let provider = owed.provider;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
monitor
|
||||
.snapshots(&setups)
|
||||
.snapshots(&machines)
|
||||
.into_iter()
|
||||
.find(|snapshot| snapshot.provider == provider)
|
||||
})
|
||||
@@ -258,7 +258,7 @@ mod tests {
|
||||
fn owed(since: f64) -> OwedResume {
|
||||
OwedResume {
|
||||
session_id: "s1".to_string(),
|
||||
setup: "local".to_string(),
|
||||
machine: "local".to_string(),
|
||||
provider: crate::usage::CLAUDE,
|
||||
scheduled: ScheduledResume { at: since, since },
|
||||
}
|
||||
@@ -267,8 +267,8 @@ mod tests {
|
||||
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
provider: crate::usage::CLAUDE.to_string(),
|
||||
setup: "local".to_string(),
|
||||
setup_name: "this machine".to_string(),
|
||||
machine: "local".to_string(),
|
||||
machine_name: "this machine".to_string(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
|
||||
+249
-124
@@ -3,27 +3,31 @@
|
||||
//! wraps the whole router in.
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /setups machines, each with what it can run
|
||||
//! POST /setups add {name, ssh?} -- providers are discovered
|
||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||
//! GET /setups/{id} one machine, for refetching after a change
|
||||
//! GET /setups/{id}/models GGUFs on that machine, for a llama session
|
||||
//! GET /setups/{id}/providers/{provider}/models models a CLI currently offers
|
||||
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
||||
//! GET /setups/{id}/file?path=P content of file P, or why not
|
||||
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
||||
//! GET /machines machines, each with what it can run
|
||||
//! POST /machines add {name, ssh?} -- providers are discovered
|
||||
//! POST /machines/probe dry run {ssh?}: what would be found there
|
||||
//! GET /machines/{id} one machine, for refetching after a change
|
||||
//! GET /machines/{id}/models GGUFs on that machine, for a llama session
|
||||
//! GET /machines/{id}/providers/{provider}/models models a CLI currently offers
|
||||
//! POST /machines/{id}/providers/{provider}/auth begin provider sign-in
|
||||
//! GET /machines/{id}/providers/{provider}/auth/{attempt} sign-in state
|
||||
//! POST /machines/{id}/providers/{provider}/auth/{attempt}/code submit browser code
|
||||
//! DELETE /machines/{id}/providers/{provider}/auth/{attempt} cancel sign-in
|
||||
//! GET /machines/{id}/dir?path=P entries of directory P, and P resolved
|
||||
//! GET /machines/{id}/file?path=P content of file P, or why not
|
||||
//! PUT /machines/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
||||
//! (409 when the file no longer matches ifSha256)
|
||||
//! POST /setups/{id}/file {path} create empty; refused if it exists
|
||||
//! POST /setups/{id}/dir {path} create; refused if it exists
|
||||
//! GET /setups/{id}/importable Claude Code sessions on it that could be continued
|
||||
//! POST /setups/{id}/importable/import {sessions} -> 202; runs on the server
|
||||
//! POST /setups/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
|
||||
//! GET /setups/{id}/importable/events SSE: what is in flight against them
|
||||
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
|
||||
//! DELETE /setups/{id} remove, refused while sessions use it
|
||||
//! POST /machines/{id}/file {path} create empty; refused if it exists
|
||||
//! POST /machines/{id}/dir {path} create; refused if it exists
|
||||
//! GET /machines/{id}/importable Claude Code sessions on it that could be continued
|
||||
//! POST /machines/{id}/importable/import {sessions} -> 202; runs on the server
|
||||
//! POST /machines/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts
|
||||
//! GET /machines/{id}/importable/events SSE: what is in flight against them
|
||||
//! PUT /machines/{id} rename {name?} and/or re-probe {rediscover?}
|
||||
//! DELETE /machines/{id} remove, refused while sessions use it
|
||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||
//! GET /sessions/{id} one session, for refetching after a change
|
||||
//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?}
|
||||
//! POST /sessions spawn {machine, provider, title?, model?, cwd?, params?}
|
||||
//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live
|
||||
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
||||
//! `reset` frame plus the newest window)
|
||||
@@ -114,30 +118,30 @@ use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
|
||||
|
||||
pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
Router::new()
|
||||
.route("/setups", get(list_setups).post(add_setup))
|
||||
.route("/setups/probe", post(probe_setup))
|
||||
.route("/setups/{id}/importable", get(list_importable))
|
||||
.route("/machines", get(list_machines).post(add_machine))
|
||||
.route("/machines/probe", post(probe_machine))
|
||||
.route("/machines/{id}/importable", get(list_importable))
|
||||
// A batch at a time, never a session at a time -- see
|
||||
// [`delete_importable`].
|
||||
.route("/setups/{id}/importable/delete", post(delete_importable))
|
||||
.route("/setups/{id}/importable/import", post(start_import))
|
||||
.route("/setups/{id}/importable/events", get(importable_events))
|
||||
.route("/machines/{id}/importable/delete", post(delete_importable))
|
||||
.route("/machines/{id}/importable/import", post(start_import))
|
||||
.route("/machines/{id}/importable/events", get(importable_events))
|
||||
.route(
|
||||
"/setups/{id}",
|
||||
get(read_setup).put(update_setup).delete(delete_setup),
|
||||
"/machines/{id}",
|
||||
get(read_machine).put(update_machine).delete(delete_machine),
|
||||
)
|
||||
// The models on the machine a setup names, for a llama session there.
|
||||
.route("/setups/{id}/models", get(setup_models))
|
||||
// The models on a configured machine, for a llama session there.
|
||||
.route("/machines/{id}/models", get(machine_models))
|
||||
.route(
|
||||
"/setups/{id}/providers/{provider}/models",
|
||||
"/machines/{id}/providers/{provider}/models",
|
||||
get(provider_models),
|
||||
)
|
||||
// The filesystem of the machine a setup names. Under the setup
|
||||
// The filesystem of a configured machine. Under the machine
|
||||
// rather than under a session because a filesystem is a property of
|
||||
// a machine; a session only says where to start looking.
|
||||
.route("/setups/{id}/dir", get(list_dir).post(create_dir))
|
||||
.route("/machines/{id}/dir", get(list_dir).post(create_dir))
|
||||
.route(
|
||||
"/setups/{id}/file",
|
||||
"/machines/{id}/file",
|
||||
get(read_file).put(write_file).post(create_file),
|
||||
)
|
||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||
@@ -255,7 +259,7 @@ async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json
|
||||
/// opened from a row carries that snapshot with it. Fine for what a row
|
||||
/// *says* and wrong for what a control is *set to*: a switch drawn from a
|
||||
/// stale row shows the position it had when the list was fetched, and the
|
||||
/// person reading it cannot tell. Same reason `GET /setups/{id}` exists.
|
||||
/// person reading it cannot tell. Same reason `GET /machines/{id}` exists.
|
||||
async fn read_session(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
@@ -269,7 +273,7 @@ async fn read_session(
|
||||
}
|
||||
|
||||
/// What the spawn screen needs to render itself, so the phone holds no
|
||||
/// hardcoded list: a setup added to `config.ron` shows up with no app
|
||||
/// hardcoded list: a machine added to `config.ron` shows up with no app
|
||||
/// rebuild.
|
||||
///
|
||||
/// One list rather than two, because the halves are not independent. A
|
||||
@@ -278,12 +282,12 @@ async fn read_session(
|
||||
/// the box that hasn't got it".
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SetupInfo {
|
||||
struct MachineInfo {
|
||||
/// Stable; what a session stores and what these routes address.
|
||||
id: String,
|
||||
/// The editable label.
|
||||
name: String,
|
||||
/// Where it runs, for telling two setups apart. Absent for this machine.
|
||||
/// Where it runs, for telling two machines apart. Absent for this machine.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
address: Option<String>,
|
||||
providers: Vec<ProviderInfo>,
|
||||
@@ -300,16 +304,16 @@ struct ProviderInfo {
|
||||
default_permission_mode: Option<&'static str>,
|
||||
}
|
||||
|
||||
async fn list_setups(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SetupInfo>> {
|
||||
axum::Json(manager.setups().into_iter().map(info_for).collect())
|
||||
async fn list_machines(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<MachineInfo>> {
|
||||
axum::Json(manager.machines().into_iter().map(info_for).collect())
|
||||
}
|
||||
|
||||
fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
|
||||
SetupInfo {
|
||||
id: setup.id,
|
||||
name: setup.name,
|
||||
address: setup.ssh.map(|ssh| ssh.address),
|
||||
providers: setup
|
||||
fn info_for(machine: crate::config::MachineConfig) -> MachineInfo {
|
||||
MachineInfo {
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
address: machine.ssh.map(|ssh| ssh.address),
|
||||
providers: machine
|
||||
.providers
|
||||
.into_iter()
|
||||
.map(|provider| ProviderInfo {
|
||||
@@ -326,7 +330,7 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
|
||||
/// How to reach a machine, as the phone describes it.
|
||||
///
|
||||
/// Note what is absent: nothing here names a program. Providers are found by
|
||||
/// asking the machine (`crate::setups`), never sent, so the enrolled token
|
||||
/// asking the machine (`crate::machines`), never sent, so the enrolled token
|
||||
/// cannot introduce something to run.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -353,7 +357,7 @@ impl SshRequest {
|
||||
/// Tidied at the boundary rather than stored as typed -- this came from a
|
||||
/// phone keyboard, so it may have a stray space or a `~`.
|
||||
fn into_config(self) -> Result<crate::config::SshConfig, ApiError> {
|
||||
let address = crate::setups::tidy(&self.address)
|
||||
let address = crate::machines::tidy(&self.address)
|
||||
.ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?;
|
||||
Ok(crate::config::SshConfig {
|
||||
address,
|
||||
@@ -361,12 +365,12 @@ impl SshRequest {
|
||||
identity_file: self
|
||||
.identity_file
|
||||
.as_deref()
|
||||
.and_then(crate::setups::tidy)
|
||||
.and_then(crate::machines::tidy)
|
||||
.map(std::path::PathBuf::from),
|
||||
options: self
|
||||
.options
|
||||
.iter()
|
||||
.filter_map(|o| crate::setups::tidy(o))
|
||||
.filter_map(|o| crate::machines::tidy(o))
|
||||
.collect(),
|
||||
// Not `tidy`: that expands `~` to *this* machine's home, and this
|
||||
// path is on the other one. The remote shell expands it there.
|
||||
@@ -391,7 +395,7 @@ impl SshRequest {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AddSetupRequest {
|
||||
struct AddMachineRequest {
|
||||
name: String,
|
||||
/// Absent means this machine.
|
||||
#[serde(default)]
|
||||
@@ -410,11 +414,11 @@ struct ProbeRequest {
|
||||
ssh: Option<SshRequest>,
|
||||
}
|
||||
|
||||
async fn probe_setup(
|
||||
async fn probe_machine(
|
||||
axum::Json(body): axum::Json<ProbeRequest>,
|
||||
) -> Result<axum::Json<Vec<ProviderInfo>>, ApiError> {
|
||||
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
|
||||
let providers = probe(ssh, "this setup").await?;
|
||||
let providers = probe(ssh, "this machine").await?;
|
||||
Ok(axum::Json(
|
||||
providers
|
||||
.into_iter()
|
||||
@@ -443,50 +447,50 @@ async fn probe(
|
||||
},
|
||||
None => crate::session::transport::Transport::Here,
|
||||
};
|
||||
crate::setups::discover(&transport)
|
||||
crate::machines::discover(&transport)
|
||||
.await
|
||||
.map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn add_setup(
|
||||
async fn add_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
axum::Json(body): axum::Json<AddSetupRequest>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
axum::Json(body): axum::Json<AddMachineRequest>,
|
||||
) -> Result<axum::Json<MachineInfo>, ApiError> {
|
||||
let ssh = body.ssh.map(SshRequest::into_config).transpose()?;
|
||||
// Ask the machine being added what it has, before writing anything, so a
|
||||
// bad address fails here rather than leaving a setup that can never
|
||||
// bad address fails here rather than leaving a machine that can never
|
||||
// spawn.
|
||||
let providers = probe(ssh.clone(), &body.name).await?;
|
||||
let setup = manager
|
||||
.add_setup(&body.name, ssh, providers)
|
||||
let machine = manager
|
||||
.add_machine(&body.name, ssh, providers)
|
||||
.map_err(bad_request)?;
|
||||
Ok(axum::Json(info_for(setup)))
|
||||
Ok(axum::Json(info_for(machine)))
|
||||
}
|
||||
|
||||
/// One setup by id, or the 404 that says so. Three handlers ask this same
|
||||
/// One machine by id, or the 404 that says so. Three handlers ask this same
|
||||
/// question; the answer, and the wording of the refusal, belong in one place.
|
||||
fn setup_by_id(
|
||||
fn machine_by_id(
|
||||
manager: &Arc<SessionManager>,
|
||||
id: &str,
|
||||
) -> Result<crate::config::SetupConfig, ApiError> {
|
||||
) -> Result<crate::config::MachineConfig, ApiError> {
|
||||
manager
|
||||
.setups()
|
||||
.machines()
|
||||
.into_iter()
|
||||
.find(|setup| setup.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))
|
||||
.find(|machine| machine.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no machine {id}")))
|
||||
}
|
||||
|
||||
async fn read_setup(
|
||||
async fn read_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
setup_by_id(&manager, &id).map(|setup| axum::Json(info_for(setup)))
|
||||
) -> Result<axum::Json<MachineInfo>, ApiError> {
|
||||
machine_by_id(&manager, &id).map(|machine| axum::Json(info_for(machine)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UpdateSetupRequest {
|
||||
struct UpdateMachineRequest {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// Ask the machine again what it has -- after installing something
|
||||
@@ -495,37 +499,37 @@ struct UpdateSetupRequest {
|
||||
rediscover: bool,
|
||||
}
|
||||
|
||||
async fn update_setup(
|
||||
async fn update_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<UpdateSetupRequest>,
|
||||
) -> Result<axum::Json<SetupInfo>, ApiError> {
|
||||
axum::Json(body): axum::Json<UpdateMachineRequest>,
|
||||
) -> Result<axum::Json<MachineInfo>, ApiError> {
|
||||
let providers = if body.rediscover {
|
||||
let existing = manager
|
||||
.setups()
|
||||
.machines()
|
||||
.into_iter()
|
||||
.find(|setup| setup.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&existing);
|
||||
.find(|machine| machine.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no machine {id}")))?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&existing);
|
||||
Some(
|
||||
crate::setups::discover(&transport)
|
||||
crate::machines::discover(&transport)
|
||||
.await
|
||||
.map_err(bad_request)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let setup = manager
|
||||
.update_setup(&id, body.name.as_deref(), providers)
|
||||
let machine = manager
|
||||
.update_machine(&id, body.name.as_deref(), providers)
|
||||
.map_err(bad_request)?;
|
||||
Ok(axum::Json(info_for(setup)))
|
||||
Ok(axum::Json(info_for(machine)))
|
||||
}
|
||||
|
||||
async fn delete_setup(
|
||||
async fn delete_machine(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
manager.delete_setup(&id).map_err(bad_request)?;
|
||||
manager.delete_machine(&id).map_err(bad_request)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -538,10 +542,10 @@ fn files_on(
|
||||
id: &str,
|
||||
path: &str,
|
||||
) -> Result<(crate::session::transport::Transport, String), ApiError> {
|
||||
let setup = setup_by_id(manager, id)?;
|
||||
let machine = machine_by_id(manager, id)?;
|
||||
let path = crate::files::check_path(path).map_err(bad_request)?;
|
||||
Ok((
|
||||
crate::session::transport::Transport::for_setup(&setup),
|
||||
crate::session::transport::Transport::for_machine(&machine),
|
||||
path,
|
||||
))
|
||||
}
|
||||
@@ -566,15 +570,15 @@ struct PathQuery {
|
||||
///
|
||||
/// Not `GET /models`, which is this backend's own downloads: those are on
|
||||
/// the machine a session runs on only when they are the same machine. A
|
||||
/// spawn screen offering this backend's list for a remote setup would be
|
||||
/// spawn screen offering this backend's list for a remote machine would be
|
||||
/// naming files that are not there, and the session would fail at the
|
||||
/// point of loading rather than at the point of choosing.
|
||||
async fn setup_models(
|
||||
async fn machine_models(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<crate::models::LocalModel>>, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
let dir = crate::models::dir_on(&transport, manager.models_dir());
|
||||
crate::models::on_machine(&transport, &dir)
|
||||
.await
|
||||
@@ -582,19 +586,19 @@ async fn setup_models(
|
||||
.map_err(from_machine)
|
||||
}
|
||||
|
||||
/// The models a CLI provider currently offers on the setup's machine.
|
||||
/// The models a CLI provider currently offers on its configured machine.
|
||||
/// Codex answers from its live account catalog; providers with a configured
|
||||
/// shortcut list return that list.
|
||||
async fn provider_models(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath((id, provider_name)): UrlPath<(String, String)>,
|
||||
) -> Result<axum::Json<Vec<String>>, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let provider = setup.provider(&provider_name).ok_or_else(|| {
|
||||
ApiError::NotFound(format!("no provider {provider_name} on {}", setup.name))
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let provider = machine.provider(&provider_name).ok_or_else(|| {
|
||||
ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name))
|
||||
})?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
crate::setups::provider_models(&transport, provider)
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
crate::machines::provider_models(&transport, provider)
|
||||
.await
|
||||
.map(axum::Json)
|
||||
.map_err(from_machine)
|
||||
@@ -704,7 +708,7 @@ async fn create_dir(
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnRequest {
|
||||
/// Which machine, and which of the things it offers.
|
||||
setup: String,
|
||||
machine: String,
|
||||
provider: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
@@ -721,7 +725,7 @@ struct SpawnRequest {
|
||||
#[serde(default)]
|
||||
params: std::collections::BTreeMap<String, String>,
|
||||
/// Continue a Claude Code session the machine already has, named by the id
|
||||
/// `GET /setups/{id}/importable` reported.
|
||||
/// `GET /machines/{id}/importable` reported.
|
||||
///
|
||||
/// An id and not a path, deliberately: the server looks the path up again
|
||||
/// among the sessions it enumerated, so an enrolled token cannot turn this
|
||||
@@ -735,8 +739,8 @@ async fn list_importable(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<ImportableRow>>, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
let mut found = crate::session::import::list(&transport)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
@@ -814,8 +818,8 @@ async fn delete_importable(
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<DeleteBatch>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let setup = setup_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &id)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
// Registered before anything is spawned, so the 202 is only sent once
|
||||
// every row is already showing "deleting" -- a phone that refetches the
|
||||
// instant it gets the reply cannot catch a row that has not started.
|
||||
@@ -892,10 +896,10 @@ async fn start_import(
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Checked before accepting, so an unknown machine is an error the caller
|
||||
// sees rather than one it has to go and read off a row.
|
||||
setup_by_id(&manager, &id)?;
|
||||
machine_by_id(&manager, &id)?;
|
||||
for session in body.sessions {
|
||||
let request = SpawnRequest {
|
||||
setup: id.clone(),
|
||||
machine: id.clone(),
|
||||
provider: body.provider.clone(),
|
||||
// Nothing to say: `spawn` titles an import from the session it
|
||||
// continues, and the cwd comes from the same place.
|
||||
@@ -946,22 +950,25 @@ struct ImportRequest {
|
||||
/// is the pending registry.
|
||||
fn in_background<F>(
|
||||
manager: &Arc<SessionManager>,
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
operation: Operation,
|
||||
work: F,
|
||||
) where
|
||||
F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
|
||||
{
|
||||
let running = manager.pending().begin(&setup, &session, operation);
|
||||
let running = manager.pending().begin(&machine, &session, operation);
|
||||
tokio::spawn(async move {
|
||||
match work.await {
|
||||
Ok(()) => {
|
||||
tracing::info!("{} {session} on {setup}: done", operation.label());
|
||||
tracing::info!("{} {session} on {machine}: done", operation.label());
|
||||
running.succeeded();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label());
|
||||
tracing::warn!(
|
||||
"{} {session} on {machine} failed: {err:#}",
|
||||
operation.label()
|
||||
);
|
||||
// The server's own words, the way every other failure in this
|
||||
// app reaches a person.
|
||||
running.failed(format!("{err:#}"));
|
||||
@@ -970,7 +977,7 @@ fn in_background<F>(
|
||||
});
|
||||
}
|
||||
|
||||
/// Every change to what is in flight against one machine. Scoped to the setup
|
||||
/// Every change to what is in flight against one machine. Scoped to the machine
|
||||
/// the screen is showing, the same way a session's events are scoped to that
|
||||
/// session.
|
||||
async fn importable_events(
|
||||
@@ -983,7 +990,7 @@ async fn importable_events(
|
||||
// that is what the listing is for: the screen refetches on arrival and
|
||||
// carries the truth whatever this stream missed.
|
||||
let change = item.ok()?;
|
||||
if change.setup() != id {
|
||||
if change.machine() != id {
|
||||
return None;
|
||||
}
|
||||
Some(Ok(SseEvent::default().json_data(&change).ok()?))
|
||||
@@ -1010,15 +1017,15 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
|
||||
// not the phone's: which file that id names, and what is in it.
|
||||
let seed = match &body.import {
|
||||
Some(want) => {
|
||||
let setup = setup_by_id(manager, &body.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(manager, &body.machine)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
let chosen = crate::session::import::find(&transport, want)
|
||||
.await
|
||||
.map_err(bad_request)?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!(
|
||||
"setup \"{}\" has no Claude Code session {want} to import",
|
||||
body.setup
|
||||
"machine \"{}\" has no Claude Code session {want} to import",
|
||||
body.machine
|
||||
))
|
||||
})?;
|
||||
if let Some(existing) = manager.session_driving(want) {
|
||||
@@ -1065,7 +1072,7 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
|
||||
};
|
||||
|
||||
let spec = SpawnSpec {
|
||||
setup: body.setup,
|
||||
machine: body.machine,
|
||||
provider: body.provider,
|
||||
// An imported session is recognised by what it was about, so its
|
||||
// opening message is the title unless one was typed. Blank normalised
|
||||
@@ -1155,8 +1162,8 @@ async fn delete_session(
|
||||
// everything as it was rather than a deleted session and a transcript the
|
||||
// phone has already promised is gone.
|
||||
if let Some(foreign) = &foreign {
|
||||
let setup = setup_by_id(&manager, &foreign.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &foreign.machine)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
foreign.delete(&transport).await.map_err(bad_request)?;
|
||||
tracing::info!(
|
||||
"deleted {} session {} with ai-app session {id}",
|
||||
@@ -1307,15 +1314,133 @@ pub fn usage_router(
|
||||
.with_state(UsageState { monitor, manager })
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAuthState {
|
||||
logins: Arc<crate::provider_auth::LoginManager>,
|
||||
manager: Arc<SessionManager>,
|
||||
}
|
||||
|
||||
/// Separate state from the session routes because login attempts are
|
||||
/// short-lived provider processes, not conversations to persist or adopt.
|
||||
pub fn provider_auth_router(
|
||||
logins: Arc<crate::provider_auth::LoginManager>,
|
||||
manager: Arc<SessionManager>,
|
||||
) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/machines/{id}/providers/{provider}/auth",
|
||||
post(start_provider_auth),
|
||||
)
|
||||
.route(
|
||||
"/machines/{id}/providers/{provider}/auth/{attempt}",
|
||||
get(read_provider_auth).delete(cancel_provider_auth),
|
||||
)
|
||||
.route(
|
||||
"/machines/{id}/providers/{provider}/auth/{attempt}/code",
|
||||
post(submit_provider_auth_code),
|
||||
)
|
||||
.with_state(ProviderAuthState { logins, manager })
|
||||
}
|
||||
|
||||
fn provider_auth_target(
|
||||
manager: &Arc<SessionManager>,
|
||||
machine_id: &str,
|
||||
provider_name: &str,
|
||||
) -> Result<
|
||||
(
|
||||
crate::config::MachineConfig,
|
||||
crate::config::ProviderConfig,
|
||||
&'static str,
|
||||
),
|
||||
ApiError,
|
||||
> {
|
||||
let machine = machine_by_id(manager, machine_id)?;
|
||||
let provider = machine.provider(provider_name).cloned().ok_or_else(|| {
|
||||
ApiError::NotFound(format!("no provider {provider_name} on {}", machine.name))
|
||||
})?;
|
||||
let key = provider.kind.usage_provider().ok_or_else(|| {
|
||||
ApiError::BadRequest(format!(
|
||||
"{} does not have an account to sign in to",
|
||||
provider.name
|
||||
))
|
||||
})?;
|
||||
if key != crate::usage::CLAUDE {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{} does not support sign-in through the app",
|
||||
provider.name
|
||||
)));
|
||||
}
|
||||
Ok((machine, provider, key))
|
||||
}
|
||||
|
||||
async fn start_provider_auth(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name)): UrlPath<(String, String)>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (machine, provider, key) =
|
||||
provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
let info = state.logins.start(machine, provider);
|
||||
let logins = Arc::clone(&state.logins);
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
logins.wait_until_ready(&machine_id, key, &info.attempt)
|
||||
})
|
||||
.await
|
||||
.context("provider sign-in worker panicked")?;
|
||||
Ok(axum::Json(info))
|
||||
}
|
||||
|
||||
async fn read_provider_auth(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
state
|
||||
.logins
|
||||
.read(&machine_id, key, &attempt)
|
||||
.map(axum::Json)
|
||||
.ok_or_else(|| ApiError::NotFound("no such sign-in attempt".to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProviderAuthCode {
|
||||
code: String,
|
||||
}
|
||||
|
||||
async fn submit_provider_auth_code(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
|
||||
axum::Json(body): axum::Json<ProviderAuthCode>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
state
|
||||
.logins
|
||||
.submit(&machine_id, key, &attempt, &body.code)
|
||||
.map(axum::Json)
|
||||
.map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn cancel_provider_auth(
|
||||
State(state): State<ProviderAuthState>,
|
||||
UrlPath((machine_id, provider_name, attempt)): UrlPath<(String, String, String)>,
|
||||
) -> Result<axum::Json<crate::provider_auth::LoginInfo>, ApiError> {
|
||||
let (_, _, key) = provider_auth_target(&state.manager, &machine_id, &provider_name)?;
|
||||
state
|
||||
.logins
|
||||
.cancel(&machine_id, key, &attempt)
|
||||
.map(axum::Json)
|
||||
.map_err(bad_request)
|
||||
}
|
||||
|
||||
async fn usage(
|
||||
State(state): State<UsageState>,
|
||||
) -> Result<axum::Json<Vec<crate::usage::UsageSnapshot>>, ApiError> {
|
||||
// Read here rather than inside the fetch, so the list of machines is the
|
||||
// one that existed when the request arrived and cannot change under a
|
||||
// fetch that takes an ssh round trip per machine.
|
||||
let setups = state.manager.setups();
|
||||
let machines = state.manager.machines();
|
||||
// The fetch is blocking by design (see `usage`); off the workers.
|
||||
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups))
|
||||
let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&machines))
|
||||
.await
|
||||
.context("usage fetch panicked")?;
|
||||
Ok(axum::Json(snapshots))
|
||||
@@ -1348,7 +1473,7 @@ struct CwdRequest {
|
||||
/// Moves a session to a different working directory.
|
||||
///
|
||||
/// The directory is checked here rather than in the manager because checking
|
||||
/// it is an ssh round trip on a remote setup, and the manager is not async.
|
||||
/// it is an ssh round trip on a remote machine, and the manager is not async.
|
||||
///
|
||||
/// Checked rather than trusted, and refused rather than corrected: a mistyped
|
||||
/// path that was accepted would leave a session recorded somewhere its process
|
||||
@@ -1372,20 +1497,20 @@ async fn set_cwd(
|
||||
// launched from, which is not something the person typing it can see. The
|
||||
// same question the explorer asks of every path, asked in one place.
|
||||
let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?;
|
||||
let setup = setup_by_id(&manager, &session.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
let machine = machine_by_id(&manager, &session.machine)?;
|
||||
let transport = crate::session::transport::Transport::for_machine(&machine);
|
||||
if !crate::session::import::directory_exists(&transport, &cwd).await {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{} has no directory {cwd}",
|
||||
setup.name
|
||||
machine.name
|
||||
)));
|
||||
}
|
||||
// Stored in the short form, so the one path kept is the one the phone will
|
||||
// draw -- rather than storing `/home/bob/…` and abbreviating it again at
|
||||
// each place it is shown, which is two representations of one directory.
|
||||
// Only where the setup runs here; see `setups::shorten_home`.
|
||||
let stored = if setup.ssh.is_none() {
|
||||
crate::setups::shorten_home(&cwd)
|
||||
// Only where the machine runs here; see `machines::shorten_home`.
|
||||
let stored = if machine.ssh.is_none() {
|
||||
crate::machines::shorten_home(&cwd)
|
||||
} else {
|
||||
cwd.clone()
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//!
|
||||
//! **The phone never names a file.** It picks an id out of what this
|
||||
//! module enumerated, and the path is looked up again on the server -- the
|
||||
//! same rule the setups model follows for providers, and for the same
|
||||
//! same rule the machines model follows for providers, and for the same
|
||||
//! reason: an enrolled token must not be able to turn into "read me this
|
||||
//! arbitrary path".
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! command carries the tunnel between them. The far `llama-server` binds
|
||||
//! loopback only, so a model is never served to that machine's network.
|
||||
//!
|
||||
//! **The model file is the far machine's, not this one's.** A remote setup
|
||||
//! **The model file is the far machine's, not this one's.** A remote machine
|
||||
//! names its own models directory (`SshConfig::models_dir`, defaulting to where
|
||||
//! this backend keeps its downloads), and the file is looked for *there* -- so
|
||||
//! a session naming a model that machine does not have says so, instead of
|
||||
|
||||
+117
-115
@@ -30,8 +30,8 @@ use serde::Serialize;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::config::{
|
||||
Config, DEFAULT_RESUME_MESSAGE, DriverKind, ProviderConfig, ScheduledResume, SessionConfig,
|
||||
SetupConfig, SshConfig, TokenEntry,
|
||||
Config, DEFAULT_RESUME_MESSAGE, DriverKind, MachineConfig, ProviderConfig, ScheduledResume,
|
||||
SessionConfig, SshConfig, TokenEntry,
|
||||
};
|
||||
use claude::ClaudeDriver;
|
||||
use codex::CodexDriver;
|
||||
@@ -100,7 +100,7 @@ pub fn now() -> f64 {
|
||||
}
|
||||
|
||||
pub struct SpawnSpec {
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
pub provider: String,
|
||||
pub title: Option<String>,
|
||||
pub model: Option<String>,
|
||||
@@ -172,7 +172,7 @@ impl AutoResumeView {
|
||||
pub struct OwedResume {
|
||||
pub session_id: String,
|
||||
/// The machine whose account ran out, which is the one to ask.
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
/// Which meter reports on it -- a `crate::usage::UsageProvider::name`, the
|
||||
/// same pairing `SessionInfo::usage_provider` uses.
|
||||
pub provider: &'static str,
|
||||
@@ -193,11 +193,11 @@ fn resume_message(meta: &SessionConfig) -> String {
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
/// That machine's current label, resolved when this row is built, so
|
||||
/// renaming a setup renames it everywhere rather than leaving old
|
||||
/// renaming a machine renames it everywhere rather than leaving old
|
||||
/// sessions showing the old name.
|
||||
pub setup_name: String,
|
||||
pub machine_name: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
@@ -562,7 +562,7 @@ impl LiveSession {
|
||||
Ok((name, path))
|
||||
}
|
||||
|
||||
/// `setup_name` and `cwd` are passed in rather than read from the
|
||||
/// `machine_name` and `cwd` are passed in rather than read from the
|
||||
/// snapshot this session launched with: only the manager holds the
|
||||
/// config, and both can change under a running session. Passed rather
|
||||
/// than mirrored into `Shared`, so there is one answer, read where the
|
||||
@@ -574,7 +574,7 @@ impl LiveSession {
|
||||
/// cautious one.
|
||||
fn info(
|
||||
&self,
|
||||
setup_name: &str,
|
||||
machine_name: &str,
|
||||
cwd: Option<&Path>,
|
||||
effort: Option<&str>,
|
||||
imported: bool,
|
||||
@@ -584,8 +584,8 @@ impl LiveSession {
|
||||
SessionInfo {
|
||||
id: self.meta.id.clone(),
|
||||
provider: self.meta.provider.clone(),
|
||||
setup: self.meta.setup.clone(),
|
||||
setup_name: setup_name.to_string(),
|
||||
machine: self.meta.machine.clone(),
|
||||
machine_name: machine_name.to_string(),
|
||||
title: self.shared.title.lock().unwrap().clone(),
|
||||
model: self.shared.model.lock().unwrap().clone(),
|
||||
permission_mode: self.shared.permission_mode.lock().unwrap().clone(),
|
||||
@@ -646,7 +646,7 @@ pub struct SessionManager {
|
||||
/// The CLI-owned copy optionally removed with an ai-app session.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ForeignTranscript {
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
pub id: String,
|
||||
kind: DriverKind,
|
||||
}
|
||||
@@ -702,10 +702,10 @@ impl SessionManager {
|
||||
// One unlaunchable session -- a corrupt transcript, an
|
||||
// unreachable host, a provider edited away -- shows as exited
|
||||
// rather than taking the server down, and can still be deleted.
|
||||
match resolve(&config, meta).and_then(|(setup, provider)| {
|
||||
match resolve(&config, meta).and_then(|(machine, provider)| {
|
||||
launch(
|
||||
meta.clone(),
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
Env {
|
||||
data_dir: &data_dir,
|
||||
@@ -777,7 +777,7 @@ impl SessionManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Writes this machine into a config that has no setups, with the
|
||||
/// Writes this machine into a config that has no machines, with the
|
||||
/// providers actually found on it.
|
||||
///
|
||||
/// Discovered rather than assumed. This used to write a `claude-cli`
|
||||
@@ -789,16 +789,16 @@ impl SessionManager {
|
||||
/// this server runs, and says so in the log. Seeding the hardcoded list
|
||||
/// would be the original bug with an extra step, and seeding nothing
|
||||
/// leaves a fresh install with nothing to prove the pipe with.
|
||||
pub async fn seed_setup(&self) -> Result<()> {
|
||||
if !self.inner.read().unwrap().config.setups.is_empty() {
|
||||
pub async fn seed_machine(&self) -> Result<()> {
|
||||
if !self.inner.read().unwrap().config.machines.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let providers = match crate::setups::discover(&transport::Transport::Here).await {
|
||||
let providers = match crate::machines::discover(&transport::Transport::Here).await {
|
||||
Ok(found) => found,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"couldn't ask this machine what it has ({err}); seeding {} only -- \
|
||||
re-probe the setup from the app once that is fixed",
|
||||
re-probe the machine from the app once that is fixed",
|
||||
crate::config::ECHO_PROVIDER
|
||||
);
|
||||
vec![Config::echo_provider()]
|
||||
@@ -807,16 +807,16 @@ impl SessionManager {
|
||||
let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
|
||||
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
if !inner.config.setups.is_empty() {
|
||||
if !inner.config.machines.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut candidate = inner.config.clone();
|
||||
candidate.setups.push(Config::seed(providers.clone()));
|
||||
candidate.machines.push(Config::seed(providers.clone()));
|
||||
candidate.save(&self.config_path)?;
|
||||
inner.config = candidate;
|
||||
tracing::info!(
|
||||
"no setups configured -- added \"{}\" with: {}",
|
||||
crate::config::LOCAL_SETUP,
|
||||
"no machines configured -- added \"{}\" with: {}",
|
||||
crate::config::LOCAL_MACHINE,
|
||||
names.join(", ")
|
||||
);
|
||||
Ok(())
|
||||
@@ -839,80 +839,80 @@ impl SessionManager {
|
||||
///
|
||||
/// `providers` comes from probing rather than from the caller: the
|
||||
/// probe is async and this is not, so the route asks and this writes.
|
||||
pub fn add_setup(
|
||||
pub fn add_machine(
|
||||
&self,
|
||||
name: &str,
|
||||
ssh: Option<SshConfig>,
|
||||
providers: Vec<ProviderConfig>,
|
||||
) -> Result<SetupConfig> {
|
||||
) -> Result<MachineConfig> {
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
bail!("a setup needs a name");
|
||||
bail!("a machine needs a name");
|
||||
}
|
||||
self.update(|config| {
|
||||
if config.setup_named(&name).is_some() {
|
||||
bail!("there is already a setup called \"{name}\"");
|
||||
if config.machine_named(&name).is_some() {
|
||||
bail!("there is already a machine called \"{name}\"");
|
||||
}
|
||||
// Ids are derived once and then fixed, so a label can be
|
||||
// edited later without orphaning the sessions that named it.
|
||||
let mut id = crate::setups::id_from(&name);
|
||||
while config.setup(&id).is_some() {
|
||||
let mut id = crate::machines::id_from(&name);
|
||||
while config.machine(&id).is_some() {
|
||||
id = format!("{id}-{}", &random_hex()[..4]);
|
||||
}
|
||||
let setup = SetupConfig {
|
||||
let machine = MachineConfig {
|
||||
id,
|
||||
name: name.clone(),
|
||||
ssh,
|
||||
providers,
|
||||
};
|
||||
config.setups.push(setup.clone());
|
||||
Ok(setup)
|
||||
config.machines.push(machine.clone());
|
||||
Ok(machine)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_setup(
|
||||
pub fn update_machine(
|
||||
&self,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
providers: Option<Vec<ProviderConfig>>,
|
||||
) -> Result<SetupConfig> {
|
||||
) -> Result<MachineConfig> {
|
||||
self.update(|config| {
|
||||
if let Some(name) = name {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
bail!("a setup needs a name");
|
||||
bail!("a machine needs a name");
|
||||
}
|
||||
if config.setups.iter().any(|s| s.name == name && s.id != id) {
|
||||
bail!("there is already a setup called \"{name}\"");
|
||||
if config.machines.iter().any(|s| s.name == name && s.id != id) {
|
||||
bail!("there is already a machine called \"{name}\"");
|
||||
}
|
||||
}
|
||||
let setup = config
|
||||
.setups
|
||||
let machine = config
|
||||
.machines
|
||||
.iter_mut()
|
||||
.find(|setup| setup.id == id)
|
||||
.with_context(|| format!("no setup with id \"{id}\""))?;
|
||||
.find(|machine| machine.id == id)
|
||||
.with_context(|| format!("no machine with id \"{id}\""))?;
|
||||
if let Some(name) = name {
|
||||
setup.name = name.trim().to_string();
|
||||
machine.name = name.trim().to_string();
|
||||
}
|
||||
if let Some(providers) = providers {
|
||||
setup.providers = providers;
|
||||
machine.providers = providers;
|
||||
}
|
||||
Ok(setup.clone())
|
||||
Ok(machine.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes a machine, provided nothing is still running on it.
|
||||
/// Refused rather than cascaded: the person asking is better placed to
|
||||
/// decide which of those sessions they still want.
|
||||
pub fn delete_setup(&self, id: &str) -> Result<()> {
|
||||
pub fn delete_machine(&self, id: &str) -> Result<()> {
|
||||
self.update(|config| {
|
||||
if config.setup(id).is_none() {
|
||||
bail!("no setup with id \"{id}\"");
|
||||
if config.machine(id).is_none() {
|
||||
bail!("no machine with id \"{id}\"");
|
||||
}
|
||||
let using: Vec<&str> = config
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|session| session.setup == id)
|
||||
.filter(|session| session.machine == id)
|
||||
.map(|session| session.title.as_str())
|
||||
.collect();
|
||||
if !using.is_empty() {
|
||||
@@ -922,7 +922,7 @@ impl SessionManager {
|
||||
using.join(", "),
|
||||
);
|
||||
}
|
||||
config.setups.retain(|setup| setup.id != id);
|
||||
config.machines.retain(|machine| machine.id != id);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -1061,18 +1061,18 @@ impl SessionManager {
|
||||
pub fn remote_of(&self, id: &str) -> Option<(crate::config::SshConfig, Option<PathBuf>)> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
|
||||
let setup = inner
|
||||
let machine = inner
|
||||
.config
|
||||
.setups
|
||||
.machines
|
||||
.iter()
|
||||
.find(|setup| setup.id == meta.setup)?;
|
||||
Some((setup.ssh.clone()?, meta.cwd.clone()))
|
||||
.find(|machine| machine.id == meta.machine)?;
|
||||
Some((machine.ssh.clone()?, meta.cwd.clone()))
|
||||
}
|
||||
|
||||
pub fn foreign_transcript(&self, id: &str) -> Option<ForeignTranscript> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
|
||||
let kind = kind_of(&inner.config, &meta.setup, &meta.provider)?;
|
||||
let kind = kind_of(&inner.config, &meta.machine, &meta.provider)?;
|
||||
let session_dir = self.data_dir.join(&meta.id);
|
||||
let foreign = match kind {
|
||||
DriverKind::ClaudeCli => {
|
||||
@@ -1085,7 +1085,7 @@ impl SessionManager {
|
||||
DriverKind::Echo | DriverKind::LlamaCpp => None,
|
||||
}?;
|
||||
Some(ForeignTranscript {
|
||||
setup: meta.setup.clone(),
|
||||
machine: meta.machine.clone(),
|
||||
id: foreign,
|
||||
kind,
|
||||
})
|
||||
@@ -1101,28 +1101,28 @@ impl SessionManager {
|
||||
.iter()
|
||||
.map(|meta| match inner.live.get(&meta.id) {
|
||||
Some(session) => session.info(
|
||||
label_of(&inner.config, &meta.setup),
|
||||
label_of(&inner.config, &meta.machine),
|
||||
meta.cwd.as_deref(),
|
||||
meta.effort.as_deref(),
|
||||
import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||
kind_of(&inner.config, &meta.setup, &meta.provider),
|
||||
kind_of(&inner.config, &meta.machine, &meta.provider),
|
||||
AutoResumeView::of(meta),
|
||||
),
|
||||
None => SessionInfo {
|
||||
id: meta.id.clone(),
|
||||
setup: meta.setup.clone(),
|
||||
setup_name: label_of(&inner.config, &meta.setup).to_string(),
|
||||
machine: meta.machine.clone(),
|
||||
machine_name: label_of(&inner.config, &meta.machine).to_string(),
|
||||
provider: meta.provider.clone(),
|
||||
title: meta.title.clone(),
|
||||
model: meta.model.clone(),
|
||||
permission_mode: meta.permission_mode.clone(),
|
||||
effort: meta.effort.clone(),
|
||||
takes_effort: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
takes_effort: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.is_some_and(DriverKind::takes_effort),
|
||||
context_tokens: None,
|
||||
max_image_edge: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
max_image_edge: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.and_then(DriverKind::max_image_edge),
|
||||
usage_provider: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
usage_provider: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.and_then(DriverKind::usage_provider),
|
||||
notify: meta.notify,
|
||||
auto_resume: meta.auto_resume,
|
||||
@@ -1131,10 +1131,10 @@ impl SessionManager {
|
||||
imported: import::read_cursor(&self.data_dir.join(&meta.id)).is_some(),
|
||||
keeps_own_transcript: keeps_own_transcript(
|
||||
&inner.config,
|
||||
&meta.setup,
|
||||
&meta.machine,
|
||||
&meta.provider,
|
||||
),
|
||||
own_transcript_name: kind_of(&inner.config, &meta.setup, &meta.provider)
|
||||
own_transcript_name: kind_of(&inner.config, &meta.machine, &meta.provider)
|
||||
.and_then(DriverKind::own_transcript_name),
|
||||
cwd: meta.cwd.clone(),
|
||||
status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
|
||||
@@ -1172,8 +1172,8 @@ impl SessionManager {
|
||||
|
||||
/// Every machine this server can run something on, each with what it
|
||||
/// can run. One list rather than two, because the pair is the choice.
|
||||
pub fn setups(&self) -> Vec<SetupConfig> {
|
||||
self.inner.read().unwrap().config.setups.clone()
|
||||
pub fn machines(&self) -> Vec<MachineConfig> {
|
||||
self.inner.read().unwrap().config.machines.clone()
|
||||
}
|
||||
|
||||
pub fn spawn_session(&self, spec: SpawnSpec) -> Result<SessionInfo> {
|
||||
@@ -1191,28 +1191,28 @@ impl SessionManager {
|
||||
|
||||
fn spawn_seeded(&self, spec: SpawnSpec, seed: Option<Seed>) -> Result<SessionInfo> {
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
let setup = inner
|
||||
let machine = inner
|
||||
.config
|
||||
.setup(&spec.setup)
|
||||
.machine(&spec.machine)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no setup with id \"{}\" -- configured: {}",
|
||||
spec.setup,
|
||||
"no machine with id \"{}\" -- configured: {}",
|
||||
spec.machine,
|
||||
// Ids, since that is what was looked up. Labels made
|
||||
// the failure read as a contradiction: "no setup named
|
||||
// the failure read as a contradiction: "no machine named
|
||||
// X -- configured: X".
|
||||
names(inner.config.setups.iter().map(|s| s.id.as_str())),
|
||||
names(inner.config.machines.iter().map(|s| s.id.as_str())),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let provider = setup
|
||||
let provider = machine
|
||||
.provider(&spec.provider)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"setup \"{}\" has no provider named \"{}\" -- it offers: {}",
|
||||
spec.setup,
|
||||
"machine \"{}\" has no provider named \"{}\" -- it offers: {}",
|
||||
spec.machine,
|
||||
spec.provider,
|
||||
names(setup.providers.iter().map(|p| p.name.as_str())),
|
||||
names(machine.providers.iter().map(|p| p.name.as_str())),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
@@ -1223,7 +1223,7 @@ impl SessionManager {
|
||||
.unwrap_or_else(|| format!("{} session", provider.name));
|
||||
let meta = SessionConfig {
|
||||
id: id.clone(),
|
||||
setup: setup.id.clone(),
|
||||
machine: machine.id.clone(),
|
||||
provider: provider.name.clone(),
|
||||
title,
|
||||
// No model unless one was chosen. This used to fall back to the
|
||||
@@ -1267,7 +1267,7 @@ impl SessionManager {
|
||||
|
||||
let session = launch(
|
||||
meta.clone(),
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
self.env(),
|
||||
self.announce.clone(),
|
||||
@@ -1286,7 +1286,7 @@ impl SessionManager {
|
||||
// Whether this one was seeded, the same question the listing asks
|
||||
// of the directory a moment later.
|
||||
let info = session.info(
|
||||
&setup.name,
|
||||
&machine.name,
|
||||
session.meta.cwd.as_deref(),
|
||||
session.meta.effort.as_deref(),
|
||||
import::read_cursor(&self.data_dir.join(&id)).is_some(),
|
||||
@@ -1442,8 +1442,8 @@ impl SessionManager {
|
||||
let scheduled = meta.resume?;
|
||||
Some(OwedResume {
|
||||
session_id: meta.id.clone(),
|
||||
setup: meta.setup.clone(),
|
||||
provider: kind_of(&inner.config, &meta.setup, &meta.provider)?
|
||||
machine: meta.machine.clone(),
|
||||
provider: kind_of(&inner.config, &meta.machine, &meta.provider)?
|
||||
.usage_provider()?,
|
||||
scheduled,
|
||||
})
|
||||
@@ -1628,7 +1628,7 @@ impl SessionManager {
|
||||
/// it entirely.
|
||||
///
|
||||
/// Whether the directory exists is the caller's question, because
|
||||
/// asking it is an ssh round trip on a remote setup; see the route.
|
||||
/// asking it is an ssh round trip on a remote machine; see the route.
|
||||
pub fn set_session_cwd(&self, id: &str, cwd: PathBuf) -> Result<()> {
|
||||
{
|
||||
let mut inner = self.inner.write().unwrap();
|
||||
@@ -1889,7 +1889,7 @@ impl SessionManager {
|
||||
// Fresh from the config, like every other launch: a model or a
|
||||
// permission mode changed while the session was stopped is what it
|
||||
// starts with.
|
||||
let (setup, provider) = resolve(&inner.config, &meta)?;
|
||||
let (machine, provider) = resolve(&inner.config, &meta)?;
|
||||
match existing {
|
||||
Some(session) => {
|
||||
// Replacing the value a driver lives in does not end the
|
||||
@@ -1901,7 +1901,7 @@ impl SessionManager {
|
||||
}
|
||||
*session.driver.lock().unwrap() = Some(make_driver(
|
||||
&meta,
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
self.env(),
|
||||
session.dir(),
|
||||
@@ -1915,7 +1915,7 @@ impl SessionManager {
|
||||
None => {
|
||||
let session = launch(
|
||||
meta,
|
||||
&setup,
|
||||
&machine,
|
||||
&provider,
|
||||
self.env(),
|
||||
self.announce.clone(),
|
||||
@@ -2050,24 +2050,26 @@ fn adoptable(session_dir: &Path) -> bool {
|
||||
/// The provider and host a session's config names, or a message saying
|
||||
/// which one is missing. Both are looked up fresh at every launch, so
|
||||
/// editing either takes effect on the next respawn.
|
||||
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(SetupConfig, ProviderConfig)> {
|
||||
let setup = config
|
||||
.setup(&meta.setup)
|
||||
.with_context(|| format!("no setup named \"{}\"", meta.setup))?;
|
||||
let provider = setup.provider(&meta.provider).with_context(|| {
|
||||
fn resolve(config: &Config, meta: &SessionConfig) -> Result<(MachineConfig, ProviderConfig)> {
|
||||
let machine = config
|
||||
.machine(&meta.machine)
|
||||
.with_context(|| format!("no machine named \"{}\"", meta.machine))?;
|
||||
let provider = machine.provider(&meta.provider).with_context(|| {
|
||||
format!(
|
||||
"setup \"{}\" has no provider named \"{}\"",
|
||||
meta.setup, meta.provider
|
||||
"machine \"{}\" has no provider named \"{}\"",
|
||||
meta.machine, meta.provider
|
||||
)
|
||||
})?;
|
||||
Ok((setup.clone(), provider.clone()))
|
||||
Ok((machine.clone(), provider.clone()))
|
||||
}
|
||||
|
||||
/// A setup's current label, or its id when the setup has been deleted --
|
||||
/// A machine's current label, or its id when the machine has been deleted --
|
||||
/// which is what a session left behind by a removed machine shows, and is
|
||||
/// better than an empty column or a guess at what it used to be called.
|
||||
fn label_of<'a>(config: &'a Config, id: &'a str) -> &'a str {
|
||||
config.setup(id).map_or(id, |setup| setup.name.as_str())
|
||||
config
|
||||
.machine(id)
|
||||
.map_or(id, |machine| machine.name.as_str())
|
||||
}
|
||||
|
||||
/// The two ways a session directory can name a Claude Code conversation:
|
||||
@@ -2094,21 +2096,21 @@ fn foreign_ids(dir: &Path) -> (Option<String>, Option<String>) {
|
||||
/// app's delete cannot reach.
|
||||
///
|
||||
/// False when the provider can't be found, which is the safe way round: a
|
||||
/// setup or provider removed from the config leaves sessions naming one
|
||||
/// machine or provider removed from the config leaves sessions naming one
|
||||
/// that is gone, and the warning that then shows is the strong one. Saying
|
||||
/// "this can be brought back" on no evidence is the answer that loses
|
||||
/// somebody's conversation.
|
||||
fn keeps_own_transcript(config: &Config, setup: &str, provider: &str) -> bool {
|
||||
kind_of(config, setup, provider).is_some_and(DriverKind::keeps_own_transcript)
|
||||
fn keeps_own_transcript(config: &Config, machine: &str, provider: &str) -> bool {
|
||||
kind_of(config, machine, provider).is_some_and(DriverKind::keeps_own_transcript)
|
||||
}
|
||||
|
||||
/// What a session's provider is, for the questions answered by its *kind*
|
||||
/// rather than by its name. `None` for a provider that has been edited away,
|
||||
/// which is a session that cannot run at all.
|
||||
fn kind_of(config: &Config, setup: &str, provider: &str) -> Option<DriverKind> {
|
||||
fn kind_of(config: &Config, machine: &str, provider: &str) -> Option<DriverKind> {
|
||||
config
|
||||
.setup(setup)
|
||||
.and_then(|setup| setup.providers.iter().find(|it| it.name == provider))
|
||||
.machine(machine)
|
||||
.and_then(|machine| machine.providers.iter().find(|it| it.name == provider))
|
||||
.map(|provider| provider.kind)
|
||||
}
|
||||
|
||||
@@ -2310,7 +2312,7 @@ struct Env<'a> {
|
||||
/// process for it to speak to. See [`Launching`] for when that is.
|
||||
fn launch(
|
||||
meta: SessionConfig,
|
||||
setup: &SetupConfig,
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
env: Env<'_>,
|
||||
announce: Announcements,
|
||||
@@ -2405,7 +2407,7 @@ fn launch(
|
||||
&& shared.context_tokens.lock().unwrap().is_none()
|
||||
&& let Some(session_id) = claude::read_resume_token(&dir)
|
||||
{
|
||||
let transport = Transport::for_setup(setup);
|
||||
let transport = Transport::for_machine(machine);
|
||||
let shared = Arc::clone(&shared);
|
||||
tokio::spawn(async move {
|
||||
if let Some(context) = import::context_of(&transport, &session_id).await {
|
||||
@@ -2423,7 +2425,7 @@ fn launch(
|
||||
&& shared.context_tokens.lock().unwrap().is_none()
|
||||
&& let Some(thread_id) = codex::read_thread(&dir)
|
||||
{
|
||||
let transport = Transport::for_setup(setup);
|
||||
let transport = Transport::for_machine(machine);
|
||||
let shared = Arc::clone(&shared);
|
||||
tokio::spawn(async move {
|
||||
if let Some(context) = codex::context_of(&transport, &thread_id).await {
|
||||
@@ -2441,7 +2443,7 @@ fn launch(
|
||||
// anybody pressing anything.
|
||||
if let Some(cursor) = import::read_cursor(&dir) {
|
||||
spawn_import_sync(
|
||||
Transport::for_setup(setup),
|
||||
Transport::for_machine(machine),
|
||||
dir.clone(),
|
||||
cursor,
|
||||
sink.clone(),
|
||||
@@ -2454,7 +2456,7 @@ fn launch(
|
||||
.then(|| {
|
||||
make_driver(
|
||||
&meta,
|
||||
setup,
|
||||
machine,
|
||||
provider,
|
||||
env,
|
||||
&dir,
|
||||
@@ -2505,7 +2507,7 @@ fn launch(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_driver(
|
||||
meta: &SessionConfig,
|
||||
setup: &SetupConfig,
|
||||
machine: &MachineConfig,
|
||||
provider: &ProviderConfig,
|
||||
env: Env<'_>,
|
||||
dir: &Path,
|
||||
@@ -2525,7 +2527,7 @@ fn make_driver(
|
||||
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||
meta,
|
||||
provider,
|
||||
&Transport::for_setup(setup),
|
||||
&Transport::for_machine(machine),
|
||||
env.models_dir,
|
||||
transcript_path,
|
||||
dir,
|
||||
@@ -2535,7 +2537,7 @@ fn make_driver(
|
||||
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
|
||||
meta,
|
||||
provider,
|
||||
&Transport::for_setup(setup),
|
||||
&Transport::for_machine(machine),
|
||||
dir,
|
||||
sink.clone(),
|
||||
Arc::clone(subagents),
|
||||
@@ -2543,7 +2545,7 @@ fn make_driver(
|
||||
DriverKind::CodexCli => Arc::new(CodexDriver::launch(
|
||||
meta,
|
||||
provider,
|
||||
Transport::for_setup(setup),
|
||||
Transport::for_machine(machine),
|
||||
dir,
|
||||
sink.clone(),
|
||||
)?),
|
||||
@@ -2799,7 +2801,7 @@ mod tests {
|
||||
fn echo_spec() -> SpawnSpec {
|
||||
SpawnSpec {
|
||||
params: Default::default(),
|
||||
setup: crate::config::LOCAL_SETUP_ID.to_string(),
|
||||
machine: crate::config::LOCAL_MACHINE_ID.to_string(),
|
||||
provider: crate::config::ECHO_PROVIDER.to_string(),
|
||||
title: None,
|
||||
model: None,
|
||||
@@ -2857,7 +2859,7 @@ mod tests {
|
||||
/// depending on whether `claude` happens to be installed.
|
||||
fn seed_echo_only(config_path: &std::path::Path) {
|
||||
Config {
|
||||
setups: vec![Config::seed(vec![Config::echo_provider()])],
|
||||
machines: vec![Config::seed(vec![Config::echo_provider()])],
|
||||
..Config::default()
|
||||
}
|
||||
.save(config_path)
|
||||
@@ -3356,7 +3358,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
manager.foreign_transcript(&info.id),
|
||||
Some(ForeignTranscript {
|
||||
setup: info.setup.clone(),
|
||||
machine: info.machine.clone(),
|
||||
id: "5ecf21da-d53f".to_string(),
|
||||
kind: DriverKind::ClaudeCli,
|
||||
})
|
||||
@@ -3979,7 +3981,7 @@ mod tests {
|
||||
std::fs::write(&command, "#!/bin/sh\ncat > /dev/null\n").expect("write stand-in");
|
||||
std::fs::set_permissions(&command, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
Config {
|
||||
setups: vec![Config::seed(vec![
|
||||
machines: vec![Config::seed(vec![
|
||||
Config::echo_provider(),
|
||||
ProviderConfig {
|
||||
name: "stand-in".to_string(),
|
||||
|
||||
@@ -48,16 +48,16 @@ impl Operation {
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
pub enum Change {
|
||||
Started {
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
operation: Operation,
|
||||
},
|
||||
Finished {
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
},
|
||||
Failed {
|
||||
setup: String,
|
||||
machine: String,
|
||||
session: String,
|
||||
message: String,
|
||||
},
|
||||
@@ -66,11 +66,11 @@ pub enum Change {
|
||||
impl Change {
|
||||
/// Which machine this is about, so a stream scoped to one can drop the rest.
|
||||
/// Every variant carries it; matching here keeps that fact in one place.
|
||||
pub fn setup(&self) -> &str {
|
||||
pub fn machine(&self) -> &str {
|
||||
match self {
|
||||
Self::Started { setup, .. }
|
||||
| Self::Finished { setup, .. }
|
||||
| Self::Failed { setup, .. } => setup,
|
||||
Self::Started { machine, .. }
|
||||
| Self::Finished { machine, .. }
|
||||
| Self::Failed { machine, .. } => machine,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,12 +106,12 @@ impl Registry {
|
||||
/// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports
|
||||
/// a failure. Dropping without settling means the task was cancelled or
|
||||
/// panicked, and a row stuck on "importing" for ever is a worse answer.
|
||||
pub fn begin(self: &Arc<Self>, setup: &str, session: &str, operation: Operation) -> InFlight {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
pub fn begin(self: &Arc<Self>, machine: &str, session: &str, operation: Operation) -> InFlight {
|
||||
let key = (machine.to_string(), session.to_string());
|
||||
self.running.lock().unwrap().insert(key.clone(), operation);
|
||||
self.failures.lock().unwrap().remove(&key);
|
||||
let _ = self.changes.send(Change::Started {
|
||||
setup: key.0.clone(),
|
||||
machine: key.0.clone(),
|
||||
session: key.1.clone(),
|
||||
operation,
|
||||
});
|
||||
@@ -123,25 +123,25 @@ impl Registry {
|
||||
}
|
||||
|
||||
/// What is happening to this session, if anything is.
|
||||
pub fn running(&self, setup: &str, session: &str) -> Option<Operation> {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
pub fn running(&self, machine: &str, session: &str) -> Option<Operation> {
|
||||
let key = (machine.to_string(), session.to_string());
|
||||
self.running.lock().unwrap().get(&key).copied()
|
||||
}
|
||||
|
||||
/// How the last operation on this session failed, if it did.
|
||||
pub fn failure(&self, setup: &str, session: &str) -> Option<String> {
|
||||
let key = (setup.to_string(), session.to_string());
|
||||
pub fn failure(&self, machine: &str, session: &str) -> Option<String> {
|
||||
let key = (machine.to_string(), session.to_string());
|
||||
self.failures.lock().unwrap().get(&key).cloned()
|
||||
}
|
||||
|
||||
/// Forgets failures against sessions the machine no longer has. Called from
|
||||
/// the listing, which is the only place that knows what is still there.
|
||||
pub fn prune(&self, setup: &str, present: &[String]) {
|
||||
pub fn prune(&self, machine: &str, present: &[String]) {
|
||||
self.failures
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(kept_setup, session), _| {
|
||||
kept_setup != setup || present.iter().any(|id| id == session)
|
||||
.retain(|(kept_machine, session), _| {
|
||||
kept_machine != machine || present.iter().any(|id| id == session)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ impl InFlight {
|
||||
}
|
||||
self.settled = true;
|
||||
self.registry.running.lock().unwrap().remove(&self.key);
|
||||
let (setup, session) = (self.key.0.clone(), self.key.1.clone());
|
||||
let (machine, session) = (self.key.0.clone(), self.key.1.clone());
|
||||
let change = match failure {
|
||||
Some(message) => {
|
||||
self.registry
|
||||
@@ -183,12 +183,12 @@ impl InFlight {
|
||||
.unwrap()
|
||||
.insert(self.key.clone(), message.clone());
|
||||
Change::Failed {
|
||||
setup,
|
||||
machine,
|
||||
session,
|
||||
message,
|
||||
}
|
||||
}
|
||||
None => Change::Finished { setup, session },
|
||||
None => Change::Finished { machine, session },
|
||||
};
|
||||
let _ = self.registry.changes.send(change);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ pub enum Transport {
|
||||
Here,
|
||||
/// Reached with the system `ssh` client. Owns its entry rather than
|
||||
/// borrowing it, so a session keeps working against the config it was
|
||||
/// spawned with even if the setup is edited afterwards.
|
||||
/// spawned with even if the machine is edited afterwards.
|
||||
Ssh { name: String, ssh: SshConfig },
|
||||
}
|
||||
|
||||
@@ -179,11 +179,11 @@ impl Transport {
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport a setup describes; a setup with no `ssh` is here.
|
||||
pub fn for_setup(setup: &crate::config::SetupConfig) -> Self {
|
||||
match &setup.ssh {
|
||||
/// The transport a machine describes; a machine with no `ssh` is here.
|
||||
pub fn for_machine(machine: &crate::config::MachineConfig) -> Self {
|
||||
match &machine.ssh {
|
||||
Some(ssh) => Self::Ssh {
|
||||
name: setup.name.clone(),
|
||||
name: machine.name.clone(),
|
||||
ssh: ssh.clone(),
|
||||
},
|
||||
None => Self::Here,
|
||||
|
||||
+196
-74
@@ -16,11 +16,11 @@
|
||||
//! behind the same snapshot shape.
|
||||
//!
|
||||
//! **Asked of the machine that spends the tokens, not of this one.** A session
|
||||
//! runs wherever its setup says, so the account being billed is that machine's.
|
||||
//! runs wherever its machine says, so the account being billed is that machine's.
|
||||
//! In the layout this project aims at, `ai-server` is on the host, the host has
|
||||
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
|
||||
//! the screen could show would be an account with no sessions. Credentials are
|
||||
//! read through the session `Transport`, one snapshot per setup that offers
|
||||
//! read through the session `Transport`, one snapshot per machine that offers
|
||||
//! that provider.
|
||||
//!
|
||||
//! The token is read *to* the backend and the HTTP call is made from here, so
|
||||
@@ -36,7 +36,7 @@ use std::time::{Duration, Instant};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::SetupConfig;
|
||||
use crate::config::MachineConfig;
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
|
||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||
@@ -72,12 +72,12 @@ pub struct UsageWindow {
|
||||
|
||||
/// What came back when a machine was asked about its limits.
|
||||
///
|
||||
/// Four answers rather than a flag and a message, because the screen has to
|
||||
/// Named answers rather than a flag and a message, because the screen has to
|
||||
/// treat them differently. "Nobody is logged in here" is a machine working
|
||||
/// exactly as configured, while "I could not reach it" is a fault worth
|
||||
/// chasing, and "the endpoint refused me" says nothing about the machine at
|
||||
/// all. Collapsing them into one `error` string made the first look like the
|
||||
/// last, so a perfectly healthy setup read as broken.
|
||||
/// last, so a perfectly healthy machine read as broken.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(tag = "state", rename_all = "camelCase")]
|
||||
pub enum UsageState {
|
||||
@@ -88,6 +88,13 @@ pub enum UsageState {
|
||||
NotLoggedIn,
|
||||
/// The machine could not be asked at all.
|
||||
Unreachable { detail: String },
|
||||
/// An explicit Claude login is waiting for its browser code. Kept apart
|
||||
/// from a failure because nothing is broken while somebody is completing
|
||||
/// the operation on the phone.
|
||||
Authenticating,
|
||||
/// Credentials exist, but the CLI could not renew them. Unlike a generic
|
||||
/// provider failure, this has a direct action the phone can offer.
|
||||
LoginRequired { detail: String },
|
||||
/// The machine is logged in, but the usage endpoint did not answer.
|
||||
Failed { detail: String },
|
||||
}
|
||||
@@ -98,10 +105,10 @@ pub struct UsageSnapshot {
|
||||
pub provider: String,
|
||||
/// Which machine these are the numbers for. The point of the whole module:
|
||||
/// they belong to an account on a particular box.
|
||||
pub setup: String,
|
||||
pub machine: String,
|
||||
/// That machine's current label, resolved when the snapshot is built, so
|
||||
/// renaming a setup renames it here too.
|
||||
pub setup_name: String,
|
||||
/// renaming a machine renames it here too.
|
||||
pub machine_name: String,
|
||||
/// The provider's billing pool, when it exposes more than one. Codex uses
|
||||
/// this to keep regular usage separate from its Luna reserve allowance.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -149,8 +156,8 @@ pub trait UsageProvider: Send + Sync {
|
||||
/// credentials that machine stores -- nothing to configure, and it reports on
|
||||
/// exactly the account whose CLI runs the sessions there.
|
||||
pub struct ClaudeUsage {
|
||||
pub setup: String,
|
||||
pub setup_name: String,
|
||||
pub machine: String,
|
||||
pub machine_name: String,
|
||||
/// How to reach that machine. `Here` for the backend's own.
|
||||
pub transport: Transport,
|
||||
/// The CLI to run there, for the one thing this asks of it: refreshing its
|
||||
@@ -168,8 +175,8 @@ impl ClaudeUsage {
|
||||
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
provider: self.name().to_string(),
|
||||
setup: self.setup.clone(),
|
||||
setup_name: self.setup_name.clone(),
|
||||
machine: self.machine.clone(),
|
||||
machine_name: self.machine_name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
@@ -234,8 +241,8 @@ impl UsageProvider for ClaudeUsage {
|
||||
/// app-server protocol. The CLI owns authentication and token refresh; this
|
||||
/// process never opens or copies its credentials.
|
||||
pub struct CodexUsage {
|
||||
pub setup: String,
|
||||
pub setup_name: String,
|
||||
pub machine: String,
|
||||
pub machine_name: String,
|
||||
pub transport: Transport,
|
||||
pub program: String,
|
||||
}
|
||||
@@ -244,8 +251,8 @@ impl CodexUsage {
|
||||
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
|
||||
UsageSnapshot {
|
||||
provider: CODEX.to_string(),
|
||||
setup: self.setup.clone(),
|
||||
setup_name: self.setup_name.clone(),
|
||||
machine: self.machine.clone(),
|
||||
machine_name: self.machine_name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
@@ -283,7 +290,7 @@ impl UsageProvider for CodexUsage {
|
||||
Err(err) => {
|
||||
return vec![self.snapshot(
|
||||
UsageState::Unreachable {
|
||||
detail: format!("couldn't ask Codex on {}: {err:#}", self.setup_name),
|
||||
detail: format!("couldn't ask Codex on {}: {err:#}", self.machine_name),
|
||||
},
|
||||
Vec::new(),
|
||||
)];
|
||||
@@ -446,7 +453,7 @@ impl ClaudeUsage {
|
||||
return Err(UsageState::Failed {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
|
||||
self.setup_name, self.program
|
||||
self.machine_name, self.program
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -463,10 +470,10 @@ impl ClaudeUsage {
|
||||
/// A login the CLI could not renew: the one state here somebody has to act
|
||||
/// on, so it says where and what to run.
|
||||
fn still_expired(&self) -> UsageState {
|
||||
UsageState::Failed {
|
||||
UsageState::LoginRequired {
|
||||
detail: format!(
|
||||
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
|
||||
self.setup_name, self.program
|
||||
"The Claude login on {} has expired and could not be refreshed.",
|
||||
self.machine_name
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -727,6 +734,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
||||
},
|
||||
UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(),
|
||||
UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"),
|
||||
UsageState::Authenticating => "sign-in is in progress".to_string(),
|
||||
UsageState::LoginRequired { detail } => format!("sign-in required ({detail})"),
|
||||
UsageState::Failed { detail } => format!("the meter failed ({detail})"),
|
||||
}
|
||||
}
|
||||
@@ -734,8 +743,8 @@ fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
|
||||
/// The fixture, as a provider, so it travels the same route and the same
|
||||
/// cache as a real meter rather than being spliced in at the screen.
|
||||
struct EchoUsage {
|
||||
setup: String,
|
||||
setup_name: String,
|
||||
machine: String,
|
||||
machine_name: String,
|
||||
fixture: Fixture,
|
||||
}
|
||||
|
||||
@@ -759,8 +768,8 @@ impl UsageProvider for EchoUsage {
|
||||
));
|
||||
vec![UsageSnapshot {
|
||||
provider: self.name().to_string(),
|
||||
setup: self.setup.clone(),
|
||||
setup_name: self.setup_name.clone(),
|
||||
machine: self.machine.clone(),
|
||||
machine_name: self.machine_name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state,
|
||||
@@ -778,7 +787,7 @@ impl UsageProvider for EchoUsage {
|
||||
|
||||
/// Which paid services a machine can be asked about.
|
||||
///
|
||||
/// Derived from what the setup says it can run, so a machine with no Claude
|
||||
/// Derived from what the machine says it can run, so a machine with no Claude
|
||||
/// provider is not asked about Claude limits -- it has none, and a row saying
|
||||
/// so would be a fact about nothing.
|
||||
///
|
||||
@@ -787,9 +796,9 @@ impl UsageProvider for EchoUsage {
|
||||
/// one of these rows by that same name: two lists that disagreed would leave a
|
||||
/// session looking for a snapshot nothing produces. A second service later is a
|
||||
/// name there and an impl beside [`ClaudeUsage`], not a screen.
|
||||
fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
|
||||
fn providers_for(machine: &MachineConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
|
||||
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
|
||||
for provider in &setup.providers {
|
||||
for provider in &machine.providers {
|
||||
let Some(name) = provider.kind.usage_provider() else {
|
||||
continue;
|
||||
};
|
||||
@@ -801,23 +810,23 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
}
|
||||
match name {
|
||||
CLAUDE => found.push(Box::new(ClaudeUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
transport: Transport::for_setup(setup),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
transport: Transport::for_machine(machine),
|
||||
program: provider.program().to_string(),
|
||||
})),
|
||||
CODEX => found.push(Box::new(CodexUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
transport: Transport::for_setup(setup),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
transport: Transport::for_machine(machine),
|
||||
program: provider.program().to_string(),
|
||||
})),
|
||||
// Nothing at all until a test has asked for something: an
|
||||
// echo session costs nothing, so the honest answer is no row
|
||||
// rather than a row saying zero.
|
||||
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
|
||||
setup: setup.id.clone(),
|
||||
setup_name: setup.name.clone(),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
fixture: fixture.clone(),
|
||||
})),
|
||||
_ => {}
|
||||
@@ -829,16 +838,22 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
|
||||
/// One machine's numbers for one service, and when they were fetched.
|
||||
///
|
||||
/// Keyed by the machine and the service rather than by position: the set is not
|
||||
/// fixed at startup -- setups are added, renamed and removed from the phone --
|
||||
/// fixed at startup -- machines are added, renamed and removed from the phone --
|
||||
/// and a positional cache would hand one machine's numbers to another the
|
||||
/// moment the list shifted.
|
||||
type Cached = HashMap<(String, &'static str), (Instant, Vec<UsageSnapshot>)>;
|
||||
type ProviderGate = Arc<Mutex<()>>;
|
||||
|
||||
#[derive(Default)]
|
||||
/// The cache in front of whatever machines exist: at most one real fetch per
|
||||
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
|
||||
pub struct UsageMonitor {
|
||||
cache: Mutex<Cached>,
|
||||
/// One gate per machine and metered provider. The cache lock cannot cover
|
||||
/// a network call without making an unreachable machine stall every other
|
||||
/// one, but releasing it used to let concurrent `/usage` requests launch
|
||||
/// two token refreshers against the same rotating credential.
|
||||
gates: Mutex<HashMap<(String, &'static str), ProviderGate>>,
|
||||
/// The invented meter an echo session can put up; empty unless one
|
||||
/// has. Shared with the session layer, which is where the command
|
||||
/// that sets it is typed -- see [`Fixture`].
|
||||
@@ -849,37 +864,116 @@ impl UsageMonitor {
|
||||
pub fn new(fixture: Fixture) -> Self {
|
||||
Self {
|
||||
cache: Mutex::new(Cached::new()),
|
||||
gates: Mutex::new(HashMap::new()),
|
||||
fixture,
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_for(&self, key: &(String, &'static str)) -> ProviderGate {
|
||||
self.gates
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn cached(
|
||||
&self,
|
||||
key: &(String, &'static str),
|
||||
machine_name: &str,
|
||||
max_age: Option<Duration>,
|
||||
) -> Option<Vec<UsageSnapshot>> {
|
||||
let cache = self.cache.lock().unwrap();
|
||||
let (fetched, snapshots) = cache.get(key)?;
|
||||
if max_age.is_some_and(|age| fetched.elapsed() >= age) {
|
||||
return None;
|
||||
}
|
||||
let mut snapshots = snapshots.clone();
|
||||
for snapshot in &mut snapshots {
|
||||
snapshot.machine_name = machine_name.to_string();
|
||||
}
|
||||
Some(snapshots)
|
||||
}
|
||||
|
||||
/// Prevents automatic refresh from overlapping an explicit login. The
|
||||
/// fresh cache entry closes the small gap before the login worker acquires
|
||||
/// the same gate, while an attempt lasting beyond the normal cache window
|
||||
/// is protected by the worker holding it.
|
||||
pub(crate) fn claude_authentication_started(&self, machine: &MachineConfig) -> ProviderGate {
|
||||
let key = (machine.id.clone(), CLAUDE);
|
||||
let gate = self.gate_for(&key);
|
||||
let guard = gate.lock().unwrap();
|
||||
self.cache.lock().unwrap().insert(
|
||||
key,
|
||||
(
|
||||
Instant::now(),
|
||||
vec![UsageSnapshot {
|
||||
provider: CLAUDE.to_string(),
|
||||
machine: machine.id.clone(),
|
||||
machine_name: machine.name.clone(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
state: UsageState::Authenticating,
|
||||
windows: Vec::new(),
|
||||
fetched_at: crate::session::now(),
|
||||
}],
|
||||
),
|
||||
);
|
||||
drop(guard);
|
||||
gate
|
||||
}
|
||||
|
||||
/// Makes the first read after a login ask the provider instead of keeping
|
||||
/// the pre-login state for the ordinary cache interval.
|
||||
pub(crate) fn claude_authentication_finished(&self, machine: &str) {
|
||||
self.cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(machine.to_string(), CLAUDE));
|
||||
}
|
||||
|
||||
/// One or more snapshots per machine that offers a paid service, in the
|
||||
/// order the machines are configured and the provider reports them.
|
||||
///
|
||||
/// Blocking -- call via `spawn_blocking`. Takes the setups rather than
|
||||
/// Blocking -- call via `spawn_blocking`. Takes the machines rather than
|
||||
/// holding the manager, so this module stays below the session layer rather
|
||||
/// than reaching up into it.
|
||||
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
|
||||
pub fn snapshots(&self, machines: &[MachineConfig]) -> Vec<UsageSnapshot> {
|
||||
let mut fresh = Vec::new();
|
||||
for setup in setups {
|
||||
for provider in providers_for(setup, &self.fixture) {
|
||||
let key = (setup.id.clone(), provider.name());
|
||||
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
|
||||
&& fetched.elapsed() < provider.poll_interval()
|
||||
for machine in machines {
|
||||
for provider in providers_for(machine, &self.fixture) {
|
||||
let key = (machine.id.clone(), provider.name());
|
||||
if let Some(snapshots) =
|
||||
self.cached(&key, &machine.name, Some(provider.poll_interval()))
|
||||
{
|
||||
fresh.extend(snapshots);
|
||||
continue;
|
||||
}
|
||||
|
||||
let gate = self.gate_for(&key);
|
||||
let _guard = match gate.try_lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
// A refresh already in flight can keep serving the last
|
||||
// measured answer. An explicit login seeds its own
|
||||
// authenticating answer before taking the gate.
|
||||
if let Some(snapshots) = self.cached(&key, &machine.name, None) {
|
||||
fresh.extend(snapshots);
|
||||
continue;
|
||||
}
|
||||
// The first-ever fetch has no honest stale answer. Wait
|
||||
// for its one producer, then recheck below.
|
||||
gate.lock().unwrap()
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(err)) => err.into_inner(),
|
||||
};
|
||||
if let Some(snapshots) =
|
||||
self.cached(&key, &machine.name, Some(provider.poll_interval()))
|
||||
{
|
||||
// Cached numbers, but the machine's *name* is read fresh: a
|
||||
// rename should show immediately rather than waiting out a
|
||||
// poll interval it has nothing to do with.
|
||||
let mut snapshots = snapshot.clone();
|
||||
for snapshot in &mut snapshots {
|
||||
snapshot.setup_name = setup.name.clone();
|
||||
}
|
||||
fresh.extend(snapshots);
|
||||
continue;
|
||||
}
|
||||
// Fetched without the lock held: this makes a network call per
|
||||
// machine, and holding the cache across them would serialise
|
||||
// every phone asking for the screen behind the slowest ssh.
|
||||
let snapshots = provider.fetch();
|
||||
self.cache
|
||||
.lock()
|
||||
@@ -890,11 +984,14 @@ impl UsageMonitor {
|
||||
}
|
||||
// Machines that have gone away should not keep their numbers alive.
|
||||
let live: std::collections::HashSet<&str> =
|
||||
setups.iter().map(|setup| setup.id.as_str()).collect();
|
||||
machines.iter().map(|machine| machine.id.as_str()).collect();
|
||||
self.cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(setup, _), _| live.contains(setup.as_str()));
|
||||
.retain(|(machine, _), _| live.contains(machine.as_str()));
|
||||
self.gates.lock().unwrap().retain(|(machine, _), gate| {
|
||||
live.contains(machine.as_str()) || Arc::strong_count(gate) > 1
|
||||
});
|
||||
fresh
|
||||
}
|
||||
}
|
||||
@@ -904,6 +1001,31 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::DriverKind;
|
||||
|
||||
#[test]
|
||||
fn refresh_gates_are_shared_per_machine_and_provider_only() {
|
||||
let monitor = UsageMonitor::new(Fixture::default());
|
||||
let claude_here = monitor.gate_for(&("here".to_string(), CLAUDE));
|
||||
let same = monitor.gate_for(&("here".to_string(), CLAUDE));
|
||||
let claude_there = monitor.gate_for(&("there".to_string(), CLAUDE));
|
||||
let codex_here = monitor.gate_for(&("here".to_string(), CODEX));
|
||||
|
||||
let held = claude_here.lock().unwrap();
|
||||
assert!(
|
||||
matches!(same.try_lock(), Err(std::sync::TryLockError::WouldBlock)),
|
||||
"the same machine/provider pair must have one credential writer"
|
||||
);
|
||||
assert!(
|
||||
claude_there.try_lock().is_ok(),
|
||||
"another machine must not wait"
|
||||
);
|
||||
assert!(
|
||||
codex_here.try_lock().is_ok(),
|
||||
"another provider must not wait"
|
||||
);
|
||||
drop(held);
|
||||
assert!(same.try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
|
||||
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
|
||||
@@ -913,9 +1035,9 @@ mod tests {
|
||||
#[test]
|
||||
fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() {
|
||||
let provider = ClaudeUsage {
|
||||
setup: "far".to_string(),
|
||||
setup_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_setup(&unreachable_setup()),
|
||||
machine: "far".to_string(),
|
||||
machine_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_machine(&unreachable_machine()),
|
||||
program: "/opt/claude".to_string(),
|
||||
};
|
||||
// The machine cannot be reached, so the refresh attempt fails there
|
||||
@@ -934,10 +1056,10 @@ mod tests {
|
||||
"the token must never be quoted back"
|
||||
);
|
||||
|
||||
let UsageState::Failed { detail } = provider.still_expired() else {
|
||||
panic!("still expired is a fault");
|
||||
let UsageState::LoginRequired { detail } = provider.still_expired() else {
|
||||
panic!("still expired must offer a new login");
|
||||
};
|
||||
assert!(detail.contains("/opt/claude /login"), "{detail}");
|
||||
assert!(detail.contains("somewhere else"), "{detail}");
|
||||
assert!(!detail.contains("unreachable"), "{detail}");
|
||||
}
|
||||
|
||||
@@ -967,10 +1089,10 @@ mod tests {
|
||||
assert_eq!(windows[3].resets_at, None);
|
||||
}
|
||||
|
||||
/// A setup naming a machine that cannot be dialled, so nothing here touches
|
||||
/// A machine naming a machine that cannot be dialled, so nothing here touches
|
||||
/// the network beyond ssh failing to resolve it.
|
||||
fn unreachable_setup() -> SetupConfig {
|
||||
SetupConfig {
|
||||
fn unreachable_machine() -> MachineConfig {
|
||||
MachineConfig {
|
||||
id: "far".to_string(),
|
||||
name: "somewhere else".to_string(),
|
||||
ssh: Some(crate::config::SshConfig {
|
||||
@@ -993,9 +1115,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_machine_that_cannot_be_asked_says_so_rather_than_looking_logged_out() {
|
||||
let provider = ClaudeUsage {
|
||||
setup: "far".to_string(),
|
||||
setup_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_setup(&unreachable_setup()),
|
||||
machine: "far".to_string(),
|
||||
machine_name: "somewhere else".to_string(),
|
||||
transport: Transport::for_machine(&unreachable_machine()),
|
||||
program: "claude".to_string(),
|
||||
};
|
||||
let snapshot = provider.fetch().remove(0);
|
||||
@@ -1007,8 +1129,8 @@ mod tests {
|
||||
"{:?}",
|
||||
snapshot.state
|
||||
);
|
||||
assert_eq!(snapshot.setup, "far");
|
||||
assert_eq!(snapshot.setup_name, "somewhere else");
|
||||
assert_eq!(snapshot.machine, "far");
|
||||
assert_eq!(snapshot.machine_name, "somewhere else");
|
||||
assert!(snapshot.windows.is_empty());
|
||||
}
|
||||
|
||||
@@ -1043,7 +1165,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn only_machines_that_can_run_claude_are_asked_about_it() {
|
||||
let mut echo_only = unreachable_setup();
|
||||
let mut echo_only = unreachable_machine();
|
||||
echo_only.providers = vec![crate::config::ProviderConfig {
|
||||
name: "echo".to_string(),
|
||||
kind: DriverKind::Echo,
|
||||
@@ -1056,7 +1178,7 @@ mod tests {
|
||||
// otherwise there is no meter to report.
|
||||
let unset = Fixture::new();
|
||||
assert!(providers_for(&echo_only, &unset).is_empty());
|
||||
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
|
||||
assert_eq!(providers_for(&unreachable_machine(), &unset).len(), 1);
|
||||
|
||||
// And with one set, that machine has exactly the invented meter
|
||||
// -- under the name the session's `usageProvider` will name.
|
||||
@@ -1142,8 +1264,8 @@ mod tests {
|
||||
#[test]
|
||||
fn keeps_codex_reserve_as_a_named_pool() {
|
||||
let provider = CodexUsage {
|
||||
setup: "local".to_string(),
|
||||
setup_name: "this machine".to_string(),
|
||||
machine: "local".to_string(),
|
||||
machine_name: "this machine".to_string(),
|
||||
transport: Transport::Here,
|
||||
program: "codex".to_string(),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user