Commit Graph
12 Commits
Author SHA1 Message Date
kosmos b3bb026fab fix(web): the session inventory lists only the keys the chat can open
The sidebar grouped everything that was not `interactive:` under
"other keys", minus the phone's status keys. That included the coding
jobs' `job:<instance>#<id>` keys and forge keys — and those are dead
ends: the message, event and history routes answer only for the
resident prefixes (`is_resident_key`), so opening one 400s and the row
sits there looking busy with nothing inside. A finished job's key was
worse than useless while its dispatch spun: `reacquire`, forever.

`isResidentKey` names the rule once, beside `isInteractive` —
`interactive:`, `task:`, `calendar:` — and the inventory filters on it.
That leaves the phone's keys out as before, keeps scheduled tasks
(their turns have real transcripts), and stops offering keys the chat
surface cannot address. The shell's `nextActiveKey` follows the same
rule: after a delete the chat moves to the next key it can open, or to
none. The full list stays on the wire — the API's inventory is the
dispatcher's, not the client's, and the delete route still answers for
any key with a record.

Consequence worth naming: a `job:` or forge key has no row now, so it
cannot be deleted from the sidebar either.
2026-11-11 23:52:54 +00:00
kosmos bb475856a0 fix(claw): a job dispatch drains its inbox, so its key goes idle
A finished coding job's key never settles. The handler takes its work
from the job table and never polls the inbox, so the item that opened
the dispatch stays there; `release` reads a non-empty inbox as work
that arrived mid-turn, `end_lease` sends the key to re-acquire with a
fresh debounce window, and `admit` hands out a lease when it lapses.
The re-dispatched handler finds the row done, answers `Handled`, and
the key re-acquires again: one lease and two record writes per
debounce window, per finished job, for the rest of the process's life.

Seen on a live daemon: `job:rhema-claw#1..#3` sat in `reacquire` with
`inbox_depth` 1, revisions climbing two every ~40s, hours after the
jobs were done — and every surface that reads the busy rule
(`inbox_depth == 0 && state != reacquire`) called them busy.

Every other handler polls: `interactive.rs` folds the batch into the
turn's input, `handler.rs` drains the forge news. This one has no news
to fold — a job key carries the row's own delivery — so it drains and
drops. The poll's other half matters too: it renews the lease, which a
coding turn running for minutes needs. The keys already spinning on a
running daemon settle by themselves on their next dispatch.

Test: a queued job runs to done through the source and the driver, and
the key it leaves behind is idle with an empty inbox and nothing due
on the next admit pass — the state a leftover item cannot produce.
2026-11-11 23:52:54 +00:00
kosmos cfb3178346 fix(test): read the turn store through events_from and rustfmt the delete tests
The forget test called `TurnStore::events`, which no longer exists — the
store reads through `events_from(key, after, limit)`, so the assertion now
spells the `(seq, event)` pairs it actually returns. The new assertions
were also wider than rustfmt allows, here and in the http/rsd delete
tests; `cargo fmt --all` reformatted them.
2026-11-11 16:44:58 +00:00
kosmos 994df09c8f feat(web): delete a session from the sidebar
A session could be created from the sidebar but never removed, so the
chat surface accumulated every experiment anyone ever named. Each row
now carries a small delete affordance that only names the key: the
confirmation is the shell's, and it says the turn history goes with
the session before calling the new route. On confirm the chat moves to
the first remaining session — the stream's watcher follows the key —
so nothing keeps streaming a session that no longer exists.

The row had to stop being a single button, since a nested button is
invalid HTML: the select button and the delete button now sit in a
container that owns the hover and active surface.
2026-11-11 16:11:07 +00:00
kosmos 8db1e2aa11 feat(claw): delete a session and its turn history over http
A session the human is done with has to be removable, and removing it
must mean forgetting it: `DELETE /v1/sessions/{key}` drops the dispatch
record and the turn history recorded under the key, so re-creating the
same name starts an empty chat instead of replaying someone else's
turns. The route refuses a leased key, because the running turn's own
save would either fail against the missing record or write the session
back after the delete; the inventory is not narrowed to the
`interactive:` prefix, so a forge key deletes on the same route.

