Rename setups and add provider reauthentication

This commit is contained in:
iris committed 2026-09-12 22:56:43 -04:00
1 parent e9a0f1b9da
commit 7d9df5d572
36 files changed
+1866 -684

No files matched your search

+7 -5
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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
+59 -27
View File
@@ -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
View File
@@ -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)
}
}
@@ -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))
Text(
if (setup.providers.isEmpty()) {
"Nothing found on it. Install something and rediscover."
} else {
setup.providers.joinToString(" · ") { it.name }
},
style = MaterialTheme.typography.bodySmall,
)
if (machine.providers.isEmpty()) {
Text(
"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",
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
+4 -4
View File
@@ -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
+467
View File
@@ -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
View File
@@ -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
View File
@@ -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()
};
+1 -1
View File
@@ -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".
+1 -1
View File
@@ -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
View File
@@ -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(),
+20 -20
View File
@@ -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);
}
+5 -5
View File
@@ -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
View File
@@ -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(),
};