The history purge rides the hub, which owns the turn table, and it
lands after the record does: the purge is the one step this side cannot
undo, so it never runs for a delete the dispatcher refused. Purging on
the no-record path as well is what makes a half-done delete repairable
— a retried DELETE finishes the purge instead of 404-ing with the old
turns still under the key, where a session created again under the same
name would replay them.

The sessions routes stay off the agent token's allowlist in
tools/contract: the agent must not be able to delete the conversation
it is serving.
2026-11-11 16:11:07 +00:00
kosmos 853c5a547c fix(claw): the account-watch test names the id the store assigned, and clippy's then_some
The test fabricated `acct-1` as the watch's account id, but `put`
assigns its own, so the dispatch refused with "names account acct-1,
which is gone" — the test proved nothing about the path it covered.
Capture the assigned id instead.

`(secret.kind == SecretKind::Token).then(|| WatchCredential { .. })`
constructs a value with no work, so clippy's unnecessary_lazy_evaluations
failed `checks`; `then_some` says the same thing.
2026-11-08 17:54:15 +01:00
kosmos 875b2f0ed8 fix(claw): an account-watched thread resolves its client without a repo watch
`watch_forge` looked the dispatch key up in the `watches` table and
nothing else, so a thread that arrived through an *account* watch — the
daemon's own account notifications, which cover the whole instance, not
one repo — failed with "no watch configured" unless the same repo also
had a repo watch row. The dispatch is created by the account poll; the
client it needs is the account's. Nothing could hand it over, because the
payload did not name the account either.

The triaged payload now carries `account_id`, the dispatch drains the
inbox *before* resolving the client (the inbox is where that id lands),
and `watch_forge_as` falls back to the account watch that delivered the
news: its host, its credential, its login. A repo watch row still wins
when there is one — a thread with its own watch keeps its own identity —
and `watch_forge` keeps its signature for callers that know nothing about
accounts, falling back to the kind's first account watch.

The client cache is now one helper over `(forge, host, credential)`, so
the dispatch's replies ride the same pool the poll built.
2026-11-08 17:54:15 +01:00
kosmos 838f71738e feat(claw): record every watch's poll health, and say so when a streak starts
A watch whose poll fails was invisible: `detect` turned the failure into
one `eprintln!` per tick and nothing else. A watch that had 401'd every
sixty seconds since the day it was created was indistinguishable — on
`/v1/watches`, in the agent's own `list_watches`, everywhere — from a
repository that had simply been quiet. That is how a dead credential
survives for weeks: the log line scrolls, and nothing else remembers.

`watch_health` is a third table beside `watches` and `account_watches`,
keyed by `(scope, forge, subject)`, holding `last_ok_at`, `last_error`,
`last_error_at` and `consecutive_failures`. Every detect pass records
each poll's outcome — repo watches as `repo`, account watches as
`account` — and the pass that *starts* a streak (the first failure after
a success, or after never having succeeded) pushes one device
notification, naming the watch and quoting the forge's error. The rest of
the streak stays quiet: the row carries the count, so the tick's log line
no longer has to.

Readable where the question gets asked: `/v1/watches` answers a `health`
array alongside the two watch lists, and the `list_watches` tool reports
`last_ok_at`, `last_error` and `consecutive_failures` per repo, so "is
this watch working?" is answerable without the daemon's stderr. A watch
that has never polled has no row and reports empty health, which is what
it is.
2026-11-08 17:54:15 +01:00
kosmos 6468ae9638 fix(claw): a borrowed watch credential carries the account's login
`resolve_watch_token` handed the adapter a bare string, so the client
could only ever try it as a token — and a `secret_kind` is a label the
human typed at `identity_save`, not a fact: a login password saved as a
token resolved as one, 401'd on every poll, and the watch stayed dead
with nothing said.

`resolve_watch_credential` returns the value *and* the login of the
account it came from (`WatchCredential`), the client cache keys on both
(one value sent two ways is two credentials), and the gitea client is
built with `with_login`, so the adapter's 401 retry can reach the
instance's own Basic-auth form.

A row token or the forge's environment variable names no login, so those
tiers resolve exactly as before: `WatchCredential::bare`, `login: None`.
`resolve_watch_token` stays as the token half, for callers with no use
for the login.
2026-11-08 17:54:15 +01:00
kosmos d31dfeae27 fix(forge): a gitea 401 retries once as basic auth
A self-hosted Gitea answers 401 to `Authorization: token <password>` and
200 to the same value as `user:password`, and the account store's
`secret_kind` cannot tell the two apart — it is a label the human typed
at `identity_save`, so a login password saved as a token is
indistinguishable from a real PAT until the forge answers.

`GiteaForge` had one auth path: the token header, on every call. A
watch whose credential resolved to a password therefore 401'd on every
poll, forever, while the same secret worked for `git push` over https
(the credential helper sends it as a password). The watch looked quiet
instead of broken.

`GiteaForge::with_login` names the login that owns the secret, and
`send` tries the token header first, then — on a 401 only — the same
value as Basic auth. The retry is cloned before the token header lands,
so the fallback request carries one `Authorization` header and not two.
No login (a pasted watch token names none) or an unclonable body leaves
the 401 to surface exactly as it did before.

The login reaches the client in the next commit; this one is the adapter
half, tested against a mock that refuses the token header and accepts
the Basic one.
2026-11-08 17:54:15 +01:00
kosmos 6ae21568d0 fix(android): gate the wifi read on ACCESS_WIFI_STATE, and declare it
Reported from the phone's diag log:

  status: crashed: SecurityException: WifiService: Neither user 10519 nor
  current process has android.permission.ACCESS_WIFI_STATE.
  at android.os.Parcel.createExceptionOrNull

`PhoneSensors.wifiSsid` read `WifiManager.connectionInfo` behind nothing
but the location grant, but WifiService gates every WifiManager call on
ACCESS_WIFI_STATE — and it enforces that by throwing SecurityException,
not by answering null. The permission was never declared, so the read
always threw.

The cost is larger than the missing SSID: the throw travels out of
`readSnapshot` into the worker's catch, which logs "status: crashed" and
returns failure — so one undeclared permission took the battery, network,
DND and location topics down with it, and the fleet's rows went stale
while the app looked healthy.

Two halves, both needed:

- the manifest declares ACCESS_WIFI_STATE (a normal permission: declaring
  it is the whole grant, no runtime prompt). An install that predates the
  line keeps the grant set it was installed with, so this alone does not
  stop the crash on an existing phone;
- `wifiSsid` returns null unless the permission is actually held, and
  wraps the read in `runCatching`, so the status report degrades to "no
  wifi name" instead of dying whole. The check is a `hasWifiState` beside
  the existing `hasLocation`, same idiom.
2026-11-08 17:54:15 +01:00
kosmos 9e132fdf71 fix(claw): read a blank since as unset instead of failing the request
The phone's notification poll sent `since` unconditionally, and before
the first ack there is no cursor at all, so it went out as `?since=`.
Axum's `Query` hands a present-but-empty parameter through as an empty
string, and `Option<i64>` only forgives a *missing* key, so the whole
poll answered `400 Failed to deserialize query string: since: cannot
parse integer from empty string` and the phone showed an error instead
of an empty queue.

Both ends: the client omits `since` unless it has a positive id to send,
and a `blank_as_none` deserializer makes a blank value mean "not set"
for `NotificationQuery` and `EventStreamQuery` alike — an empty `since`
reads as "from the start" however it got there.
2026-11-06 20:39:53 +00:00